From 866739e69768dd756c01014345f53961869b6f48 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Sun, 6 Sep 2026 13:05:08 +0200 Subject: [PATCH] Le contacteur ne se rend plus au milieu du service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La regle du 05/09 ferme quand la maison depasse 80 % du plafond de decharge du parc — elle a mordu au bon instant hier soir, a -97,4 A pour 100 A autorises. Mais elle relachait sur un seul echantillon sous 50 %, et la soiree offrait quatorze occasions de le faire. Le troisieme pic est tombe deux minutes avant l'ouverture reelle : decale de cinq minutes, elle refermait, et chaque aller-retour du contacteur coute 50 a 150 Wh. Les accalmies mesurees sont franchement bimodales — treize de 5 min au plus pendant la cuisson, une de 201 min apres. Le relachement se mesure donc en duree, et la duree vient du bilan energetique : 20 min tant que la batterie est condamnee a toucher son plancher avant les heures creuses (l'energie reseau du soir est due de toute facon, la tenir ne change que son heure), 6 min des qu'elle passerait la nuit seule (chaque kWh du soir deplacerait alors un kWh d'heures creuses vers des heures pleines). Premiere lecture du bilan par le workflow d'hysteresis. Le dwell se compte sur la maison, jamais sur le contacteur : l'horodatage est ecrit qu'il soit ouvert ou ferme, sans quoi la decision detruirait sa propre cause. Et parce que la tension batterie s'est tue 27 minutes hier soir en pleine cuisson, le plafond retombe sur la tension nominale du parc, et a defaut l'horodatage — un fait persiste — tient la protection jusqu'au bout du dwell. Golden-hysteresis 11 -> 20. Six mutations, chacune tombant sur un jeu different. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ --- HANDOFF.md | 149 +++++++++ docs/MANUEL_COMPLET.md | 49 +++ docs/mesures/batteries/202609052040-suivi.csv | 287 ++++++++++++++++++ tests/golden-hysteresis/fixture-snapshot.json | 14 +- tests/golden-hysteresis/run.mjs | 5 + tests/golden-hysteresis/scenarios.mjs | 221 ++++++++++++++ .../patch-20260906-relachement-sur-duree.mjs | 270 ++++++++++++++++ workflows/victron-grid-hysteresis-v1.json | 6 +- 8 files changed, 991 insertions(+), 10 deletions(-) create mode 100644 tools/patch-20260906-relachement-sur-duree.mjs diff --git a/HANDOFF.md b/HANDOFF.md index cbd213b..10e304a 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4353,3 +4353,152 @@ ce qui confirme la commune retenue pour l'integration Meteo-France (§32). --- *Document mis a jour le : 05/09/2026 — parc date par les factures ; le passage en parallele de mars 2025 est disculpe* + +## 57. Session du 06/09/2026 — Le relachement du contacteur se mesurait sur un instant + +### La nuit du 05-06/09, et une erreur de cadrage de ma part +Premiere nuit a quatre modules. La soiree a vide le pack : **87 % -> 20 % entre +20:00 et 00:00, 6,80 kWh sortis** pour une maison a 1,2-3 kW. A 23:55 le SOC a +touche le seuil bas de l'hysteresis ET le plancher ESS (20 dans les deux cas), +le contacteur s'est ferme, l'ESS est passe en `self_consumption_soc_below_min`. +A 01:24 la fenetre HC s'est ouverte, la session reseau unique (§40) s'est +engagee, l'ACM est passe en `evcs_takeover_recover` et **c'est lui qui a charge +la batterie**, vers `night_target_soc` — cible issue du bilan depuis le 03/09 +(§39), qui demandait 11,71 kWh a couvrir, soit plus de 100 % de SOC avec quatre +modules, donc 90 (plafond) puis 80,5 a 01:00. + +La charge s'est arretee a 05:05 quand le SOC (55) a rattrape la cible qui +descendait (49,4) -> `evcs_takeover_hold`, DVCC 2 A. Bilan de la nuit : +**13,34 kWh importes**, dont 4,23 dans la JEEP, **3,28 dans la batterie** et +~5,8 directement dans la maison. Verification par la suite : a 06:47 la JEEP est +debranchee, EDF ouvert, et la batterie a porte la maison de 55 % a 45 % jusqu'au +croisement PV vers 08:45 — **0,99 kWh consommes contre 1,49 annonces a 06:00**. +La chaine a fonctionne. + +⚠️ **J'ai d'abord presente la vidange du soir comme un defaut. C'est faux** : la +batterie a delivre 6,80 kWh en heures pleines, ce qui est exactement son metier, +et elle est partie de 87 % — elle ne pouvait pas etre plus pleine. Les 1,4 kWh +achetes de 23:55 a 01:24 l'auraient ete de toute facon. Le defaut est ailleurs. + +### Le defaut : treize occasions de relacher au milieu du service +La regle du 05/09 ferme quand la maison depasse 80 % du plafond de decharge du +BMS et relachait des que la charge repassait sous 50 % — **sur un seul +echantillon**. Releve de la soiree : + +| | | +|---|---| +| pic de decharge a 20:40 | **-97,4 A** pour un plafond de **100 A** (maison 4 944 W) | +| depassements du seuil de fermeture (4 080 W) | 3 : 20:34:20, 20:39:30, **21:01:55** | +| passages sous le seuil de relachement (2 550 W) | **14** en quatre heures | +| contacteur ouvert a | 21:04 | +| maison apres le relachement | jusqu'a **3 423 W** a 21:37, soit -73 A, 73 % du plafond | + +La fermeture a mordu au bon instant, a 2,6 A de la limite du parc. Mais le +troisieme pic est tombe **deux minutes avant l'ouverture** : decale de cinq +minutes, la regle refermait, et chaque aller-retour du contacteur coute 50 a +150 Wh de transitoire (§28/§31). Le battement n'a pas eu lieu par chance, pas +par construction. + +### La mesure qui donne les durees (et rend tout choix inutile) +Duree des accalmies sous 2 550 W, pas d'une minute : + +| periode | accalmies | la plus longue | +|---|---:|---:| +| pendant la cuisson, 20:33 -> 21:37 | **13** | **5 min** | +| apres la cuisson, 21:40 -> 01:00 | 1 | **201 min** | + +Distribution franchement bimodale, fosse net entre 5 et 201 minutes. **Un creux +de vingt minutes n'existe pas tant que ca cuisine.** Les deux durees se lisent +donc sur la mesure : **20 min** (milieu du fosse, marge x4) et **6 min** (juste +au-dessus de la plus longue accalmie de cuisson). + +### Le correctif (deploye 06/09 13:03, golden-hysteresis 20/20) +`tools/patch-20260906-relachement-sur-duree.mjs`, rejouable et idempotent. + +**Fermeture inchangee** — c'est une contrainte de PUISSANCE, ni le SOC ni un +budget d'energie n'ont rien a y dire, et le plafond vient deja du BMS donc il +suit le nombre de modules tout seul. + +**Relachement sur une DUREE** : la maison doit rester sous le seuil bas pendant +N minutes. Meme mecanisme que la tolerance temporelle de la politique batterie +(§35) — un franchissement de seuil doit DURER pour compter. + +**Et N vient du bilan energetique.** Tant que la batterie est de toute facon +condamnee a toucher son plancher avant l'ouverture des heures creuses, le +partage batterie/reseau de la soiree est FIGE : tenir le contacteur plus +longtemps ne change que l'HEURE d'achat, pas le montant. Des qu'elle passerait +la nuit seule, chaque kWh du soir deplace un kWh d'heures creuses vers des +heures pleines. D'ou 20 min dans le premier cas, 6 dans le second. +C'est la premiere lecture du bilan par le workflow d'hysteresis, qui n'en lisait +aucun champ (trou structurel signale en §11.8.A). + +⚠️ **APPROXIMATION ASSUMEE** : le bilan publie `required_soc`, qui vise le +CROISEMENT PV — cible strictement plus lointaine que 01:24. `soc < +required_soc` est donc NECESSAIRE mais pas SUFFISANT pour « la batterie +n'atteindra pas l'ouverture des HC ». L'erreur est unilaterale : on tient +parfois le long dwell une nuit ou le court aurait suffi, ce qui deplace ~0,4 kWh +des heures creuses vers les heures pleines et menage le parc. Direction voulue. + +⚠️ **LE DWELL SE COMPTE SUR LA MAISON.** Le nouvel horodatage +`input_datetime.victron_grid_charge_high_last_at` porte la derniere charge +maison HAUTE et il est ecrit **que le contacteur soit ouvert ou ferme**. Meme +raison qu'au 05/09 : fermer le contacteur fait chuter le courant batterie, s'en +servir comme critere ferait que la decision detruirait sa propre cause — le +piege des quatre oscillateurs deja corriges. Horodatage absent ou illisible => +dwell repute ecoule => comportement d'avant ce correctif. + +Helpers : paquet `victron_grid_charge_dwell.yaml` (`..._dwell_long_min` 20, +`..._dwell_court_min` 6, `..._charge_high_last_at`), crees par +`input_number.reload` + `input_datetime.reload` — **aucun redemarrage HA**. + +### Capteurs muets — un mode de defaillance observe, pas suppose +Le 05/09 **de 21:22:21 a 21:49:35, soit 27 minutes**, +`sensor.victron_battery_current` et `sensor.victron_battery_voltage` n'ont RIEN +publie, pendant que `sensor.victron_consumption_power_l1` continuait +normalement. Le SOC, lui, a publie en boucle une valeur figee a 60 % alors qu'il +tombait reellement a 49 %. Un courant fige a -73,2 A pendant que la maison +oscille entre 1,4 et 3,4 kW est physiquement impossible. + +Consequences : les `battery_current_hard_stop` du controleur EVCS enregistres a +21:31, 21:36, 21:38 et 21:41 sont des decisions prises sur une valeur vieille +d'une demi-heure ; et **la tension alimente `plafondDechargeW`**, donc sans +repli la protection s'evanouissait pile a l'heure du diner. + +Deux filets, tous deux fondes sur du mesure ou du persiste : +1. plafond en AMPERES lisible mais tension muette => **tension nominale 51 V**. + Sur toute la soiree la tension mesuree n'est pas sortie de 48,85-51,5 V : au + pire 5 % d'erreur sur le seuil, contre 100 % si la regle s'eteint ; +2. les deux muets => l'horodatage de charge haute reste un **fait persiste** : + on tient jusqu'a la fin du dwell. La memoire ne survit pas au dwell, puisque + l'horodatage n'est ecrit que quand le plafond est lisible. + +### Golden-hysteresis 11 -> 20 +`h-dwell-accalmie-de-cuisson-ne-relache-pas` (le releve reel : accalmie de +5 min, SOC 65 sous les 92 requis) et son contrefactuel a 25 min · trois +scenarios a **8 minutes** de calme, entre les deux durees, que **seul l'etat du +bilan separe** (frais+condamne -> tient · frais+reserve suffisante -> rend · +perime -> rend) · l'horodatage ecrit contacteur OUVERT · les trois cas de +capteurs muets avec leur contrefactuel de memoire expiree. + +Preuve par mutation, six directions, **chacune faisant tomber un jeu different** +et aucune plus d'un ou deux scenarios : dwell retire · bilan ignore · garde de +fraicheur retiree · horodatage ecrit seulement contacteur ferme · repli de +tension retire · memoire de protection retiree. Restauration -> 20/20. + +### A surveiller ce soir +1. `input_datetime.victron_grid_charge_high_last_at` doit avancer pendant la + cuisson et se figer ensuite ; +2. au plus UNE fermeture et UNE ouverture du contacteur sur la soiree ; +3. la raison doit afficher `Charge maison retombee a XW depuis Y min : on garde + le reseau jusqu'a 20 min (reserve deja condamnee...)` ; +4. si la tension batterie se remet a disparaitre, `plafond_decharge_w` doit + rester a ~5100 et non tomber a 0. + +### Reste ouvert +- Detecteur de gel dans le service d'historisation : une grandeur qui ne bouge + pas alors qu'une autre du meme bilan bouge = alerte. Non fait. +- `grid_soc_on` / `grid_soc_off` (20/30) restent des niveaux fixes : c'est le + dernier consommateur du bilan qui n'est pas cable (§11.8.A). + +--- +*Document mis a jour le : 06/09/2026 (~13:10) — relachement du contacteur sur une duree gagee sur le bilan, golden-hysteresis 20/20, deploye* diff --git a/docs/MANUEL_COMPLET.md b/docs/MANUEL_COMPLET.md index 926d9fa..d33e151 100644 --- a/docs/MANUEL_COMPLET.md +++ b/docs/MANUEL_COMPLET.md @@ -941,6 +941,55 @@ que la charge, en donnant un preneur au MPPT, detruirait sa propre autorisation — le bistable P6 du 24/08. Lectures indisponibles → repli sur le comportement anterieur. Golden 64 → 66. Detail : HANDOFF §37. +#### D-bis. Le relachement du contacteur se mesurait sur un instant (06/09/2026) + +La regle du 05/09 ferme le contacteur EDF quand la maison demande plus de 80 % +de ce que le parc sait delivrer — elle a mordu au bon moment le 05/09 au soir, +a **-97,4 A pour un plafond BMS de 100 A**. Mais elle relachait des que la +charge repassait sous 50 %, sur un SEUL echantillon. Duree des accalmies sous +2 550 W, pas d'une minute : + +| periode | accalmies | la plus longue | +|---|---:|---:| +| pendant la cuisson, 20:33 -> 21:37 | **13** | **5 min** | +| apres la cuisson, 21:40 -> 01:00 | 1 | **201 min** | + +Treize occasions de rendre le reseau au milieu du service, et chaque +aller-retour du contacteur coute 50 a 150 Wh de transitoire. Le troisieme pic +de la soiree (4 236 W a 21:01:55) est tombe **deux minutes avant l'ouverture +reelle** : decale de cinq minutes, la regle refermait. + +Correctif : le relachement se mesure en DUREE — la maison doit rester sous le +seuil bas pendant N minutes, meme mecanisme que la tolerance temporelle de la +politique batterie (§C). Les deux valeurs se lisent sur la distribution +ci-dessus, franchement bimodale : **20 min** (milieu du fosse) et **6 min** +(juste au-dessus de la plus longue accalmie de cuisson). + +**Et N vient du bilan energetique** — premiere lecture du bilan par le workflow +d'hysteresis, qui n'en lisait aucun champ. Tant que la batterie est de toute +facon condamnee a toucher son plancher avant l'ouverture des heures creuses, le +partage batterie/reseau de la soiree est fige : tenir le contacteur plus +longtemps ne change que l'HEURE d'achat, pas le montant. Des qu'elle passerait +la nuit seule, chaque kWh du soir deplace un kWh d'heures creuses vers des +heures pleines, et il faut relacher vite. + +⚠️ Le dwell se compte sur la MAISON, jamais sur le contacteur ni sur le courant +batterie : l'horodatage `input_datetime.victron_grid_charge_high_last_at` est +ecrit que le contacteur soit ouvert ou ferme. Fermer le contacteur fait chuter +le courant batterie ; s'en servir comme critere ferait que la decision +detruirait sa propre cause. + +⚠️ Mode de defaillance observe, pas suppose : le 05/09 de 21:22 a 21:49, la +tension et le courant batterie n'ont RIEN publie pendant 27 minutes. La tension +alimentant le plafond de decharge, la protection se serait eteinte pile a +l'heure du diner. Deux replis : tension nominale 51 V quand le plafond en +amperes reste lisible (48,85-51,5 V mesures sur la soiree, donc 5 % d'erreur au +pire), et a defaut l'horodatage de charge haute — un fait persiste — tient la +protection jusqu'a la fin du dwell. + +Golden-hysteresis 11 -> 20, six mutations tombant chacune sur un jeu different. +Detail : HANDOFF §57. + #### E. Le plancher du soir etait un niveau, pas une energie (03/09/2026) `evening_soc_start_min` = 85 % tente de dire « garde de quoi passer la nuit ». diff --git a/docs/mesures/batteries/202609052040-suivi.csv b/docs/mesures/batteries/202609052040-suivi.csv index 9e3fb46..77b2ced 100644 --- a/docs/mesures/batteries/202609052040-suivi.csv +++ b/docs/mesures/batteries/202609052040-suivi.csv @@ -101,3 +101,290 @@ heure_reelle,etat,courant_mA,c1_mV,c2_mV,c3_mV,c4_mV,c5_mV,c6_mV,c7_mV,c8_mV,c9_ 2026-09-05T22:50:32.234Z,Y230202C30371425,,3270,3271,3270,3271,3269,3268,3271,3269,3269,3271,3271,3271,3270,3272,3270,4 2026-09-05T22:50:32.360Z,Y230202C30371431,,3271,3270,3272,3271,3268,3270,3270,3270,3270,3269,3272,3270,3270,3268,3270,4 2026-09-05T22:50:32.485Z,Y230202C30371427,,3271,3270,3270,3272,3270,3271,3270,3270,3270,3270,3271,3270,3271,3269,3271,3 +2026-09-05T23:00:32.504Z,Y230202C30371426,,3270,3271,3270,3271,3271,3269,3270,3271,3269,3269,3270,3270,3270,3270,3271,2 +2026-09-05T23:00:32.607Z,Y230202C30371425,,3270,3271,3270,3271,3269,3268,3271,3269,3269,3271,3272,3271,3270,3272,3269,4 +2026-09-05T23:00:32.731Z,Y230202C30371431,,3271,3270,3272,3271,3268,3270,3270,3270,3270,3270,3272,3270,3270,3268,3270,4 +2026-09-05T23:00:32.856Z,Y230202C30371427,,3271,3270,3270,3272,3269,3271,3270,3270,3270,3270,3271,3269,3271,3269,3271,3 +2026-09-05T23:10:32.960Z,Y230202C30371426,,3270,3271,3270,3271,3271,3269,3270,3271,3269,3269,3270,3270,3270,3270,3271,2 +2026-09-05T23:10:33.057Z,Y230202C30371425,,3270,3271,3270,3271,3269,3268,3271,3269,3269,3271,3271,3271,3270,3272,3270,4 +2026-09-05T23:10:33.273Z,Y230202C30371427,,3271,3270,3272,3271,3268,3270,3270,3270,3270,3269,3272,3270,3270,3268,3270,4 +2026-09-05T23:20:33.334Z,Y230202C30371426,,3271,3270,3270,3272,3270,3271,3270,3270,3270,3270,3271,3270,3271,3269,3271,3270,3271,3270,3271,3271,3269,3270,3271,3269,3269,3270,3270,3270,3270,3271,3 +2026-09-05T23:20:33.463Z,Y230202C30371425,,3270,3271,3270,3271,3269,3268,3271,3269,3269,3271,3272,3271,3270,3272,3270,4 +2026-09-05T23:20:33.559Z,Y230202C30371431,,3271,3270,3272,3271,3268,3270,3270,3270,3270,3269,3272,3270,3270,3268,3270,4 +2026-09-05T23:20:33.685Z,Y230202C30371427,,3271,3270,3270,3272,3269,3271,3270,3270,3270,3270,3271,3270,3271,3269,3270,3 +2026-09-05T23:30:33.787Z,Y230202C30371426,,3270,3271,3270,3271,3271,3269,3270,3271,3269,3269,3270,3270,3270,3270,3271,2 +2026-09-05T23:30:33.884Z,Y230202C30371425,,3315,3315,3314,3315,3314,3313,3316,3314,3312,3315,3317,3316,3315,3317,3314,5 +2026-09-05T23:30:34.008Z,Y230202C30371431,,3315,3313,3316,3314,3312,3314,3313,3313,3313,3313,3317,3313,3313,3312,3313,5 +2026-09-05T23:30:34.133Z,Y230202C30371427,,3318,3317,3316,3317,3317,3318,3316,3316,3317,3317,3318,3315,3317,3316,3318,3 +2026-09-05T23:40:34.237Z,Y230202C30371426,,3315,3314,3314,3315,3315,3314,3314,3315,3313,3313,3314,3314,3314,3313,3316,3 +2026-09-05T23:40:34.364Z,Y230202C30371425,,3315,3315,3313,3314,3313,3313,3315,3313,3312,3314,3316,3315,3314,3316,3313,4 +2026-09-05T23:40:34.460Z,Y230202C30371431,,3314,3312,3315,3314,3312,3313,3312,3313,3313,3313,3315,3312,3313,3312,3313,3 +2026-09-05T23:40:34.586Z,Y230202C30371427,,3315,3315,3314,3316,3314,3315,3314,3314,3314,3314,3315,3314,3315,3314,3315,2 +2026-09-05T23:50:34.689Z,Y230202C30371426,,3314,3314,3313,3314,3315,3313,3313,3314,3313,3313,3313,3313,3314,3313,3315,2 +2026-09-05T23:50:34.787Z,Y230202C30371425,,3316,3316,3315,3315,3315,3315,3316,3315,3314,3315,3317,3316,3316,3316,3315,3 +2026-09-05T23:50:34.913Z,Y230202C30371431,,3318,3317,3318,3317,3316,3318,3316,3317,3317,3316,3319,3317,3317,3317,3317,3 +2026-09-05T23:50:35.098Z,Y230202C30371427,,3317,3316,3316,3317,3316,3317,3316,3316,3316,3316,3317,3316,3316,3316,3317,1 +2026-09-06T00:00:35.201Z,Y230202C30371426,,3316,3316,3316,3317,3317,3316,3316,3316,3316,3316,3316,3316,3316,3316,3318,2 +2026-09-06T00:00:35.330Z,Y230202C30371425,,3323,3322,3322,3322,3322,3322,3322,3321,3321,3322,3323,3322,3322,3322,3322,2 +2026-09-06T00:00:35.425Z,Y230202C30371431,,3320,3319,3320,3319,3319,3320,3319,3319,3319,3319,3321,3320,3320,3319,3319,2 +2026-09-06T00:00:35.550Z,Y230202C30371427,,3321,3320,3320,3320,3321,3320,3320,3320,3320,3320,3321,3320,3320,3320,3321,1 +2026-09-06T00:10:35.636Z,Y230202C30371426,,3320,3319,3319,3319,3319,3319,3319,3319,3318,3318,3320,3320,3319,3319,3319,2 +2026-09-06T00:10:35.763Z,Y230202C30371425,,3319,3319,3319,3319,3319,3319,3319,3318,3319,3319,3320,3319,3319,3319,3319,2 +2026-09-06T00:10:35.857Z,Y230202C30371431,,3320,3320,3320,3320,3320,3320,3319,3320,3320,3320,3321,3320,3320,3320,3321,2 +2026-09-06T00:10:35.982Z,Y230202C30371427,,3321,3320,3320,3320,3321,3321,3319,3320,3320,3320,3321,3320,3320,3321,3321,2 +2026-09-06T00:20:36.081Z,Y230202C30371426,,3322,3321,3321,3321,3322,3321,3321,3321,3321,3322,3322,3321,3321,3321,3322,1 +2026-09-06T00:20:36.209Z,Y230202C30371425,,3323,3322,3322,3323,3323,3322,3322,3322,3322,3324,3323,3323,3323,3323,3324,2 +2026-09-06T00:20:36.305Z,Y230202C30371431,,3324,3323,3323,3322,3322,3324,3322,3322,3322,3322,3324,3323,3323,3323,3323,2 +2026-09-06T00:20:36.430Z,Y230202C30371427,,3323,3322,3322,3322,3322,3323,3322,3321,3322,3321,3323,3322,3322,3322,3322,2 +2026-09-06T00:30:36.533Z,Y230202C30371426,,3323,3322,3322,3322,3322,3323,3322,3322,3322,3322,3323,3323,3322,3322,3323,1 +2026-09-06T00:30:36.632Z,Y230202C30371425,,3323,3322,3322,3322,3322,3322,3322,3321,3322,3322,3323,3322,3322,3323,3322,2 +2026-09-06T00:30:36.758Z,Y230202C30371431,,3324,3323,3323,3323,3323,3323,3322,3323,3323,3323,3324,3323,3323,3323,3324,2 +2026-09-06T00:30:36.914Z,Y230202C30371427,,3323,3322,3323,3323,3324,3323,3322,3322,3323,3323,3324,3322,3323,3323,3324,2 +2026-09-06T00:40:37.017Z,Y230202C30371426,,3324,3323,3323,3324,3324,3324,3323,3323,3323,3323,3325,3324,3324,3323,3324,2 +2026-09-06T00:40:37.144Z,Y230202C30371425,,3321,3320,3320,3321,3321,3321,3320,3320,3320,3322,3322,3321,3321,3321,3322,2 +2026-09-06T00:40:37.239Z,Y230202C30371431,,3321,3321,3321,3320,3320,3321,3320,3320,3320,3320,3322,3321,3321,3321,3321,2 +2026-09-06T00:40:37.364Z,Y230202C30371427,,3319,3319,3319,3319,3320,3319,3318,3319,3319,3319,3319,3319,3319,3320,3320,2 +2026-09-06T00:50:37.468Z,Y230202C30371426,,3320,3319,3319,3319,3319,3319,3319,3319,3318,3318,3320,3320,3319,3319,3319,2 +2026-09-06T00:50:37.594Z,Y230202C30371425,,3323,3323,3323,3323,3323,3323,3323,3322,3323,3324,3324,3323,3323,3324,3324,2 +2026-09-06T00:50:37.688Z,Y230202C30371431,,3323,3323,3323,3323,3323,3323,3322,3322,3322,3323,3324,3323,3323,3323,3324,2 +2026-09-06T00:50:37.812Z,Y230202C30371427,,3323,3323,3323,3323,3324,3323,3322,3323,3323,3323,3324,3323,3323,3324,3324,2 +2026-09-06T01:00:37.910Z,Y230202C30371426,,3324,3323,3323,3323,3323,3324,3323,3323,3322,3323,3325,3324,3323,3323,3324,3 +2026-09-06T01:00:38.038Z,Y230202C30371425,,3320,3320,3320,3320,3320,3320,3320,3319,3319,3320,3321,3321,3320,3320,3320,2 +2026-09-06T01:00:38.132Z,Y230202C30371431,,3321,3320,3321,3320,3320,3321,3320,3320,3320,3321,3321,3320,3321,3320,3321,1 +2026-09-06T01:00:38.258Z,Y230202C30371427,,3321,3321,3321,3321,3321,3321,3320,3320,3320,3320,3321,3321,3321,3321,3321,1 +2026-09-06T01:10:38.362Z,Y230202C30371426,,3320,3320,3320,3320,3320,3320,3320,3320,3319,3319,3321,3320,3320,3319,3320,2 +2026-09-06T01:10:38.490Z,Y230202C30371425,,3321,3321,3321,3321,3321,3321,3321,3320,3321,3321,3322,3322,3321,3322,3321,2 +2026-09-06T01:10:38.585Z,Y230202C30371431,,3321,3320,3321,3320,3320,3321,3320,3320,3320,3320,3321,3321,3321,3321,3321,1 +2026-09-06T01:10:38.709Z,Y230202C30371427,,3323,3323,3323,3323,3324,3323,3322,3323,3323,3323,3324,3323,3323,3324,3324,2 +2026-09-06T01:20:38.813Z,Y230202C30371426,,3323,3322,3322,3323,3324,3323,3322,3323,3322,3324,3323,3323,3323,3323,3324,2 +2026-09-06T01:20:38.910Z,Y230202C30371425,,3324,3323,3323,3324,3324,3323,3323,3322,3323,3324,3324,3324,3324,3324,3324,2 +2026-09-06T01:20:39.127Z,Y230202C30371427,,3323,3323,3323,3322,3323,3323,3322,3322,3322,3323,3324,3323,3323,3323,3324,2 +2026-09-06T01:30:39.139Z,Y230202C30371426,,3325,3324,3323,3323,3324,3325,3323,3323,3323,3323,3325,3324,3324,3324,3324,3325,3324,3323,3324,3324,3325,3324,3323,3323,3323,3325,3324,3323,3323,3324,2 +2026-09-06T01:30:39.237Z,Y230202C30371425,,3323,3322,3322,3322,3322,3322,3322,3321,3321,3323,3323,3323,3322,3323,3323,2 +2026-09-06T01:30:39.362Z,Y230202C30371431,,3323,3323,3323,3322,3323,3323,3321,3322,3322,3322,3324,3322,3322,3323,3323,3 +2026-09-06T01:30:39.486Z,Y230202C30371427,,3321,3320,3321,3321,3322,3321,3320,3321,3321,3321,3321,3320,3321,3322,3322,2 +2026-09-06T01:40:39.552Z,Y230202C30371426,,3323,3322,3321,3322,3322,3322,3322,3322,3321,3321,3323,3322,3322,3322,3322,2 +2026-09-06T01:40:39.650Z,Y230202C30371425,,3326,3325,3325,3325,3325,3325,3325,3324,3324,3325,3326,3326,3326,3326,3325,2 +2026-09-06T01:40:39.777Z,Y230202C30371431,,3325,3324,3324,3323,3323,3325,3323,3324,3323,3323,3326,3324,3324,3324,3324,3 +2026-09-06T01:40:39.903Z,Y230202C30371427,,3324,3323,3323,3323,3324,3324,3323,3323,3323,3324,3325,3323,3324,3324,3324,2 +2026-09-06T01:50:40.007Z,Y230202C30371426,,3324,3323,3323,3323,3323,3323,3323,3323,3322,3322,3324,3323,3323,3322,3323,2 +2026-09-06T01:50:40.136Z,Y230202C30371425,,3323,3323,3322,3322,3321,3322,3322,3321,3321,3322,3323,3323,3323,3323,3322,2 +2026-09-06T01:50:40.231Z,Y230202C30371431,,3320,3320,3320,3320,3319,3320,3319,3320,3319,3319,3321,3320,3320,3320,3320,2 +2026-09-06T01:50:40.358Z,Y230202C30371427,,3322,3322,3321,3322,3322,3322,3321,3321,3321,3321,3323,3322,3322,3322,3322,2 +2026-09-06T02:00:40.416Z,Y230202C30371426,,3324,3323,3322,3323,3323,3323,3322,3323,3322,3322,3324,3323,3323,3322,3323,2 +2026-09-06T02:00:40.543Z,Y230202C30371425,,3324,3324,3323,3324,3323,3324,3324,3322,3322,3324,3325,3324,3324,3324,3323,3 +2026-09-06T02:00:40.638Z,Y230202C30371431,,3323,3322,3324,3323,3323,3323,3321,3323,3323,3323,3324,3322,3323,3323,3324,3 +2026-09-06T02:00:40.824Z,Y230202C30371427,,3322,3322,3322,3322,3322,3322,3321,3321,3321,3321,3323,3322,3322,3322,3322,2 +2026-09-06T02:10:40.928Z,Y230202C30371426,,3324,3323,3324,3324,3325,3324,3323,3324,3323,3324,3325,3324,3324,3323,3325,2 +2026-09-06T02:10:41.055Z,Y230202C30371425,,3326,3326,3325,3325,3325,3326,3326,3324,3324,3326,3327,3327,3326,3326,3325,3 +2026-09-06T02:10:41.149Z,Y230202C30371431,,3326,3325,3325,3325,3324,3325,3323,3324,3324,3324,3326,3325,3325,3325,3325,3 +2026-09-06T02:10:41.273Z,Y230202C30371427,,3326,3325,3324,3325,3325,3325,3324,3324,3324,3325,3326,3325,3325,3325,3326,2 +2026-09-06T02:20:41.377Z,Y230202C30371426,,3324,3323,3324,3324,3325,3324,3323,3324,3323,3324,3324,3324,3324,3323,3325,2 +2026-09-06T02:20:41.474Z,Y230202C30371425,,3326,3325,3325,3325,3325,3325,3325,3324,3324,3325,3327,3326,3325,3326,3325,3 +2026-09-06T02:20:41.598Z,Y230202C30371431,,3327,3325,3326,3326,3325,3326,3324,3325,3325,3325,3327,3325,3326,3326,3326,3 +2026-09-06T02:20:41.723Z,Y230202C30371427,,3326,3325,3326,3326,3326,3326,3325,3325,3325,3325,3327,3325,3326,3326,3326,2 +2026-09-06T02:30:41.825Z,Y230202C30371426,,3327,3326,3326,3327,3327,3327,3326,3326,3325,3326,3327,3326,3326,3326,3327,2 +2026-09-06T02:30:41.954Z,Y230202C30371425,,3322,3322,3321,3322,3320,3321,3322,3320,3320,3321,3323,3322,3321,3322,3321,3 +2026-09-06T02:30:42.050Z,Y230202C30371431,,3324,3323,3324,3323,3323,3324,3322,3323,3323,3323,3324,3323,3323,3323,3324,2 +2026-09-06T02:30:42.176Z,Y230202C30371427,,3324,3324,3323,3324,3324,3324,3323,3323,3323,3323,3324,3323,3324,3324,3324,1 +2026-09-06T02:40:42.280Z,Y230202C30371426,,3322,3322,3322,3322,3322,3322,3322,3322,3321,3320,3322,3322,3322,3321,3322,2 +2026-09-06T02:40:42.408Z,Y230202C30371425,,3327,3326,3326,3327,3326,3326,3327,3325,3326,3327,3328,3327,3327,3328,3327,3 +2026-09-06T02:40:42.504Z,Y230202C30371431,,3325,3324,3325,3324,3323,3325,3323,3324,3323,3323,3326,3324,3324,3324,3324,3 +2026-09-06T02:40:42.630Z,Y230202C30371427,,3325,3324,3324,3325,3325,3324,3324,3324,3324,3324,3325,3324,3325,3325,3325,1 +2026-09-06T02:50:42.733Z,Y230202C30371426,,3328,3327,3326,3327,3327,3327,3327,3327,3325,3326,3327,3327,3327,3326,3327,3 +2026-09-06T02:50:42.830Z,Y230202C30371425,,3326,3326,3326,3327,3325,3326,3326,3324,3325,3326,3327,3327,3326,3327,3326,3 +2026-09-06T02:50:43.046Z,Y230202C30371427,,3326,3325,3326,3325,3323,3326,3323,3325,3324,3324,3327,3325,3325,3324,3324,4 +2026-09-06T03:00:43.149Z,Y230202C30371426,,3326,3325,3325,3326,3326,3326,3325,3325,3325,3325,3326,3325,3326,3326,3326,3326,3326,3325,3326,3326,3326,3325,3325,3324,3324,3326,3325,3325,3324,3326,2 +2026-09-06T03:00:43.277Z,Y230202C30371425,,3324,3324,3324,3325,3323,3323,3325,3323,3323,3324,3325,3325,3324,3325,3324,2 +2026-09-06T03:00:43.373Z,Y230202C30371431,,3327,3325,3326,3325,3323,3326,3324,3325,3324,3324,3327,3325,3325,3325,3324,4 +2026-09-06T03:00:43.498Z,Y230202C30371427,,3326,3325,3324,3325,3325,3325,3324,3324,3324,3325,3325,3324,3325,3325,3326,2 +2026-09-06T03:10:43.581Z,Y230202C30371426,,3326,3326,3325,3326,3327,3326,3326,3326,3325,3325,3326,3325,3326,3325,3327,2 +2026-09-06T03:10:43.710Z,Y230202C30371425,,3315,3315,3315,3315,3314,3315,3315,3314,3314,3315,3316,3315,3315,3316,3315,2 +2026-09-06T03:10:43.804Z,Y230202C30371431,,3315,3315,3316,3315,3313,3315,3314,3315,3314,3314,3316,3314,3315,3315,3315,3 +2026-09-06T03:10:43.931Z,Y230202C30371427,,3314,3314,3314,3315,3314,3314,3314,3314,3314,3313,3314,3314,3314,3314,3315,2 +2026-09-06T03:20:44.035Z,Y230202C30371426,,3314,3314,3314,3315,3315,3314,3314,3315,3314,3314,3314,3314,3315,3314,3315,1 +2026-09-06T03:20:44.163Z,Y230202C30371425,,3315,3314,3314,3314,3313,3314,3315,3313,3313,3314,3315,3314,3314,3315,3313,2 +2026-09-06T03:20:44.258Z,Y230202C30371431,,3314,3314,3315,3314,3312,3314,3313,3314,3313,3313,3314,3314,3314,3314,3313,3 +2026-09-06T03:20:44.383Z,Y230202C30371427,,3315,3314,3314,3315,3314,3314,3314,3314,3314,3313,3315,3314,3314,3314,3314,2 +2026-09-06T03:30:44.451Z,Y230202C30371426,,3314,3315,3314,3315,3314,3314,3315,3314,3314,3313,3314,3314,3314,3314,3315,2 +2026-09-06T03:30:44.578Z,Y230202C30371425,,3312,3312,3311,3311,3310,3311,3312,3310,3310,3311,3312,3312,3311,3312,3311,2 +2026-09-06T03:30:44.674Z,Y230202C30371431,,3311,3310,3311,3311,3309,3311,3310,3311,3310,3310,3311,3310,3311,3311,3310,2 +2026-09-06T03:30:44.830Z,Y230202C30371427,,3312,3312,3311,3312,3311,3311,3311,3311,3311,3311,3312,3312,3312,3311,3312,1 +2026-09-06T03:40:44.934Z,Y230202C30371426,,3310,3311,3311,3311,3312,3310,3311,3311,3311,3310,3310,3310,3311,3311,3312,2 +2026-09-06T03:40:45.062Z,Y230202C30371425,,3312,3312,3311,3312,3311,3311,3312,3311,3311,3311,3312,3312,3311,3313,3311,2 +2026-09-06T03:40:45.158Z,Y230202C30371431,,3312,3312,3313,3312,3311,3312,3311,3312,3312,3312,3313,3312,3312,3312,3312,2 +2026-09-06T03:40:45.284Z,Y230202C30371427,,3312,3312,3312,3313,3312,3312,3312,3312,3312,3311,3312,3312,3312,3312,3313,2 +2026-09-06T03:50:45.387Z,Y230202C30371426,,3311,3311,3311,3311,3311,3310,3311,3311,3310,3310,3311,3311,3311,3311,3312,2 +2026-09-06T03:50:45.515Z,Y230202C30371425,,3313,3313,3313,3313,3312,3313,3313,3312,3312,3313,3314,3313,3313,3314,3313,2 +2026-09-06T03:50:45.611Z,Y230202C30371431,,3314,3314,3314,3314,3313,3314,3313,3314,3314,3313,3315,3314,3314,3314,3314,2 +2026-09-06T03:50:45.736Z,Y230202C30371427,,3313,3313,3313,3314,3312,3313,3312,3312,3313,3312,3313,3313,3313,3313,3313,2 +2026-09-06T04:00:45.811Z,Y230202C30371426,,3313,3313,3312,3313,3313,3312,3313,3313,3312,3312,3312,3312,3313,3312,3314,2 +2026-09-06T04:00:45.937Z,Y230202C30371425,,3308,3308,3308,3308,3307,3308,3308,3307,3307,3308,3309,3308,3308,3309,3307,2 +2026-09-06T04:00:46.032Z,Y230202C30371431,,3307,3307,3308,3307,3306,3307,3307,3307,3307,3307,3308,3307,3307,3307,3307,2 +2026-09-06T04:00:46.158Z,Y230202C30371427,,3308,3308,3308,3309,3308,3308,3308,3308,3308,3307,3308,3308,3308,3308,3309,2 +2026-09-06T04:10:46.261Z,Y230202C30371426,,3308,3309,3308,3308,3308,3308,3309,3308,3308,3308,3308,3308,3309,3308,3309,1 +2026-09-06T04:10:46.389Z,Y230202C30371425,,3312,3312,3312,3312,3311,3311,3312,3311,3311,3312,3312,3312,3312,3313,3312,2 +2026-09-06T04:10:46.483Z,Y230202C30371431,,3313,3312,3313,3312,3311,3312,3312,3312,3312,3311,3313,3312,3312,3312,3312,2 +2026-09-06T04:10:46.609Z,Y230202C30371427,,3313,3312,3312,3314,3312,3312,3312,3312,3312,3312,3313,3312,3313,3312,3313,2 +2026-09-06T04:20:46.636Z,Y230202C30371426,,3312,3312,3312,3312,3313,3311,3312,3312,3312,3312,3311,3311,3312,3312,3313,2 +2026-09-06T04:20:46.793Z,Y230202C30371425,,3315,3315,3315,3315,3314,3315,3315,3315,3314,3315,3316,3315,3315,3316,3315,2 +2026-09-06T04:20:46.918Z,Y230202C30371431,,3315,3314,3315,3314,3313,3314,3314,3314,3314,3313,3315,3314,3314,3314,3314,2 +2026-09-06T04:20:47.043Z,Y230202C30371427,,3315,3314,3314,3316,3314,3315,3314,3314,3314,3314,3315,3314,3315,3314,3315,2 +2026-09-06T04:30:47.123Z,Y230202C30371426,,3314,3315,3314,3314,3314,3314,3315,3314,3313,3313,3314,3314,3314,3314,3315,2 +2026-09-06T04:30:47.250Z,Y230202C30371425,,3312,3312,3312,3312,3311,3311,3312,3311,3311,3312,3312,3312,3312,3313,3312,2 +2026-09-06T04:30:47.344Z,Y230202C30371431,,3313,3312,3313,3312,3310,3312,3312,3312,3312,3311,3313,3312,3312,3312,3311,3 +2026-09-06T04:30:47.471Z,Y230202C30371427,,3312,3312,3312,3313,3311,3312,3312,3311,3311,3311,3312,3312,3312,3312,3312,2 +2026-09-06T04:40:47.565Z,Y230202C30371426,,3312,3313,3312,3313,3313,3311,3312,3313,3312,3312,3311,3312,3313,3312,3313,2 +2026-09-06T04:40:47.693Z,Y230202C30371425,,3312,3311,3311,3311,3310,3311,3312,3311,3310,3311,3312,3311,3311,3313,3311,3 +2026-09-06T04:40:47.787Z,Y230202C30371431,,3313,3312,3313,3312,3311,3312,3312,3312,3312,3311,3313,3312,3312,3312,3312,2 +2026-09-06T04:40:47.911Z,Y230202C30371427,,3313,3312,3312,3314,3312,3312,3312,3312,3312,3311,3313,3312,3312,3312,3313,3 +2026-09-06T04:50:48.014Z,Y230202C30371426,,3311,3312,3311,3312,3312,3310,3312,3312,3311,3310,3311,3311,3312,3311,3312,2 +2026-09-06T04:50:48.142Z,Y230202C30371425,,3296,3295,3296,3295,3295,3295,3296,3295,3295,3296,3296,3295,3295,3297,3296,2 +2026-09-06T04:50:48.237Z,Y230202C30371431,,3294,3295,3295,3295,3293,3294,3294,3295,3294,3293,3295,3295,3295,3295,3294,2 +2026-09-06T04:50:48.362Z,Y230202C30371427,,3296,3296,3296,3297,3296,3295,3296,3296,3296,3296,3296,3296,3296,3296,3297,2 +2026-09-06T05:00:48.434Z,Y230202C30371426,,3295,3296,3295,3295,3295,3295,3296,3296,3295,3295,3295,3295,3296,3295,3296,1 +2026-09-06T05:00:48.562Z,Y230202C30371425,,3285,3285,3286,3286,3285,3285,3285,3285,3285,3286,3285,3285,3286,3286,3285,1 +2026-09-06T05:00:48.718Z,Y230202C30371431,,3286,3286,3286,3286,3284,3286,3286,3287,3286,3285,3286,3287,3287,3287,3285,3 +2026-09-06T05:00:48.843Z,Y230202C30371427,,3286,3286,3286,3286,3285,3285,3285,3285,3285,3284,3285,3286,3286,3285,3285,2 +2026-09-06T05:10:48.946Z,Y230202C30371426,,3285,3286,3286,3286,3286,3285,3286,3286,3286,3286,3285,3286,3286,3286,3287,2 +2026-09-06T05:10:49.072Z,Y230202C30371425,,3280,3280,3281,3280,3280,3280,3280,3280,3280,3281,3280,3280,3280,3281,3280,1 +2026-09-06T05:10:49.168Z,Y230202C30371431,,3280,3280,3281,3281,3280,3280,3280,3281,3281,3280,3280,3280,3281,3281,3280,1 +2026-09-06T05:10:49.294Z,Y230202C30371427,,3282,3282,3282,3282,3281,3281,3282,3282,3282,3281,3282,3282,3282,3282,3282,1 +2026-09-06T05:20:49.397Z,Y230202C30371426,,3279,3280,3279,3279,3278,3278,3280,3279,3280,3278,3279,3279,3280,3280,3279,2 +2026-09-06T05:20:49.525Z,Y230202C30371425,,3278,3278,3278,3278,3278,3278,3278,3278,3278,3278,3278,3278,3278,3279,3278,1 +2026-09-06T05:20:49.620Z,Y230202C30371431,,3278,3278,3278,3278,3277,3278,3278,3278,3278,3277,3278,3278,3278,3278,3278,1 +2026-09-06T05:20:49.746Z,Y230202C30371427,,3278,3279,3279,3279,3278,3278,3279,3278,3278,3278,3278,3279,3279,3279,3279,1 +2026-09-06T05:30:49.850Z,Y230202C30371426,,3279,3279,3279,3279,3279,3278,3279,3279,3279,3279,3279,3279,3279,3279,3280,2 +2026-09-06T05:30:49.947Z,Y230202C30371425,,3278,3278,3278,3278,3278,3278,3278,3278,3278,3278,3278,3278,3278,3278,3278,0 +2026-09-06T05:30:50.074Z,Y230202C30371431,,3279,3279,3279,3279,3278,3279,3279,3279,3279,3278,3279,3279,3280,3279,3279,2 +2026-09-06T05:30:50.200Z,Y230202C30371427,,3277,3278,3278,3277,3277,3277,3277,3277,3277,3276,3277,3278,3277,3277,3277,2 +2026-09-06T05:40:50.304Z,Y230202C30371426,,3275,3276,3276,3276,3276,3275,3276,3276,3277,3276,3275,3276,3277,3277,3277,2 +2026-09-06T05:40:50.433Z,Y230202C30371425,,3277,3277,3277,3277,3277,3277,3277,3277,3277,3277,3277,3277,3277,3277,3276,1 +2026-09-06T05:40:50.528Z,Y230202C30371431,,3276,3276,3276,3276,3275,3276,3276,3276,3276,3275,3276,3276,3276,3276,3276,1 +2026-09-06T05:40:50.684Z,Y230202C30371427,,3279,3279,3278,3278,3278,3278,3278,3278,3278,3278,3279,3279,3278,3278,3278,1 +2026-09-06T05:50:50.760Z,Y230202C30371426,,3277,3277,3277,3277,3277,3276,3277,3277,3277,3277,3277,3277,3277,3277,3277,1 +2026-09-06T05:50:50.859Z,Y230202C30371425,,3278,3278,3278,3278,3278,3278,3278,3278,3278,3279,3278,3278,3278,3278,3279,1 +2026-09-06T05:50:50.984Z,Y230202C30371431,,3279,3279,3278,3279,3277,3279,3279,3279,3278,3278,3279,3279,3279,3279,3278,2 +2026-09-06T05:50:51.110Z,Y230202C30371427,,3278,3279,3279,3278,3278,3278,3278,3278,3278,3278,3278,3279,3279,3279,3279,1 +2026-09-06T06:00:51.214Z,Y230202C30371426,,3279,3279,3279,3279,3279,3278,3279,3279,3279,3279,3279,3279,3279,3279,3280,2 +2026-09-06T06:00:51.313Z,Y230202C30371425,,3275,3275,3275,3274,3274,3275,3275,3274,3274,3274,3275,3275,3275,3275,3274,1 +2026-09-06T06:00:51.438Z,Y230202C30371431,,3274,3274,3274,3274,3274,3274,3274,3274,3274,3274,3274,3274,3274,3274,3274,0 +2026-09-06T06:00:51.563Z,Y230202C30371427,,3274,3275,3275,3274,3274,3274,3274,3274,3274,3274,3274,3275,3274,3275,3275,1 +2026-09-06T06:10:51.667Z,Y230202C30371426,,3274,3274,3274,3274,3274,3273,3274,3274,3274,3274,3274,3274,3274,3275,3274,2 +2026-09-06T06:10:51.766Z,Y230202C30371425,,3275,3275,3276,3275,3275,3275,3275,3275,3275,3276,3275,3275,3276,3276,3275,1 +2026-09-06T06:10:51.892Z,Y230202C30371431,,3276,3276,3276,3276,3275,3276,3276,3277,3276,3276,3276,3277,3277,3276,3276,2 +2026-09-06T06:10:52.019Z,Y230202C30371427,,3277,3277,3277,3277,3276,3277,3277,3277,3277,3276,3277,3277,3277,3277,3277,1 +2026-09-06T06:20:52.123Z,Y230202C30371426,,3277,3277,3277,3277,3276,3276,3277,3277,3277,3276,3277,3277,3277,3277,3276,1 +2026-09-06T06:20:52.222Z,Y230202C30371425,,3279,3279,3279,3279,3279,3279,3279,3279,3279,3279,3279,3279,3279,3279,3279,0 +2026-09-06T06:20:52.348Z,Y230202C30371431,,3279,3279,3279,3279,3278,3279,3278,3279,3279,3279,3279,3279,3279,3279,3279,1 +2026-09-06T06:20:52.594Z,Y230202C30371427,,3278,3278,3278,3278,3278,3278,3278,3278,3278,3278,3278,3278,3278,3278,3279,1 +2026-09-06T06:30:52.698Z,Y230202C30371426,,3277,3277,3277,3277,3277,3276,3277,3277,3277,3277,3277,3277,3277,3277,3277,1 +2026-09-06T06:30:52.827Z,Y230202C30371425,,3280,3279,3280,3280,3280,3279,3279,3279,3280,3280,3280,3279,3280,3280,3280,1 +2026-09-06T06:30:52.921Z,Y230202C30371431,,3279,3280,3279,3280,3279,3280,3279,3280,3279,3279,3280,3280,3280,3280,3279,1 +2026-09-06T06:30:53.046Z,Y230202C30371427,,3280,3280,3280,3280,3280,3280,3280,3280,3280,3279,3280,3280,3280,3280,3280,1 +2026-09-06T06:40:53.149Z,Y230202C30371426,,3280,3280,3280,3280,3280,3280,3280,3280,3280,3280,3280,3280,3280,3281,3280,1 +2026-09-06T06:40:53.277Z,Y230202C30371425,,3282,3282,3282,3282,3281,3282,3282,3282,3282,3282,3282,3282,3282,3282,3281,1 +2026-09-06T06:40:53.372Z,Y230202C30371431,,3282,3282,3282,3282,3281,3282,3282,3282,3282,3282,3282,3282,3282,3282,3282,1 +2026-09-06T06:40:53.498Z,Y230202C30371427,,3282,3282,3282,3282,3282,3282,3282,3282,3282,3281,3282,3282,3282,3282,3282,1 +2026-09-06T06:50:53.566Z,Y230202C30371426,,3282,3282,3282,3282,3282,3281,3281,3281,3282,3282,3282,3282,3282,3282,3282,1 +2026-09-06T06:50:53.694Z,Y230202C30371425,,3293,3293,3293,3293,3293,3293,3293,3293,3293,3294,3294,3293,3293,3294,3293,1 +2026-09-06T06:50:53.788Z,Y230202C30371431,,3292,3292,3292,3292,3292,3292,3291,3292,3292,3292,3293,3292,3293,3292,3293,2 +2026-09-06T06:50:53.913Z,Y230202C30371427,,3293,3293,3293,3293,3293,3293,3292,3293,3293,3293,3293,3293,3293,3293,3294,2 +2026-09-06T07:00:54.017Z,Y230202C30371426,,3292,3292,3292,3292,3292,3292,3292,3292,3292,3292,3292,3292,3292,3292,3293,1 +2026-09-06T07:00:54.145Z,Y230202C30371425,,3299,3299,3298,3298,3298,3298,3298,3298,3298,3298,3299,3299,3299,3299,3298,1 +2026-09-06T07:00:54.240Z,Y230202C30371431,,3298,3298,3299,3298,3298,3298,3298,3299,3298,3298,3298,3298,3299,3299,3298,1 +2026-09-06T07:00:54.366Z,Y230202C30371427,,3298,3298,3298,3298,3298,3298,3298,3298,3298,3298,3299,3298,3298,3298,3298,1 +2026-09-06T07:10:54.469Z,Y230202C30371426,,3300,3299,3299,3299,3299,3299,3299,3299,3299,3298,3300,3299,3299,3299,3299,2 +2026-09-06T07:10:54.597Z,Y230202C30371425,,3310,3310,3310,3310,3310,3310,3310,3309,3309,3310,3310,3310,3310,3310,3310,1 +2026-09-06T07:10:54.723Z,Y230202C30371431,,3310,3310,3310,3310,3310,3310,3309,3310,3310,3310,3311,3310,3310,3310,3310,2 +2026-09-06T07:10:54.819Z,Y230202C30371427,,3311,3311,3311,3311,3310,3311,3310,3310,3310,3310,3311,3311,3311,3311,3310,1 +2026-09-06T07:20:54.922Z,Y230202C30371426,,3309,3309,3309,3309,3309,3309,3309,3309,3308,3308,3309,3309,3309,3309,3309,1 +2026-09-06T07:20:55.051Z,Y230202C30371425,,3326,3325,3325,3326,3325,3325,3325,3325,3325,3326,3326,3326,3326,3326,3325,1 +2026-09-06T07:20:55.146Z,Y230202C30371431,,3325,3325,3325,3325,3324,3325,3324,3325,3325,3324,3326,3325,3325,3325,3325,2 +2026-09-06T07:20:55.272Z,Y230202C30371427,,3325,3324,3324,3325,3325,3325,3324,3324,3325,3325,3325,3324,3325,3325,3326,2 +2026-09-06T07:30:55.375Z,Y230202C30371426,,3326,3325,3325,3326,3326,3326,3325,3325,3325,3325,3326,3326,3325,3325,3326,1 +2026-09-06T07:30:55.504Z,Y230202C30371425,,3322,3322,3322,3322,3322,3322,3322,3321,3322,3323,3323,3322,3322,3323,3322,2 +2026-09-06T07:30:55.599Z,Y230202C30371431,,3323,3323,3323,3323,3322,3323,3322,3323,3322,3322,3324,3323,3323,3323,3323,2 +2026-09-06T07:30:55.723Z,Y230202C30371427,,3322,3321,3321,3322,3321,3322,3321,3321,3321,3321,3322,3321,3322,3322,3322,1 +2026-09-06T07:40:55.743Z,Y230202C30371426,,3323,3323,3322,3323,3323,3323,3323,3323,3322,3323,3323,3323,3323,3322,3324,2 +2026-09-06T07:40:55.871Z,Y230202C30371425,,3317,3317,3316,3316,3316,3316,3316,3315,3315,3316,3317,3317,3316,3317,3316,2 +2026-09-06T07:40:55.967Z,Y230202C30371431,,3315,3315,3315,3315,3314,3315,3314,3315,3315,3315,3315,3315,3315,3315,3315,1 +2026-09-06T07:40:56.093Z,Y230202C30371427,,3318,3317,3317,3318,3317,3318,3317,3317,3317,3317,3318,3317,3318,3317,3317,1 +2026-09-06T07:50:56.139Z,Y230202C30371426,,3316,3316,3316,3316,3316,3316,3316,3316,3315,3315,3316,3316,3316,3316,3316,1 +2026-09-06T07:50:56.269Z,Y230202C30371425,,3302,3302,3302,3302,3302,3301,3302,3302,3302,3302,3302,3302,3302,3303,3302,2 +2026-09-06T07:50:56.365Z,Y230202C30371431,,3304,3304,3304,3304,3303,3304,3304,3304,3304,3304,3304,3304,3304,3304,3304,1 +2026-09-06T07:50:56.522Z,Y230202C30371427,,3301,3301,3302,3302,3302,3301,3301,3302,3302,3302,3301,3302,3302,3302,3302,1 +2026-09-06T08:00:56.597Z,Y230202C30371426,,3304,3304,3304,3304,3304,3304,3304,3304,3304,3304,3304,3304,3304,3304,3304,0 +2026-09-06T08:00:56.727Z,Y230202C30371425,,3347,3346,3345,3346,3345,3346,3346,3344,3344,3346,3348,3347,3346,3346,3345,4 +2026-09-06T08:00:56.823Z,Y230202C30371431,,3344,3342,3344,3343,3343,3343,3341,3342,3342,3342,3344,3342,3343,3342,3343,3 +2026-09-06T08:00:56.948Z,Y230202C30371427,,3344,3343,3343,3343,3344,3345,3343,3343,3343,3343,3345,3343,3344,3344,3344,2 +2026-09-06T08:10:56.997Z,Y230202C30371426,,3343,3342,3342,3343,3344,3343,3342,3343,3342,3342,3343,3342,3342,3342,3344,2 +2026-09-06T08:10:57.097Z,Y230202C30371425,,3291,3291,3292,3291,3291,3291,3291,3291,3291,3292,3291,3291,3291,3292,3291,1 +2026-09-06T08:10:57.222Z,Y230202C30371431,,3292,3292,3292,3291,3290,3292,3292,3292,3291,3290,3292,3292,3292,3291,3291,2 +2026-09-06T08:10:57.349Z,Y230202C30371427,,3294,3294,3294,3294,3293,3294,3294,3294,3294,3293,3294,3294,3294,3294,3294,1 +2026-09-06T08:20:57.419Z,Y230202C30371426,,3293,3293,3293,3293,3292,3292,3293,3293,3292,3292,3293,3293,3293,3293,3292,1 +2026-09-06T08:20:57.519Z,Y230202C30371425,,3301,3301,3300,3300,3300,3301,3301,3299,3300,3300,3301,3301,3300,3300,3300,2 +2026-09-06T08:20:57.645Z,Y230202C30371431,,3301,3301,3301,3301,3301,3301,3300,3301,3301,3301,3301,3301,3301,3301,3301,1 +2026-09-06T08:20:57.772Z,Y230202C30371427,,3298,3298,3299,3299,3299,3298,3298,3298,3298,3298,3298,3298,3298,3299,3299,1 +2026-09-06T08:30:57.783Z,Y230202C30371426,,3300,3300,3300,3300,3301,3299,3300,3300,3300,3301,3300,3300,3300,3300,3301,2 +2026-09-06T08:30:57.911Z,Y230202C30371425,,3297,3297,3298,3297,3297,3296,3297,3297,3297,3298,3297,3297,3297,3298,3298,2 +2026-09-06T08:30:58.007Z,Y230202C30371431,,3295,3296,3296,3296,3294,3296,3295,3296,3295,3294,3296,3296,3296,3296,3295,2 +2026-09-06T08:30:58.132Z,Y230202C30371427,,3298,3298,3298,3298,3297,3298,3298,3298,3297,3297,3298,3298,3298,3298,3297,1 +2026-09-06T08:40:58.145Z,Y230202C30371426,,3296,3297,3296,3296,3295,3296,3297,3296,3296,3295,3296,3297,3296,3296,3295,2 +2026-09-06T08:40:58.244Z,Y230202C30371425,,3296,3296,3296,3295,3294,3296,3296,3295,3295,3295,3297,3296,3295,3296,3295,3 +2026-09-06T08:40:58.431Z,Y230202C30371431,,3296,3296,3296,3296,3295,3296,3296,3296,3296,3296,3297,3296,3296,3297,3296,2 +2026-09-06T08:40:58.557Z,Y230202C30371427,,3296,3296,3296,3296,3296,3296,3296,3296,3296,3296,3297,3296,3296,3296,3297,1 +2026-09-06T08:50:58.577Z,Y230202C30371426,,3297,3296,3296,3296,3297,3296,3296,3296,3296,3296,3297,3296,3296,3296,3297,1 +2026-09-06T08:50:58.706Z,Y230202C30371425,,3287,3287,3288,3288,3288,3287,3287,3287,3287,3288,3287,3287,3288,3288,3288,1 +2026-09-06T08:50:58.802Z,Y230202C30371431,,3287,3287,3287,3287,3286,3287,3286,3287,3287,3286,3287,3287,3287,3287,3287,1 +2026-09-06T08:50:58.927Z,Y230202C30371427,,3287,3287,3287,3287,3287,3286,3287,3287,3287,3287,3287,3287,3287,3287,3288,2 +2026-09-06T09:00:59.031Z,Y230202C30371426,,3285,3286,3286,3286,3286,3285,3286,3286,3286,3286,3285,3286,3286,3286,3287,2 +2026-09-06T09:00:59.160Z,Y230202C30371425,,3331,3331,3330,3331,3330,3331,3331,3330,3330,3331,3332,3332,3331,3331,3330,2 +2026-09-06T09:00:59.257Z,Y230202C30371431,,3331,3330,3331,3330,3329,3331,3329,3330,3329,3329,3332,3330,3330,3330,3330,3 +2026-09-06T09:00:59.383Z,Y230202C30371427,,3330,3329,3329,3329,3330,3330,3329,3329,3329,3329,3330,3329,3330,3329,3330,1 +2026-09-06T09:10:59.468Z,Y230202C30371426,,3332,3331,3331,3331,3331,3332,3331,3331,3330,3330,3332,3331,3331,3330,3332,2 +2026-09-06T09:10:59.596Z,Y230202C30371425,,3362,3362,3361,3363,3362,3361,3362,3361,3360,3363,3364,3363,3363,3364,3362,4 +2026-09-06T09:10:59.692Z,Y230202C30371431,,3364,3362,3364,3362,3362,3363,3360,3362,3362,3362,3365,3361,3362,3362,3363,5 +2026-09-06T09:10:59.818Z,Y230202C30371427,,3365,3364,3363,3364,3366,3365,3363,3363,3364,3364,3365,3362,3364,3364,3366,4 +2026-09-06T09:20:59.832Z,Y230202C30371426,,3365,3363,3363,3364,3365,3364,3363,3364,3363,3363,3364,3363,3363,3363,3366,3 +2026-09-06T09:20:59.931Z,Y230202C30371425,,3351,3351,3350,3351,3349,3350,3352,3349,3348,3351,3353,3352,3350,3353,3350,5 +2026-09-06T09:21:00.057Z,Y230202C30371431,,3350,3348,3351,3349,3345,3349,3346,3348,3347,3347,3351,3348,3348,3347,3347,6 +2026-09-06T09:21:00.182Z,Y230202C30371427,,3348,3346,3346,3348,3347,3347,3345,3346,3346,3345,3347,3345,3347,3346,3348,3 +2026-09-06T09:31:00.225Z,Y230202C30371426,,3350,3350,3349,3350,3350,3350,3350,3350,3348,3348,3349,3349,3349,3348,3351,3 +2026-09-06T09:31:00.324Z,Y230202C30371425,,3358,3358,3356,3358,3356,3355,3359,3356,3355,3359,3360,3358,3357,3361,3358,6 +2026-09-06T09:31:00.541Z,Y230202C30371427,,3363,3360,3363,3361,3356,3361,3357,3360,3359,3359,3364,3358,3358,3358,3359,8 +2026-09-06T09:41:00.618Z,Y230202C30371426,,3362,3360,3358,3362,3359,3361,3359,3358,3359,3358,3361,3357,3360,3358,3362,3362,3362,3361,3362,3364,3361,3362,3362,3359,3360,3361,3360,3361,3359,3365,8 +2026-09-06T09:41:00.747Z,Y230202C30371425,,3381,3379,3379,3379,3380,3380,3379,3378,3378,3380,3382,3380,3380,3380,3380,4 +2026-09-06T09:41:00.844Z,Y230202C30371431,,3376,3375,3375,3374,3374,3376,3374,3374,3374,3375,3377,3374,3374,3374,3375,3 +2026-09-06T09:41:00.971Z,Y230202C30371427,,3379,3378,3378,3378,3380,3380,3377,3378,3378,3379,3380,3378,3379,3379,3380,3 +2026-09-06T09:51:01.057Z,Y230202C30371426,,3377,3375,3375,3376,3377,3376,3375,3375,3375,3376,3377,3376,3375,3375,3377,2 +2026-09-06T09:51:01.187Z,Y230202C30371425,,3365,3364,3364,3364,3365,3365,3365,3364,3364,3365,3366,3365,3365,3365,3365,2 +2026-09-06T09:51:01.283Z,Y230202C30371431,,3368,3367,3367,3366,3367,3368,3366,3367,3366,3367,3369,3367,3367,3367,3368,3 +2026-09-06T09:51:01.410Z,Y230202C30371427,,3364,3364,3363,3363,3364,3364,3363,3363,3363,3363,3364,3363,3364,3364,3364,1 +2026-09-06T10:01:01.514Z,Y230202C30371426,,3369,3368,3368,3368,3369,3369,3368,3368,3367,3369,3370,3368,3368,3368,3370,3 +2026-09-06T10:01:01.611Z,Y230202C30371425,,3361,3360,3360,3360,3361,3361,3360,3360,3360,3361,3362,3361,3361,3361,3361,2 +2026-09-06T10:01:01.737Z,Y230202C30371431,,3357,3357,3357,3357,3357,3357,3356,3356,3356,3357,3358,3357,3357,3357,3357,2 +2026-09-06T10:01:01.864Z,Y230202C30371427,,3361,3360,3359,3360,3360,3361,3359,3359,3359,3359,3361,3360,3360,3360,3360,2 +2026-09-06T10:11:01.950Z,Y230202C30371426,,3359,3358,3358,3359,3360,3359,3358,3358,3358,3359,3359,3359,3359,3359,3360,2 +2026-09-06T10:11:02.049Z,Y230202C30371425,,3337,3337,3337,3337,3336,3337,3337,3337,3337,3336,3337,3337,3337,3337,3337,1 +2026-09-06T10:11:02.175Z,Y230202C30371431,,3330,3331,3331,3332,3331,3330,3332,3332,3332,3331,3330,3332,3332,3332,3332,2 +2026-09-06T10:11:02.332Z,Y230202C30371427,,3331,3331,3331,3331,3330,3330,3331,3331,3331,3329,3331,3332,3332,3331,3330,3 +2026-09-06T10:21:02.344Z,Y230202C30371426,,3335,3335,3335,3335,3335,3335,3335,3335,3335,3335,3335,3336,3335,3336,3336,1 +2026-09-06T10:21:02.474Z,Y230202C30371425,,3304,3305,3306,3306,3306,3304,3305,3306,3306,3306,3304,3305,3306,3306,3306,2 +2026-09-06T10:21:02.569Z,Y230202C30371431,,3304,3305,3304,3305,3302,3304,3305,3305,3304,3303,3304,3305,3305,3304,3303,3 +2026-09-06T10:21:02.696Z,Y230202C30371427,,3310,3310,3310,3309,3308,3309,3310,3309,3309,3308,3310,3310,3310,3309,3309,2 +2026-09-06T10:31:02.788Z,Y230202C30371426,,3300,3302,3302,3302,3301,3299,3302,3302,3302,3301,3300,3302,3302,3302,3301,3 +2026-09-06T10:31:02.918Z,Y230202C30371425,,3342,3342,3342,3342,3343,3342,3342,3342,3342,3343,3343,3342,3342,3343,3343,1 +2026-09-06T10:31:03.014Z,Y230202C30371431,,3338,3338,3338,3338,3338,3338,3338,3338,3338,3339,3338,3338,3338,3338,3339,1 +2026-09-06T10:31:03.140Z,Y230202C30371427,,3339,3339,3339,3339,3339,3339,3339,3339,3339,3338,3339,3339,3339,3339,3339,1 +2026-09-06T10:41:03.147Z,Y230202C30371426,,3341,3341,3341,3341,3342,3341,3341,3341,3341,3341,3341,3341,3341,3341,3342,1 +2026-09-06T10:41:03.276Z,Y230202C30371425,,3375,3374,3373,3374,3374,3374,3374,3373,3373,3374,3376,3375,3374,3374,3374,3 +2026-09-06T10:41:03.372Z,Y230202C30371431,,3375,3374,3374,3374,3375,3375,3373,3373,3373,3374,3376,3374,3374,3374,3375,3 +2026-09-06T10:41:03.496Z,Y230202C30371427,,3375,3374,3374,3374,3375,3375,3374,3374,3374,3374,3376,3374,3375,3375,3375,2 +2026-09-06T10:51:03.595Z,Y230202C30371426,,3376,3374,3374,3374,3375,3376,3374,3374,3373,3374,3376,3375,3374,3374,3375,3 +2026-09-06T10:51:03.694Z,Y230202C30371425,,3391,3390,3389,3391,3391,3391,3390,3389,3390,3391,3393,3391,3391,3391,3391,4 +2026-09-06T10:51:03.820Z,Y230202C30371431,,3389,3388,3388,3387,3390,3389,3387,3387,3387,3389,3390,3388,3387,3387,3389,3 +2026-09-06T10:51:03.946Z,Y230202C30371427,,3389,3387,3387,3387,3390,3389,3387,3387,3387,3389,3389,3387,3388,3388,3390,3 +2026-09-06T11:01:04.050Z,Y230202C30371426,,3393,3390,3390,3391,3392,3393,3390,3390,3390,3391,3393,3391,3390,3390,3393,3 +2026-09-06T11:01:04.145Z,Y230202C30371425,,3392,3390,3390,3390,3391,3391,3391,3389,3389,3391,3393,3392,3391,3391,3391,4 +2026-09-06T11:01:04.362Z,Y230202C30371427,,3392,3390,3390,3389,3391,3391,3388,3389,3389,3390,3392,3389,3389,3389,3391,4 diff --git a/tests/golden-hysteresis/fixture-snapshot.json b/tests/golden-hysteresis/fixture-snapshot.json index 4c464be..23c8e24 100644 --- a/tests/golden-hysteresis/fixture-snapshot.json +++ b/tests/golden-hysteresis/fixture-snapshot.json @@ -2,31 +2,31 @@ "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": "20", "grid_soc_off": "30", "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" + "evcs_surplus_state": "idle_wait_conditions", + "charge_high_last_at": "", + "charge_dwell_long_min": "20", + "charge_dwell_court_min": "6", + "balance_required_soc": "0", + "balance_status": "ok", + "balance_last_run": "" } diff --git a/tests/golden-hysteresis/run.mjs b/tests/golden-hysteresis/run.mjs index dabeadf..7beebd3 100644 --- a/tests/golden-hysteresis/run.mjs +++ b/tests/golden-hysteresis/run.mjs @@ -65,6 +65,11 @@ function summarize(out) { latchAction: d.latch_action, shouldNotify: !!d.should_notify, evcsGridOverride: !!c.evcs_grid_override, + plafondDechargeW: c.plafond_decharge_w, + dwellMinutes: c.dwell_minutes, + dwellEcoule: c.dwell_ecoule, + reserveDejaCondamnee: !!c.reserve_deja_condamnee, + setChargeHighLastAt: !!d.set_charge_high_last_at, }; } diff --git a/tests/golden-hysteresis/scenarios.mjs b/tests/golden-hysteresis/scenarios.mjs index 134af17..8e6243e 100644 --- a/tests/golden-hysteresis/scenarios.mjs +++ b/tests/golden-hysteresis/scenarios.mjs @@ -31,6 +31,16 @@ function minutesAgo(fixedMs, minutes) { return stamp(fixedMs - minutes * 60000); } +// Le bilan energetique publie son horodatage au format "DD/MM HH:mm". +function bilanRunAgo(fixedMs, minutes) { + const p = new Intl.DateTimeFormat('fr-FR', { + timeZone: 'Europe/Paris', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', hour12: false, + }).formatToParts(new Date(fixedMs - minutes * 60000)); + const g = t => p.find(x => x.type === t).value; + return `${g('day')}/${g('month')} ${g('hour')}:${g('minute')}`; +} + 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(); @@ -238,4 +248,215 @@ export const scenarios = [ gridAction: 'turn_off', }, }, + // ===================================================== + // RELACHEMENT SUR UNE DUREE (06/09/2026) + // ===================================================== + // Mesure de la soiree du 05/09, seuil de relachement 2550 W, pas d'une + // minute : pendant la cuisson (20:33 -> 21:37) treize accalmies, la plus + // longue de CINQ minutes ; apres la cuisson (21:40 -> 01:00) une seule + // accalmie de 201 minutes. La distribution est bimodale, le fosse est net, + // et le relachement instantane tombait treize fois dedans. + // + // Le releve exact de cette soiree : SOC 65 %, bilan exigeant 92 % pour + // atteindre le croisement PV — la reserve est deja condamnee, donc le + // reseau du soir est du de toute facon et le long dwell s'applique. + { + id: 'h-dwell-accalmie-de-cuisson-ne-relache-pas', + label: "LE CAS REEL : accalmie de 5 min au milieu du service (la plus longue mesuree) — le reseau n'est PAS rendu", + time: T, + overrides: { + ...recoveryBase, + ...parcQuatreModules, + soc_percent: '65', + house_ac_power_w: '2000', + last_on_at: minutesAgo(T_MS, 40), + charge_high_last_at: minutesAgo(T_MS, 5), + balance_required_soc: '92', + balance_last_run: bilanRunAgo(T_MS, 3), + }, + expect: { + state: 'hold_on_charge_encore_elevee', + gridAction: 'none', + dwellMinutes: 20, + dwellEcoule: false, + reserveDejaCondamnee: true, + setChargeHighLastAt: false, + }, + }, + { + id: 'h-dwell-ecoule-relache', + label: 'CONTREFACTUEL : meme soiree, mais la maison est calme depuis 25 min — le reseau est rendu', + time: T, + overrides: { + ...recoveryBase, + ...parcQuatreModules, + soc_percent: '65', + house_ac_power_w: '2000', + last_on_at: minutesAgo(T_MS, 40), + charge_high_last_at: minutesAgo(T_MS, 25), + balance_required_soc: '92', + balance_last_run: bilanRunAgo(T_MS, 3), + }, + expect: { + state: 'turn_off_vehicle_unplugged', + gridAction: 'turn_off', + dwellMinutes: 20, + dwellEcoule: true, + reserveDejaCondamnee: true, + }, + }, + + // Les trois scenarios suivants sont a 8 MINUTES de calme, entre le dwell + // court (6) et le long (20) : seul l'etat du bilan les separe. + { + id: 'h-dwell-long-quand-la-reserve-est-condamnee', + label: 'Bilan frais, SOC 65 % sous les 92 % requis : dwell long, on garde le reseau a 8 min', + time: T, + overrides: { + ...recoveryBase, + ...parcQuatreModules, + soc_percent: '65', + house_ac_power_w: '2000', + last_on_at: minutesAgo(T_MS, 40), + charge_high_last_at: minutesAgo(T_MS, 8), + balance_required_soc: '92', + balance_last_run: bilanRunAgo(T_MS, 3), + }, + expect: { + state: 'hold_on_charge_encore_elevee', + gridAction: 'none', + dwellMinutes: 20, + reserveDejaCondamnee: true, + }, + }, + { + id: 'h-dwell-court-quand-la-reserve-tient', + label: 'PAIRE : bilan frais mais SOC 80 % au-dessus des 40 % requis — chaque kWh du soir couterait, dwell court, on rend a 8 min', + time: T, + overrides: { + ...recoveryBase, + ...parcQuatreModules, + soc_percent: '80', + house_ac_power_w: '2000', + last_on_at: minutesAgo(T_MS, 40), + charge_high_last_at: minutesAgo(T_MS, 8), + balance_required_soc: '40', + balance_last_run: bilanRunAgo(T_MS, 3), + }, + expect: { + state: 'turn_off_vehicle_unplugged', + gridAction: 'turn_off', + dwellMinutes: 6, + reserveDejaCondamnee: false, + }, + }, + { + id: 'h-dwell-bilan-perime-duree-courte', + label: 'GARDE DE FRAICHEUR : memes chiffres que le dwell long, mais bilan vieux de 2 h — on retombe sur la duree courte', + time: T, + overrides: { + ...recoveryBase, + ...parcQuatreModules, + soc_percent: '65', + house_ac_power_w: '2000', + last_on_at: minutesAgo(T_MS, 40), + charge_high_last_at: minutesAgo(T_MS, 8), + balance_required_soc: '92', + balance_last_run: bilanRunAgo(T_MS, 120), + }, + expect: { + state: 'turn_off_vehicle_unplugged', + gridAction: 'turn_off', + dwellMinutes: 6, + reserveDejaCondamnee: false, + }, + }, + { + id: 'h-dwell-horodatage-ecrit-quand-la-charge-est-haute', + label: "L'horodatage suit la MAISON : charge haute contacteur OUVERT — il est ecrit quand meme", + time: T, + overrides: { + ...recoveryBase, + ...parcQuatreModules, + grid_latch: 'off', + grid_state: 'off', + soc_percent: '65', + house_ac_power_w: '3000', + charge_high_last_at: minutesAgo(T_MS, 30), + }, + expect: { + setChargeHighLastAt: true, + }, + }, + // ===================================================== + // CAPTEURS MUETS (releve du 05/09/2026) + // ===================================================== + // De 21:22:21 a 21:49:35, `sensor.victron_battery_current` et + // `sensor.victron_battery_voltage` n'ont RIEN publie — 27 minutes — pendant + // que `sensor.victron_consumption_power_l1` continuait normalement. Un + // courant fige a -73,2 A pendant que la maison oscille entre 1,4 et 3,4 kW + // est physiquement impossible : la valeur etait perimee. Sans repli, la + // protection s'evanouissait pile a l'heure du diner. + { + id: 'h-capteur-tension-muette-la-regle-tient', + label: 'Tension du parc muette, plafond en amperes lisible : nominal 51 V, la regle continue', + time: T, + overrides: { + ...recoveryBase, + battery_max_discharge_a: '100', + battery_voltage_v: 'unavailable', + soc_percent: '65', + house_ac_power_w: '2000', + last_on_at: minutesAgo(T_MS, 40), + charge_high_last_at: minutesAgo(T_MS, 5), + balance_required_soc: '92', + balance_last_run: bilanRunAgo(T_MS, 3), + }, + expect: { + state: 'hold_on_charge_encore_elevee', + gridAction: 'none', + plafondDechargeW: 5100, + }, + }, + { + id: 'h-capteur-tout-muet-le-dwell-tient', + label: 'Plafond ET tension muets en pleine protection : l horodatage persiste, on tient jusqu au bout du dwell', + time: T, + overrides: { + ...recoveryBase, + battery_max_discharge_a: 'unavailable', + battery_voltage_v: 'unavailable', + soc_percent: '65', + house_ac_power_w: '2000', + last_on_at: minutesAgo(T_MS, 40), + charge_high_last_at: minutesAgo(T_MS, 5), + balance_required_soc: '92', + balance_last_run: bilanRunAgo(T_MS, 3), + }, + expect: { + state: 'hold_on_charge_encore_elevee', + gridAction: 'none', + plafondDechargeW: 0, + }, + }, + { + id: 'h-capteur-tout-muet-memoire-expiree', + label: 'CONTREFACTUEL : memes capteurs muets mais derniere charge haute il y a 30 min — la memoire a expire, le reseau est rendu', + time: T, + overrides: { + ...recoveryBase, + battery_max_discharge_a: 'unavailable', + battery_voltage_v: 'unavailable', + soc_percent: '65', + house_ac_power_w: '2000', + last_on_at: minutesAgo(T_MS, 40), + charge_high_last_at: minutesAgo(T_MS, 30), + balance_required_soc: '92', + balance_last_run: bilanRunAgo(T_MS, 3), + }, + expect: { + state: 'turn_off_vehicle_unplugged', + gridAction: 'turn_off', + }, + }, ]; diff --git a/tools/patch-20260906-relachement-sur-duree.mjs b/tools/patch-20260906-relachement-sur-duree.mjs new file mode 100644 index 0000000..8450b31 --- /dev/null +++ b/tools/patch-20260906-relachement-sur-duree.mjs @@ -0,0 +1,270 @@ +#!/usr/bin/env node +// Le relachement du contacteur EDF se mesure en DUREE, et la duree vient du +// bilan energetique. +// +// La regle du 05/09 ferme quand la maison depasse 80 % de ce que le parc sait +// delivrer, et relachait des que la charge repassait sous 50 % — sur un SEUL +// echantillon. Mesure de la soiree du 05/09 (pas d'une minute, seuil 2550 W) : +// +// pendant la cuisson 20:33 -> 21:37 13 accalmies, la plus longue 5 min +// apres la cuisson 21:40 -> 01:00 une seule accalmie 201 min +// +// Treize occasions de rendre le reseau au milieu du service, alors que le +// troisieme pic (4236 W a 21:01:55) est tombe deux minutes avant l'ouverture +// reelle : decale de cinq minutes, la regle refermait, et chaque aller-retour +// du contacteur coute 50 a 150 Wh de transitoire. +// +// ⚠️ LE DWELL SE COMPTE SUR LA MAISON, JAMAIS SUR LE CONTACTEUR NI SUR LE +// COURANT BATTERIE. Meme raison qu'au 05/09 : fermer le contacteur fait chuter +// le courant batterie, s'en servir comme critere de relachement ferait que la +// decision detruirait sa propre cause. L'horodatage ecrit est celui de la +// derniere charge maison HAUTE, mis a jour que le contacteur soit ouvert ou +// ferme. +// +// LA DUREE VIENT DU BILAN. Tant que la batterie est de toute facon condamnee a +// toucher son plancher avant l'ouverture des heures creuses, le partage +// batterie/reseau de la soiree est fige : tenir le contacteur plus longtemps ne +// change que l'HEURE d'achat, pas le montant. Des que la batterie passerait la +// nuit toute seule, chaque kWh reseau du soir deplace au contraire un kWh +// d'heures creuses vers des heures pleines — et il faut relacher vite. +// +// ⚠️ APPROXIMATION ASSUMEE : le bilan publie `required_soc`, le SOC necessaire +// pour atteindre le CROISEMENT PV, cible strictement plus lointaine que +// l'ouverture des heures creuses. `soc < required_soc` est donc NECESSAIRE mais +// pas SUFFISANT pour « la batterie n'atteindra pas 01:24 ». L'erreur est +// unilaterale : on tient parfois le long dwell une nuit ou le court aurait +// suffi, ce qui coute ~0,4 kWh deplaces des heures creuses vers les heures +// pleines et protege le parc. C'est la direction voulue. +// +// node tools/patch-20260906-relachement-sur-duree.mjs + +import fs from 'node:fs'; + +const F = 'workflows/victron-grid-hysteresis-v1.json'; +const wf = JSON.parse(fs.readFileSync(F, 'utf8')); +const snap = wf.nodes.find((n) => n.name === 'HA Snapshot Grid Hysteresis'); +const calc = wf.nodes.find((n) => n.name === 'Compute Grid Hysteresis'); +const build = wf.nodes.find((n) => n.name === 'Build Grid Hysteresis Actions'); +if (!snap || !calc || !build) throw new Error('noeuds introuvables'); + +function remplace(texte, avant, apres, quoi) { + if (!texte.includes(avant)) throw new Error(`ancre introuvable (${quoi}) : ${avant.slice(0, 60)}`); + return texte.replace(avant, apres); +} + +// ------------------------------------------------------- 1. le snapshot +let p = JSON.stringify(snap.parameters); +if (!p.includes('charge_high_last_at')) { + const ancre = 'battery_voltage_v\\": states(\\"sensor.victron_battery_voltage\\"),'; + p = remplace(p, ancre, ancre + + '\\n \\"charge_high_last_at\\": states(\\"input_datetime.victron_grid_charge_high_last_at\\"),' + + '\\n \\"charge_dwell_long_min\\": states(\\"input_number.victron_grid_charge_dwell_long_min\\"),' + + '\\n \\"charge_dwell_court_min\\": states(\\"input_number.victron_grid_charge_dwell_court_min\\"),' + + '\\n \\"balance_required_soc\\": states(\\"input_number.evcs_balance_required_soc\\"),' + + '\\n \\"balance_status\\": states(\\"input_text.evcs_balance_status\\"),' + + '\\n \\"balance_last_run\\": states(\\"input_text.evcs_balance_last_run\\"),', + 'snapshot'); + snap.parameters = JSON.parse(p); + console.log(' snapshot : +charge_high_last_at, +2 durees, +3 champs du bilan'); +} else console.log(' snapshot : deja fait'); + +let c = calc.parameters.jsCode; + +// ------------------------------------------------------- 2. le parseur +if (!c.includes('function parseRunStampToMs')) { + const ancre = ` return new Date(y, mo - 1, d, h, mi, se).getTime(); +}`; + c = remplace(c, ancre, ancre + ` + +// Le bilan energetique publie son horodatage au format "DD/MM HH:mm" (le meme +// que l'advisor). On le ramene au format de parseLocalStampToMs plutot que +// d'ajouter un second systeme de dates : toute l'arithmetique de ce node se +// fait sur une heure murale de Paris, la conversion se compense d'elle-meme. +function parseRunStampToMs(s, year, nowMsRef) { + const m = (s ?? '').toString().trim().match(/^(\\d{2})\\/(\\d{2}) (\\d{2}):(\\d{2})$/); + if (!m) return null; + + const build = (y) => parseLocalStampToMs(\`\${y}-\${m[2]}-\${m[1]} \${m[3]}:\${m[4]}:00\`); + let ts = build(year); + // Passage d'annee : un horodatage qui tomberait plus d'un jour dans le futur + // vient forcement de l'annee precedente. + if (ts !== null && nowMsRef !== null && ts - nowMsRef > 24 * 3600 * 1000) ts = build(year - 1); + return ts; +}`, 'parseur'); + console.log(' calcul : parseur d horodatage du bilan'); +} + +// ------------------------------------------------------- 3. le dwell +if (!c.includes('dwellEcoule')) { + const ancre = 'const chargeEncoreElevee = plafondDechargeW > 0 && houseAcPowerW >= seuilRelachementW;'; + c = remplace(c, ancre, `const chargeSousSeuilBas = plafondDechargeW > 0 && houseAcPowerW < seuilRelachementW; + +// ====================================================== +// LE RELACHEMENT SE MESURE EN DUREE (06/09/2026) +// ====================================================== +// Soiree du 05/09, seuil de relachement 2550 W, pas d'une minute : +// pendant la cuisson 20:33->21:37 : 13 accalmies, la plus longue 5 min +// apres la cuisson 21:40->01:00 : 1 accalmie, 201 min +// Un creux de 20 minutes n'existe pas tant que ca cuisine. Le relachement +// instantane offrait donc 13 occasions de rendre le reseau au milieu du +// service, chacune facturee 50 a 150 Wh de transitoire. +// +// L'horodatage porte la derniere charge maison HAUTE et il est ecrit que le +// contacteur soit ouvert ou ferme : il suit une grandeur que la decision ne +// modifie pas. Absent ou illisible => dwell repute ecoule => comportement +// d'avant ce correctif, jamais une supposition. +const chargeHighLastAt = (input.charge_high_last_at ?? '').toString().trim(); +const chargeHighLastMs = parseLocalStampToMs(chargeHighLastAt); +const minutesDepuisChargeHaute = + chargeHighLastMs !== null && nowMs !== null + ? Math.max(0, (nowMs - chargeHighLastMs) / 60000) + : null; + +// La duree vient du BILAN. Tant que la batterie touchera son plancher avant +// l'ouverture des heures creuses, l'energie reseau du soir est due de toute +// facon : la tenir plus longtemps ne change que l'heure d'achat. Des qu'elle +// passerait la nuit seule, chaque kWh du soir deplace un kWh d'heures creuses +// vers des heures pleines, et il faut relacher vite. +// +// ⚠️ required_soc vise le CROISEMENT PV, plus lointain que 01:24 : le test est +// necessaire mais pas suffisant. L'erreur est unilaterale (long dwell une nuit +// ou le court aurait suffi, ~0,4 kWh deplaces, parc menage) — direction voulue. +const BALANCE_MAX_AGE_MIN = 30; // six cycles du bilan, qui tourne aux 5 min +const balanceStatusOk = low(input.balance_status) === 'ok'; +const balanceRequiredSocPct = num(input.balance_required_soc, NaN); +const balanceRunMs = parseRunStampToMs(input.balance_last_run, now.year, nowMs); +const balanceAgeMin = + balanceRunMs !== null && nowMs !== null ? (nowMs - balanceRunMs) / 60000 : null; +const balanceUsable = + balanceStatusOk && + Number.isFinite(balanceRequiredSocPct) && + balanceAgeMin !== null && + balanceAgeMin <= BALANCE_MAX_AGE_MIN && + balanceAgeMin >= -5; // horodatage dans le futur : rejete +const reserveDejaCondamnee = balanceUsable && soc < balanceRequiredSocPct; + +const dwellMinutes = reserveDejaCondamnee + ? Math.max(0, num(input.charge_dwell_long_min, 20)) + : Math.max(0, num(input.charge_dwell_court_min, 6)); + +const dwellEcoule = + minutesDepuisChargeHaute === null ? true : minutesDepuisChargeHaute >= dwellMinutes; + +// Le reseau reste du tant que la charge n'est pas retombee ET restee basse. +const chargeEncoreElevee = plafondDechargeW > 0 && !(chargeSousSeuilBas && dwellEcoule);`, + 'dwell'); + console.log(' calcul : relachement sur duree, duree gagee sur le bilan'); +} + +// ------------------------------------------------------- 4. la trace +if (!c.includes('reserve deja condamnee')) { + const ancre = ` state = 'hold_on_charge_encore_elevee'; + reason.push( + \`Charge maison \${Math.round(houseAcPowerW)}W encore au-dessus de \${Math.round(seuilRelachementW)}W : on garde le reseau\`);`; + c = remplace(c, ancre, ` state = 'hold_on_charge_encore_elevee'; + reason.push(chargeSousSeuilBas + ? \`Charge maison retombee a \${Math.round(houseAcPowerW)}W depuis \${round1(minutesDepuisChargeHaute || 0)} min : \` + + \`on garde le reseau jusqu'a \${dwellMinutes} min \` + + \`(\${reserveDejaCondamnee ? 'reserve deja condamnee, le reseau du soir est du de toute facon' : 'reserve suffisante, relachement rapide'})\` + : \`Charge maison \${Math.round(houseAcPowerW)}W encore au-dessus de \${Math.round(seuilRelachementW)}W : on garde le reseau\`);`, + 'trace'); + console.log(' calcul : la raison distingue le dwell de la charge haute'); +} + +// ------------------------------------------------------- 5. l ecriture +if (!c.includes('setChargeHighLastAt')) { + const ancre = `const setLastOffAt = + gridAction === 'turn_off';`; + c = remplace(c, ancre, ancre + ` + +// Horodatage de la derniere charge maison HAUTE. Ecrit quel que soit l'etat du +// contacteur : c'est ce qui garantit que le compteur de dwell ne depend pas de +// la decision qu'il alimente. +const setChargeHighLastAt = plafondDechargeW > 0 && !chargeSousSeuilBas;`, 'ecriture'); + + c = remplace(c, ` set_last_off_at: setLastOffAt,`, + ` set_last_off_at: setLastOffAt, + set_charge_high_last_at: setChargeHighLastAt,`, 'decision'); + + c = remplace(c, ` helper_last_off_at: {`, + ` helper_charge_high_last_at: { + domain: 'input_datetime', + service: 'set_datetime', + data: { + entity_id: 'input_datetime.victron_grid_charge_high_last_at', + datetime: now.stamp + } + }, + + helper_last_off_at: {`, 'appel'); + console.log(' calcul : ecriture de l horodatage de charge haute'); +} + +// ------------------------------------------------------- 6. le debug +if (!c.includes('dwell_minutes:')) { + c = remplace(c, ` charge_au_dessus_du_parc: chargeAuDessusDuParc,`, + ` charge_au_dessus_du_parc: chargeAuDessusDuParc, + charge_sous_seuil_bas: chargeSousSeuilBas, + minutes_depuis_charge_haute: + minutesDepuisChargeHaute !== null ? round1(minutesDepuisChargeHaute) : null, + dwell_minutes: dwellMinutes, + dwell_ecoule: dwellEcoule, + balance_usable: balanceUsable, + balance_required_soc_pct: + Number.isFinite(balanceRequiredSocPct) ? round1(balanceRequiredSocPct) : null, + balance_age_min: balanceAgeMin !== null ? round1(balanceAgeMin) : null, + reserve_deja_condamnee: reserveDejaCondamnee,`, 'debug'); + console.log(' calcul : debug expose'); +} + +// ------------------------------------------------- 7bis. capteur muet +// Le 05/09 de 21:22:21 a 21:49:35, `sensor.victron_battery_current` et +// `sensor.victron_battery_voltage` n'ont RIEN publie — 27 minutes — pendant que +// la consommation continuait de se mettre a jour normalement. Sans repli, le +// plafond tombait a zero et la protection s'evanouissait pile a l'heure du +// diner. Deux filets, tous deux fondes sur du mesure ou du persiste : +if (!c.includes('TENSION_PARC_NOMINALE_V')) { + c = remplace(c, `const plafondDechargeW = plafondDechargeA > 0 && tensionParcV > 0 + ? plafondDechargeA * tensionParcV : 0;`, + `// La tension a disparu 27 min d'affilee le 05/09 alors que le plafond en +// AMPERES restait lisible. Sur toute la soiree la tension mesuree n'est pas +// sortie de 48,85-51,5 V : un nominal a 51 V vaut au pire 5 % d'erreur sur le +// seuil, contre 100 % si la regle s'eteint. +const TENSION_PARC_NOMINALE_V = 51; +const plafondDechargeW = plafondDechargeA > 0 + ? plafondDechargeA * (tensionParcV > 0 ? tensionParcV : TENSION_PARC_NOMINALE_V) + : 0;`, 'tension nominale'); + console.log(' calcul : repli sur la tension nominale du parc'); +} + +if (!c.includes('protectionRecente')) { + c = remplace(c, `const chargeEncoreElevee = plafondDechargeW > 0 && !(chargeSousSeuilBas && dwellEcoule);`, + `// Si le plafond devient illisible EN COURS de protection, l'horodatage de +// charge haute reste, lui, un fait PERSISTE : on tient jusqu'a la fin du dwell +// plutot que de rendre le reseau sur un capteur muet. La memoire ne survit pas +// au dwell — l'horodatage n'etant ecrit que quand le plafond est lisible, un +// capteur mort depuis des heures rend bien la regle inerte. +const protectionRecente = !dwellEcoule; +const chargeEncoreElevee = plafondDechargeW > 0 + ? !(chargeSousSeuilBas && dwellEcoule) + : protectionRecente;`, 'memoire de protection'); + console.log(' calcul : la protection survit a un capteur muet'); +} + +calc.parameters.jsCode = c; + +// ------------------------------------------------- 7. Build Actions +let b = build.parameters.jsCode; +if (!b.includes('helper_charge_high_last_at')) { + b = remplace(b, `if (decision.set_last_off_at) {`, + `if (decision.set_charge_high_last_at) { + pushAction('helper_charge_high_last_at', calls.helper_charge_high_last_at, true); +} + +if (decision.set_last_off_at) {`, 'build'); + build.parameters.jsCode = b; + console.log(' actions : ecriture de l horodatage poussee'); +} else console.log(' actions : deja fait'); + +fs.writeFileSync(F, JSON.stringify(wf, null, 2) + '\n'); +console.log(`\n${F} ecrit.`); diff --git a/workflows/victron-grid-hysteresis-v1.json b/workflows/victron-grid-hysteresis-v1.json index c3aae60..eefa267 100644 --- a/workflows/victron-grid-hysteresis-v1.json +++ b/workflows/victron-grid-hysteresis-v1.json @@ -51,7 +51,7 @@ "parameters": [ { "name": "template", - "value": "{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"battery_max_discharge_a\": states(\"sensor.airtria_8_us2000c_maximum_allowed_discharge_current\"),\n \"battery_voltage_v\": states(\"sensor.victron_battery_voltage\"),\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n \"evcs_charging\": states(\"switch.victron_evcs_charging\"),\n \"evcs_status_raw\": states(\"sensor.victron_evcs_status_raw\"),\n\n \"hysteresis_enabled\": states(\"input_boolean.victron_grid_hysteresis_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_grid_hysteresis_notifications\"),\n \"grid_latch\": states(\"input_boolean.victron_grid_hysteresis_latch\"),\n\n \"grid_soc_on\": states(\"input_number.victron_grid_soc_on\"),\n \"grid_soc_off\": states(\"input_number.victron_grid_soc_off\"),\n \"grid_min_on_minutes\": states(\"input_number.victron_grid_min_on_minutes\"),\n \"grid_cooldown_minutes\": states(\"input_number.victron_grid_cooldown_minutes\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"last_on_at\": states(\"input_datetime.victron_grid_last_on_at\"),\n \"last_off_at\": states(\"input_datetime.victron_grid_last_off_at\"),\n \"last_signature\": states(\"input_text.victron_grid_control_last_signature\"),\n\n \"evcs_priority_active\": states(\"input_boolean.victron_evcs_priority_active\"),\n \"night_grid_takeover_needed\": states(\"input_boolean.victron_evcs_night_grid_takeover_needed\"),\n \"evcs_source_mode\": states(\"input_text.victron_evcs_source_mode\"),\n \"user_strategy\": states(\"input_select.victron_evcs_strategy\"),\n \"evcs_surplus_state\": states(\"input_text.victron_evcs_surplus_state\")\n} | tojson }}\n" + "value": "{{ {\n \"soc_percent\": states(\"sensor.victron_battery_soc\"),\n \"grid_state\": states(\"switch.edf_general_ok_switch\"),\n \"pv_power_w\": states(\"sensor.victron_pv_power_system\"),\n\n \"house_ac_power_w\": states(\"sensor.victron_consumption_power_l1\"),\n \"battery_max_discharge_a\": states(\"sensor.airtria_8_us2000c_maximum_allowed_discharge_current\"),\n \"battery_voltage_v\": states(\"sensor.victron_battery_voltage\"),\n \"charge_high_last_at\": states(\"input_datetime.victron_grid_charge_high_last_at\"),\n \"charge_dwell_long_min\": states(\"input_number.victron_grid_charge_dwell_long_min\"),\n \"charge_dwell_court_min\": states(\"input_number.victron_grid_charge_dwell_court_min\"),\n \"balance_required_soc\": states(\"input_number.evcs_balance_required_soc\"),\n \"balance_status\": states(\"input_text.evcs_balance_status\"),\n \"balance_last_run\": states(\"input_text.evcs_balance_last_run\"),\n \"grid_power_w\": states(\"sensor.victron_grid_power_l1\"),\n\n \"evcs_ac_power_w\": states(\"sensor.victron_evcs_ac_power\"),\n \"evcs_charging\": states(\"switch.victron_evcs_charging\"),\n \"evcs_status_raw\": states(\"sensor.victron_evcs_status_raw\"),\n\n \"hysteresis_enabled\": states(\"input_boolean.victron_grid_hysteresis_enabled\"),\n \"notifications_enabled\": states(\"input_boolean.victron_grid_hysteresis_notifications\"),\n \"grid_latch\": states(\"input_boolean.victron_grid_hysteresis_latch\"),\n\n \"grid_soc_on\": states(\"input_number.victron_grid_soc_on\"),\n \"grid_soc_off\": states(\"input_number.victron_grid_soc_off\"),\n \"grid_min_on_minutes\": states(\"input_number.victron_grid_min_on_minutes\"),\n \"grid_cooldown_minutes\": states(\"input_number.victron_grid_cooldown_minutes\"),\n \"ac_loads_max_safe_w\": states(\"input_number.victron_ac_loads_max_safe_w\"),\n\n \"last_on_at\": states(\"input_datetime.victron_grid_last_on_at\"),\n \"last_off_at\": states(\"input_datetime.victron_grid_last_off_at\"),\n \"last_signature\": states(\"input_text.victron_grid_control_last_signature\"),\n\n \"evcs_priority_active\": states(\"input_boolean.victron_evcs_priority_active\"),\n \"night_grid_takeover_needed\": states(\"input_boolean.victron_evcs_night_grid_takeover_needed\"),\n \"evcs_source_mode\": states(\"input_text.victron_evcs_source_mode\"),\n \"user_strategy\": states(\"input_select.victron_evcs_strategy\"),\n \"evcs_surplus_state\": states(\"input_text.victron_evcs_surplus_state\")\n} | tojson }}\n" } ] }, @@ -74,7 +74,7 @@ }, { "parameters": { - "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction pickDefined(...values) {\n for (const v of values) {\n if (v === undefined || v === null) continue;\n if (typeof v === 'string' && v.trim() === '') continue;\n return v;\n }\n return undefined;\n}\n\nfunction isOnLike(v) {\n const s = low(v);\n return s === 'on' || s === 'true' || s === '1' || s === 'yes';\n}\n\nfunction getParisNowParts() {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date())\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour: Number(parts.hour),\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n };\n}\n\nfunction parseLocalStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n return new Date(y, mo - 1, d, h, mi, se).getTime();\n}\n\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n};\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\nconst nowMs = parseLocalStampToMs(now.stamp);\n\n// ======================================================\n// INPUTS PRINCIPAUX\n// ======================================================\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\nconst gridStateRaw = low(input.grid_state || 'off');\nconst gridIsOn = gridStateRaw === 'on';\nconst gridSwitchUnavailable = gridStateRaw === 'unavailable' || gridStateRaw === 'unknown' || gridStateRaw === 'none';\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\n\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\nconst gridPowerW = num(input.grid_power_w, 0);\n\nconst evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));\nconst evcsCharging = low(input.evcs_charging || 'off') === 'on';\nconst evcsStatusRaw = num(input.evcs_status_raw, NaN);\n\nconst hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\nconst latchIsOn = low(input.grid_latch || 'off') === 'on';\n\nconst gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);\nconst gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);\nconst gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));\nconst gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));\n\nconst lastOnAt = (input.last_on_at ?? '').toString().trim();\nconst lastOffAt = (input.last_off_at ?? '').toString().trim();\nconst lastSignature = (input.last_signature ?? '').toString().trim();\n\nconst lastOnMs = parseLocalStampToMs(lastOnAt);\nconst lastOffMs = parseLocalStampToMs(lastOffAt);\n\nconst minutesSinceLastOn =\n lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;\n\nconst minutesSinceLastOff =\n lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;\n\nconst minOnElapsed =\n minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;\n\nconst cooldownElapsed =\n minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;\n\nconst socBand =\n soc <= gridSocOn ? 'low' :\n soc < gridSocOff ? 'mid' :\n 'high';\n\nconst evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;\nconst acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;\n\n// ======================================================\n// CHARGE MAISON AU-DELA DE CE QUE LA BATTERIE SAIT DONNER\n// ======================================================\n// Le plafond vient du BMS lui-meme et suit le nombre de modules : rien n'est\n// code en dur, retirer ou ajouter un module deplace le seuil tout seul.\nconst plafondDechargeA = num(input.battery_max_discharge_a, 0);\nconst tensionParcV = num(input.battery_voltage_v, 0);\nconst plafondDechargeW = plafondDechargeA > 0 && tensionParcV > 0\n ? plafondDechargeA * tensionParcV : 0;\n// Bande large et volontairement dissymetrique : on ferme a 80 % du plafond, on\n// ne relache qu'en dessous de 50 %. Les deux seuils portent sur la charge\n// maison, grandeur que le contacteur ne modifie pas.\nconst seuilFermetureW = plafondDechargeW * 0.80;\nconst seuilRelachementW = plafondDechargeW * 0.50;\nconst chargeAuDessusDuParc = plafondDechargeW > 0 && houseAcPowerW >= seuilFermetureW;\nconst chargeEncoreElevee = plafondDechargeW > 0 && houseAcPowerW >= seuilRelachementW;\n\n// ======================================================\n// EVCS OVERRIDE INPUTS\n// ======================================================\nconst evcsPriorityActive = isOnLike(\n pickDefined(\n input.evcs_priority_active,\n input.victron_evcs_priority_active,\n input['input_boolean.victron_evcs_priority_active']\n )\n);\n\nconst evcsNightGridTakeoverNeeded = isOnLike(\n pickDefined(\n input.night_grid_takeover_needed,\n input.victron_evcs_night_grid_takeover_needed,\n input['input_boolean.victron_evcs_night_grid_takeover_needed']\n )\n);\n\nconst evcsSourceModeRaw = (\n pickDefined(\n input.evcs_source_mode,\n input.victron_evcs_source_mode,\n input['input_text.victron_evcs_source_mode']\n ) ?? ''\n).toString().trim();\n\nconst evcsSourceMode = low(evcsSourceModeRaw);\n\nconst userStrategyRaw = (\n pickDefined(\n input.user_strategy,\n input.victron_evcs_user_strategy,\n input['input_select.victron_evcs_user_strategy']\n ) ?? ''\n).toString().trim();\n\nconst userStrategy = low(userStrategyRaw);\n\n// ======================================================\n// EVCS SEMANTIC STATE\n// Ici on ne détecte PAS la voiture pleine par seuils.\n// On consomme seulement l'état sémantique déjà produit\n// par le workflow EVCS.\n// ======================================================\nconst evcsSurplusStateRaw = (\n pickDefined(\n input.evcs_surplus_state,\n input.victron_evcs_surplus_state,\n input['input_text.victron_evcs_surplus_state']\n ) ?? ''\n).toString().trim();\n\nconst evcsSurplusState = low(evcsSurplusStateRaw);\n\nconst evcsVehicleNoDemandSemantic =\n evcsSurplusState === 'vehicle_full_or_paused' ||\n evcsSurplusState === 'vehicle_not_requesting_power' ||\n evcsSurplusState === 'vehicle_full' ||\n evcsSurplusState === 'charge_complete' ||\n evcsSurplusState.includes('full_or_paused') ||\n evcsSurplusState.includes('not_requesting_power') ||\n evcsSurplusState.includes('charge_complete') ||\n evcsSurplusState.includes('no_demand');\n\n// ======================================================\n// OVERRIDE EVCS VERS GRID\n// CORRECTION:\n// - evcsPriorityActive seul ne doit PLUS suffire à allumer EDF\n// - seuls les cas explicitement réseau peuvent forcer le grid\n// - ainsi \"Réseau HC midi = OFF\" redevient réellement respecté\n// ======================================================\nconst evcsExplicitGridRequest =\n userStrategy === 'force_grid_now' ||\n userStrategy === 'force_charge_now' ||\n evcsNightGridTakeoverNeeded ||\n evcsSourceMode === 'réseau forcé' ||\n evcsSourceMode === 'réseau hc autorisé' ||\n evcsSourceMode === 'grid_forced' ||\n evcsSourceMode === 'grid_hc_allowed';\n\nconst evcsGridOverride =\n !evcsVehicleNoDemandSemantic &&\n evcsExplicitGridRequest;\n\n// ======================================================\n// DÉCISION\n// ======================================================\nlet state = 'observe_only';\nlet reason = [];\nlet desiredGrid = gridIsOn ? 'on' : 'off';\nlet desiredLatch = latchIsOn ? 'on' : 'off';\n\n// ======================================================\n// FORCE_GRID + VEHICLE FULL IMMEDIATE CUT\n// FIX V2.5: Si force_grid_now actif et voiture pleine,\n// couper EDF immédiatement sans attendre minOnRuntime.\n// ======================================================\nlet forcedCut = false;\nif (\n userStrategy === 'force_grid_now' &&\n evcsVehicleNoDemandSemantic &&\n latchIsOn &&\n !chargeEncoreElevee // la maison prime\n) {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_force_grid_vehicle_full';\n reason.push('Force grid + voiture pleine : coupure immediate du reseau');\n forcedCut = true;\n}\n\n// ======================================================\n// FIX C (08/08/2026): VEHICULE DEBRANCHE -> COUPURE IMMEDIATE\n// Incident nuit 07-08/08 : apres debranchement du vehicule, EDF restait\n// maintenu ON 25 min (hold_on_min_runtime) alors que plus rien ne le\n// justifiait (SOC >= seuil OFF). Ici : coupure immediate, bypass min_on.\n// ======================================================\nif (\n !forcedCut &&\n latchIsOn &&\n gridIsOn &&\n !evcsGridOverride &&\n evcsSurplusState === 'vehicle_not_connected' &&\n soc >= gridSocOff &&\n !chargeEncoreElevee // la maison prime\n) {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_vehicle_unplugged';\n reason.push('Vehicule debranche + SOC >= seuil OFF : coupure immediate du reseau (bypass min_on)');\n forcedCut = true;\n}\n\n// ======================================================\n// FIX E2 (09/08/2026): RECOVERY DONE -> COUPURE IMMEDIATE\n// 'night_recovery_done_idle' est un etat COLLANT du controleur EVCS :\n// charge terminee ET batterie a la cible, le takeover ne reviendra pas\n// (sauf SOC <= cible-2, qui repasserait par l'override). Attendre le\n// min_on 25 min ne protege ici de rien : coupure immediate.\n// ======================================================\nif (\n !forcedCut &&\n latchIsOn &&\n gridIsOn &&\n !evcsGridOverride &&\n (\n evcsSurplusState === 'night_recovery_done_idle' ||\n evcsSurplusState === 'departure_done_idle'\n ) &&\n soc >= gridSocOff &&\n !chargeEncoreElevee // la maison prime\n) {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_recovery_done';\n reason.push('Charge terminee + batterie a la cible : coupure immediate du reseau (bypass min_on)');\n forcedCut = true;\n}\n\n\n// ======================================================\n// CORE HYSTERESIS LOGIC\n// ======================================================\nif (gridSwitchUnavailable) {\n // Switch EDF hors ligne : mode sûr. Aucune action, aucun timestamp,\n // on garde le latch tel quel et on réévaluera au retour du switch.\n state = 'switch_unavailable';\n desiredGrid = gridIsOn ? 'on' : 'off';\n desiredLatch = latchIsOn ? 'on' : 'off';\n reason.push('Switch EDF indisponible : aucune action, attente du retour');\n\n} else if (forcedCut) {\n // Décision déjà prise plus haut (force_grid + voiture pleine) :\n // on ne laisse plus la chaîne principale l'écraser.\n\n} else if (evcsGridOverride) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n\n if (!gridIsOn) {\n state = 'turn_on_evcs_override';\n reason.push('Override EVCS actif : activation EDF forcée');\n } else if (!latchIsOn) {\n state = 'adopt_existing_grid_on_evcs_override';\n reason.push('Override EVCS actif : EDF déjà ON, adoption par latch');\n } else {\n state = 'hold_on_evcs_override';\n reason.push('Override EVCS actif : maintien EDF ON');\n }\n\n if (userStrategy === 'force_grid_now') {\n reason.push('Cause override : force_grid_now');\n } else if (userStrategy === 'force_charge_now') {\n reason.push('Cause override : force_charge_now');\n } else if (evcsNightGridTakeoverNeeded) {\n reason.push('Cause override : night_grid_takeover_needed');\n } else {\n reason.push(`Cause override : source_mode=${evcsSourceModeRaw || 'n/a'}`);\n }\n\n} else if (!hysteresisEnabled) {\n state = 'hysteresis_disabled';\n desiredGrid = gridIsOn ? 'on' : 'off';\n desiredLatch = latchIsOn ? 'on' : 'off';\n reason.push('Automatisme hystérésis réseau désactivé');\n\n} else if (chargeAuDessusDuParc) {\n // Priorite sur la bande SOC : c'est une contrainte de PUISSANCE, elle ne se\n // resout pas en attendant que le SOC descende.\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = gridIsOn ? 'hold_on_charge_au_dessus_du_parc' : 'turn_on_charge_au_dessus_du_parc';\n reason.push(\n `Charge maison ${Math.round(houseAcPowerW)}W au-dessus de ${Math.round(seuilFermetureW)}W `\n + `(80% du plafond de decharge BMS ${Math.round(plafondDechargeW)}W) : le reseau prend la difference`);\n\n} else if (soc <= gridSocOn) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n\n if (!cooldownElapsed && !gridIsOn) {\n state = 'turn_on_low_soc_cooldown_bypassed';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`);\n } else if (!gridIsOn) {\n state = 'turn_on_low_soc';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`);\n } else if (!latchIsOn) {\n state = 'adopt_existing_grid_on_low_soc';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`);\n } else {\n state = 'hold_on_low_soc';\n reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`);\n }\n\n} else if (gridIsOn && !latchIsOn) {\n if (soc < gridSocOff) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'adopt_existing_grid_on';\n reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`);\n } else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_stray_grid_on';\n reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`);\n }\n\n} else if (latchIsOn && chargeEncoreElevee) {\n // Tant que la maison consomme plus de la moitie de ce que la batterie sait\n // donner, on garde le reseau — meme si le SOC autoriserait de le lacher.\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'hold_on_charge_encore_elevee';\n reason.push(\n `Charge maison ${Math.round(houseAcPowerW)}W encore au-dessus de ${Math.round(seuilRelachementW)}W : on garde le reseau`);\n\n} else if (latchIsOn) {\n if (!minOnElapsed) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'hold_on_min_runtime';\n reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`);\n } else if (soc < gridSocOff) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'hold_on_until_soc_off';\n reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`);\n } else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_recovered';\n reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`);\n }\n\n} else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'observe_only';\n reason.push('Aucune action : hors zone d’hystérésis');\n}\n\n// ======================================================\n// COMPARE DESIRED VS CURRENT\n// ======================================================\nconst gridAction =\n desiredGrid === (gridIsOn ? 'on' : 'off')\n ? 'none'\n : desiredGrid === 'on'\n ? 'turn_on'\n : 'turn_off';\n\nconst latchAction =\n desiredLatch === (latchIsOn ? 'on' : 'off')\n ? 'none'\n : desiredLatch === 'on'\n ? 'turn_on'\n : 'turn_off';\n\n// ======================================================\n// TIMESTAMPS\n// ======================================================\nconst setLastOnAt =\n (gridAction === 'turn_on' && latchAction === 'turn_on') ||\n state === 'adopt_existing_grid_on' ||\n state === 'adopt_existing_grid_on_low_soc' ||\n state === 'adopt_existing_grid_on_evcs_override';\n\nconst setLastOffAt =\n gridAction === 'turn_off';\n\n// ======================================================\n// CONTEXT / ALERTS\n// ======================================================\nreason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`);\nreason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`);\nreason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`);\nreason.push(`PV ${Math.round(pvPowerW)}W`);\nreason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`);\nreason.push(`Puissance réseau ${Math.round(gridPowerW)}W`);\nreason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`);\n\nif (minutesSinceLastOn !== null) {\n reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`);\n}\nif (minutesSinceLastOff !== null) {\n reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`);\n}\n\nif (acOverSafeLimit) {\n reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`);\n}\n\nreason.push(`EVCS override ${evcsGridOverride ? 'ON' : 'OFF'}`);\nreason.push(`EVCS explicit grid request ${evcsExplicitGridRequest ? 'ON' : 'OFF'}`);\nreason.push(`EVCS priority ${evcsPriorityActive ? 'ON' : 'OFF'}`);\nreason.push(`EVCS night takeover ${evcsNightGridTakeoverNeeded ? 'ON' : 'OFF'}`);\nreason.push(`EVCS source ${evcsSourceModeRaw || 'n/a'}`);\nreason.push(`EVCS strategy ${userStrategyRaw || 'n/a'}`);\nreason.push(`EVCS surplus state ${evcsSurplusStateRaw || 'n/a'}`);\nreason.push(`EVCS no-demand semantic ${evcsVehicleNoDemandSemantic ? 'ON' : 'OFF'}`);\n\n// ======================================================\n// NOTIFICATIONS\n// ======================================================\nlet notifyTitle = 'Victron Grid Hysteresis';\nlet notifyMessage = '';\nlet notifyWanted = false;\n\nif (\n state === 'turn_on_evcs_override' ||\n state === 'adopt_existing_grid_on_evcs_override' ||\n state === 'hold_on_evcs_override'\n) {\n notifyTitle = '🚗 EDF forcé par EVCS';\n notifyMessage = `Priorité EVCS active. EDF maintenu ON pour la recharge véhicule. Cause: ${userStrategyRaw || evcsSourceModeRaw || 'override EVCS'}.`;\n notifyWanted = true;\n\n} else if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') {\n notifyTitle = '⚡ EDF activé sur SOC bas';\n notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\n notifyWanted = true;\n\n} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') {\n notifyTitle = '🔁 EDF ON repris par hystérésis';\n notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_vehicle_unplugged') {\n notifyTitle = '✅ EDF coupé (véhicule débranché)';\n notifyMessage = `Véhicule débranché et SOC ${round1(soc)}% >= ${gridSocOff}%. Coupure immédiate du réseau.`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_recovery_done') {\n notifyTitle = '✅ EDF coupé (charge terminée)';\n notifyMessage = `Charge VE terminée et batterie à la cible (SOC ${round1(soc)}%). Coupure immédiate du réseau.`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_recovered') {\n notifyTitle = '✅ EDF coupé après recharge';\n notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_stray_grid_on') {\n notifyTitle = '✅ EDF coupé (reprise de contrôle)';\n notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`;\n notifyWanted = true;\n\n} else if (acOverSafeLimit && !gridIsOn) {\n notifyTitle = '🚨 Charge AC élevée hors réseau';\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`;\n notifyWanted = true;\n\n} else if (acOverSafeLimit && gridIsOn) {\n notifyTitle = '⚠️ Charge AC élevée';\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`;\n notifyWanted = true;\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n state,\n `dg=${desiredGrid}`,\n `dl=${desiredLatch}`,\n `sb=${socBand}`,\n `ac=${acOverSafeLimit ? '1' : '0'}`,\n `ov=${evcsGridOverride ? '1' : '0'}`,\n `eg=${evcsExplicitGridRequest ? '1' : '0'}`,\n `ep=${evcsPriorityActive ? '1' : '0'}`,\n `es=${evcsSurplusState || 'na'}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\n// ======================================================\n// OUTPUT\n// ======================================================\nconst result = {\n timestamp_paris: now.stamp,\n\n inputs: {\n soc_percent: soc,\n grid_state: gridIsOn ? 'on' : 'off',\n pv_power_w: pvPowerW,\n house_ac_power_w: houseAcPowerW,\n grid_power_w: gridPowerW,\n evcs_ac_power_w: evcsAcPowerW,\n evcs_charging: evcsCharging,\n evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null,\n\n hysteresis_enabled: hysteresisEnabled,\n notifications_enabled: notificationsEnabled,\n grid_latch: latchIsOn,\n grid_soc_on: gridSocOn,\n grid_soc_off: gridSocOff,\n grid_min_on_minutes: gridMinOnMinutes,\n grid_cooldown_minutes: gridCooldownMinutes,\n ac_loads_max_safe_w: acLoadsMaxSafeW,\n\n last_on_at: lastOnAt || null,\n last_off_at: lastOffAt || null,\n last_signature: lastSignature || null,\n\n evcs_priority_active: evcsPriorityActive,\n night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\n evcs_source_mode: evcsSourceModeRaw || null,\n user_strategy: userStrategyRaw || null,\n evcs_surplus_state: evcsSurplusStateRaw || null\n },\n\n computed: {\n desired_grid: desiredGrid,\n desired_latch: desiredLatch,\n minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null,\n minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null,\n min_on_elapsed: minOnElapsed,\n cooldown_elapsed: cooldownElapsed,\n ac_over_safe_limit: acOverSafeLimit,\n plafond_decharge_w: Math.round(plafondDechargeW),\n charge_au_dessus_du_parc: chargeAuDessusDuParc,\n evcs_seems_active: evcsSeemsActive,\n\n evcs_grid_override: evcsGridOverride,\n evcs_explicit_grid_request: evcsExplicitGridRequest,\n evcs_priority_active: evcsPriorityActive,\n evcs_night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\n evcs_source_mode: evcsSourceModeRaw || null,\n evcs_user_strategy: userStrategyRaw || null,\n evcs_surplus_state: evcsSurplusStateRaw || null,\n evcs_vehicle_no_demand_semantic: evcsVehicleNoDemandSemantic\n },\n\n decision: {\n state,\n grid_action: gridAction,\n latch_action: latchAction,\n set_last_on_at: setLastOnAt,\n set_last_off_at: setLastOffAt,\n should_notify: notificationsEnabled && signatureChanged && notifyWanted,\n signature,\n signature_changed: signatureChanged,\n reason,\n short_reason: shortReason\n },\n\n ha_service_calls: {\n helper_state: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_state',\n value: state\n }\n },\n\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n\n helper_signature: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_last_signature',\n value: signature\n }\n },\n\n helper_latch: {\n domain: 'input_boolean',\n service: desiredLatch === 'on' ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_grid_hysteresis_latch'\n }\n },\n\n helper_last_on_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_grid_last_on_at',\n datetime: now.stamp\n }\n },\n\n helper_last_off_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_grid_last_off_at',\n datetime: now.stamp\n }\n },\n\n grid: {\n domain: 'switch',\n service: desiredGrid === 'on' ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n logbook: {\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron Grid Hysteresis',\n message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`,\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n mobile_notification: {\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage,\n data: {\n push: {\n 'interruption-level': 'time-sensitive',\n sound: { name: 'default' }\n },\n ttl: 0,\n priority: 'high'\n }\n }\n }\n }\n};\n\nreturn [{ json: result }];\n" + "jsCode": "function num(v, fallback = NaN) {\n const n = Number.parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n}\n\nfunction low(v) {\n return (v ?? '').toString().trim().toLowerCase();\n}\n\nfunction parseMaybeJson(v) {\n if (typeof v !== 'string') return v;\n try {\n return JSON.parse(v);\n } catch {\n return v;\n }\n}\n\nfunction extractSnapshot(input) {\n if (typeof input === 'string') return parseMaybeJson(input);\n if (input?.data && typeof input.data === 'string') return parseMaybeJson(input.data);\n if (input?.body && typeof input.body === 'string') return parseMaybeJson(input.body);\n if (input?.data && typeof input.data === 'object') return input.data;\n if (input?.body && typeof input.body === 'object') return input.body;\n return input;\n}\n\nfunction round1(v) {\n return Math.round(v * 10) / 10;\n}\n\nfunction clamp(v, min, max) {\n return Math.min(Math.max(v, min), max);\n}\n\nfunction pickDefined(...values) {\n for (const v of values) {\n if (v === undefined || v === null) continue;\n if (typeof v === 'string' && v.trim() === '') continue;\n return v;\n }\n return undefined;\n}\n\nfunction isOnLike(v) {\n const s = low(v);\n return s === 'on' || s === 'true' || s === '1' || s === 'yes';\n}\n\nfunction getParisNowParts() {\n const fmt = new Intl.DateTimeFormat('fr-FR', {\n timeZone: 'Europe/Paris',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n });\n\n const parts = Object.fromEntries(\n fmt.formatToParts(new Date())\n .filter(p => p.type !== 'literal')\n .map(p => [p.type, p.value])\n );\n\n const date = `${parts.year}-${parts.month}-${parts.day}`;\n const stamp = `${date} ${parts.hour}:${parts.minute}:${parts.second}`;\n\n return {\n year: Number(parts.year),\n month: Number(parts.month),\n day: Number(parts.day),\n hour: Number(parts.hour),\n minute: Number(parts.minute),\n second: Number(parts.second),\n date,\n stamp,\n };\n}\n\nfunction parseLocalStampToMs(s) {\n const text = (s ?? '').toString().trim();\n const m = text.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const y = Number(m[1]);\n const mo = Number(m[2]);\n const d = Number(m[3]);\n const h = Number(m[4]);\n const mi = Number(m[5]);\n const se = Number(m[6]);\n\n if (![y, mo, d, h, mi, se].every(Number.isFinite)) return null;\n\n return new Date(y, mo - 1, d, h, mi, se).getTime();\n}\n\n// Le bilan energetique publie son horodatage au format \"DD/MM HH:mm\" (le meme\n// que l'advisor). On le ramene au format de parseLocalStampToMs plutot que\n// d'ajouter un second systeme de dates : toute l'arithmetique de ce node se\n// fait sur une heure murale de Paris, la conversion se compense d'elle-meme.\nfunction parseRunStampToMs(s, year, nowMsRef) {\n const m = (s ?? '').toString().trim().match(/^(\\d{2})\\/(\\d{2}) (\\d{2}):(\\d{2})$/);\n if (!m) return null;\n\n const build = (y) => parseLocalStampToMs(`${y}-${m[2]}-${m[1]} ${m[3]}:${m[4]}:00`);\n let ts = build(year);\n // Passage d'annee : un horodatage qui tomberait plus d'un jour dans le futur\n // vient forcement de l'annee precedente.\n if (ts !== null && nowMsRef !== null && ts - nowMsRef > 24 * 3600 * 1000) ts = build(year - 1);\n return ts;\n}\n\nconst cfg = {\n mobileNotifyDomain: 'notify',\n mobileNotifyService: 'mobile_app_iphone_16_de_tt',\n};\n\nconst raw = $input.first().json;\nconst input = extractSnapshot(raw);\nconst now = getParisNowParts();\nconst nowMs = parseLocalStampToMs(now.stamp);\n\n// ======================================================\n// INPUTS PRINCIPAUX\n// ======================================================\nconst soc = clamp(num(input.soc_percent, 0), 0, 100);\nconst gridStateRaw = low(input.grid_state || 'off');\nconst gridIsOn = gridStateRaw === 'on';\nconst gridSwitchUnavailable = gridStateRaw === 'unavailable' || gridStateRaw === 'unknown' || gridStateRaw === 'none';\nconst pvPowerW = Math.max(0, num(input.pv_power_w, 0));\n\nconst houseAcPowerW = Math.max(0, num(input.house_ac_power_w, 0));\nconst gridPowerW = num(input.grid_power_w, 0);\n\nconst evcsAcPowerW = Math.max(0, num(input.evcs_ac_power_w, 0));\nconst evcsCharging = low(input.evcs_charging || 'off') === 'on';\nconst evcsStatusRaw = num(input.evcs_status_raw, NaN);\n\nconst hysteresisEnabled = low(input.hysteresis_enabled || 'off') === 'on';\nconst notificationsEnabled = low(input.notifications_enabled || 'off') === 'on';\nconst latchIsOn = low(input.grid_latch || 'off') === 'on';\n\nconst gridSocOn = clamp(num(input.grid_soc_on, 22), 0, 100);\nconst gridSocOff = clamp(num(input.grid_soc_off, 35), 0, 100);\nconst gridMinOnMinutes = Math.max(0, num(input.grid_min_on_minutes, 45));\nconst gridCooldownMinutes = Math.max(0, num(input.grid_cooldown_minutes, 15));\nconst acLoadsMaxSafeW = Math.max(0, num(input.ac_loads_max_safe_w, 7800));\n\nconst lastOnAt = (input.last_on_at ?? '').toString().trim();\nconst lastOffAt = (input.last_off_at ?? '').toString().trim();\nconst lastSignature = (input.last_signature ?? '').toString().trim();\n\nconst lastOnMs = parseLocalStampToMs(lastOnAt);\nconst lastOffMs = parseLocalStampToMs(lastOffAt);\n\nconst minutesSinceLastOn =\n lastOnMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOnMs) / 60000) : null;\n\nconst minutesSinceLastOff =\n lastOffMs !== null && nowMs !== null ? Math.max(0, (nowMs - lastOffMs) / 60000) : null;\n\nconst minOnElapsed =\n minutesSinceLastOn === null ? true : minutesSinceLastOn >= gridMinOnMinutes;\n\nconst cooldownElapsed =\n minutesSinceLastOff === null ? true : minutesSinceLastOff >= gridCooldownMinutes;\n\nconst socBand =\n soc <= gridSocOn ? 'low' :\n soc < gridSocOff ? 'mid' :\n 'high';\n\nconst evcsSeemsActive = evcsCharging || evcsAcPowerW > 300;\nconst acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;\n\n// ======================================================\n// CHARGE MAISON AU-DELA DE CE QUE LA BATTERIE SAIT DONNER\n// ======================================================\n// Le plafond vient du BMS lui-meme et suit le nombre de modules : rien n'est\n// code en dur, retirer ou ajouter un module deplace le seuil tout seul.\nconst plafondDechargeA = num(input.battery_max_discharge_a, 0);\nconst tensionParcV = num(input.battery_voltage_v, 0);\n// La tension a disparu 27 min d'affilee le 05/09 alors que le plafond en\n// AMPERES restait lisible. Sur toute la soiree la tension mesuree n'est pas\n// sortie de 48,85-51,5 V : un nominal a 51 V vaut au pire 5 % d'erreur sur le\n// seuil, contre 100 % si la regle s'eteint.\nconst TENSION_PARC_NOMINALE_V = 51;\nconst plafondDechargeW = plafondDechargeA > 0\n ? plafondDechargeA * (tensionParcV > 0 ? tensionParcV : TENSION_PARC_NOMINALE_V)\n : 0;\n// Bande large et volontairement dissymetrique : on ferme a 80 % du plafond, on\n// ne relache qu'en dessous de 50 %. Les deux seuils portent sur la charge\n// maison, grandeur que le contacteur ne modifie pas.\nconst seuilFermetureW = plafondDechargeW * 0.80;\nconst seuilRelachementW = plafondDechargeW * 0.50;\nconst chargeAuDessusDuParc = plafondDechargeW > 0 && houseAcPowerW >= seuilFermetureW;\nconst chargeSousSeuilBas = plafondDechargeW > 0 && houseAcPowerW < seuilRelachementW;\n\n// ======================================================\n// LE RELACHEMENT SE MESURE EN DUREE (06/09/2026)\n// ======================================================\n// Soiree du 05/09, seuil de relachement 2550 W, pas d'une minute :\n// pendant la cuisson 20:33->21:37 : 13 accalmies, la plus longue 5 min\n// apres la cuisson 21:40->01:00 : 1 accalmie, 201 min\n// Un creux de 20 minutes n'existe pas tant que ca cuisine. Le relachement\n// instantane offrait donc 13 occasions de rendre le reseau au milieu du\n// service, chacune facturee 50 a 150 Wh de transitoire.\n//\n// L'horodatage porte la derniere charge maison HAUTE et il est ecrit que le\n// contacteur soit ouvert ou ferme : il suit une grandeur que la decision ne\n// modifie pas. Absent ou illisible => dwell repute ecoule => comportement\n// d'avant ce correctif, jamais une supposition.\nconst chargeHighLastAt = (input.charge_high_last_at ?? '').toString().trim();\nconst chargeHighLastMs = parseLocalStampToMs(chargeHighLastAt);\nconst minutesDepuisChargeHaute =\n chargeHighLastMs !== null && nowMs !== null\n ? Math.max(0, (nowMs - chargeHighLastMs) / 60000)\n : null;\n\n// La duree vient du BILAN. Tant que la batterie touchera son plancher avant\n// l'ouverture des heures creuses, l'energie reseau du soir est due de toute\n// facon : la tenir plus longtemps ne change que l'heure d'achat. Des qu'elle\n// passerait la nuit seule, chaque kWh du soir deplace un kWh d'heures creuses\n// vers des heures pleines, et il faut relacher vite.\n//\n// ⚠️ required_soc vise le CROISEMENT PV, plus lointain que 01:24 : le test est\n// necessaire mais pas suffisant. L'erreur est unilaterale (long dwell une nuit\n// ou le court aurait suffi, ~0,4 kWh deplaces, parc menage) — direction voulue.\nconst BALANCE_MAX_AGE_MIN = 30; // six cycles du bilan, qui tourne aux 5 min\nconst balanceStatusOk = low(input.balance_status) === 'ok';\nconst balanceRequiredSocPct = num(input.balance_required_soc, NaN);\nconst balanceRunMs = parseRunStampToMs(input.balance_last_run, now.year, nowMs);\nconst balanceAgeMin =\n balanceRunMs !== null && nowMs !== null ? (nowMs - balanceRunMs) / 60000 : null;\nconst balanceUsable =\n balanceStatusOk &&\n Number.isFinite(balanceRequiredSocPct) &&\n balanceAgeMin !== null &&\n balanceAgeMin <= BALANCE_MAX_AGE_MIN &&\n balanceAgeMin >= -5; // horodatage dans le futur : rejete\nconst reserveDejaCondamnee = balanceUsable && soc < balanceRequiredSocPct;\n\nconst dwellMinutes = reserveDejaCondamnee\n ? Math.max(0, num(input.charge_dwell_long_min, 20))\n : Math.max(0, num(input.charge_dwell_court_min, 6));\n\nconst dwellEcoule =\n minutesDepuisChargeHaute === null ? true : minutesDepuisChargeHaute >= dwellMinutes;\n\n// Le reseau reste du tant que la charge n'est pas retombee ET restee basse.\n// Si le plafond devient illisible EN COURS de protection, l'horodatage de\n// charge haute reste, lui, un fait PERSISTE : on tient jusqu'a la fin du dwell\n// plutot que de rendre le reseau sur un capteur muet. La memoire ne survit pas\n// au dwell — l'horodatage n'etant ecrit que quand le plafond est lisible, un\n// capteur mort depuis des heures rend bien la regle inerte.\nconst protectionRecente = !dwellEcoule;\nconst chargeEncoreElevee = plafondDechargeW > 0\n ? !(chargeSousSeuilBas && dwellEcoule)\n : protectionRecente;\n\n// ======================================================\n// EVCS OVERRIDE INPUTS\n// ======================================================\nconst evcsPriorityActive = isOnLike(\n pickDefined(\n input.evcs_priority_active,\n input.victron_evcs_priority_active,\n input['input_boolean.victron_evcs_priority_active']\n )\n);\n\nconst evcsNightGridTakeoverNeeded = isOnLike(\n pickDefined(\n input.night_grid_takeover_needed,\n input.victron_evcs_night_grid_takeover_needed,\n input['input_boolean.victron_evcs_night_grid_takeover_needed']\n )\n);\n\nconst evcsSourceModeRaw = (\n pickDefined(\n input.evcs_source_mode,\n input.victron_evcs_source_mode,\n input['input_text.victron_evcs_source_mode']\n ) ?? ''\n).toString().trim();\n\nconst evcsSourceMode = low(evcsSourceModeRaw);\n\nconst userStrategyRaw = (\n pickDefined(\n input.user_strategy,\n input.victron_evcs_user_strategy,\n input['input_select.victron_evcs_user_strategy']\n ) ?? ''\n).toString().trim();\n\nconst userStrategy = low(userStrategyRaw);\n\n// ======================================================\n// EVCS SEMANTIC STATE\n// Ici on ne détecte PAS la voiture pleine par seuils.\n// On consomme seulement l'état sémantique déjà produit\n// par le workflow EVCS.\n// ======================================================\nconst evcsSurplusStateRaw = (\n pickDefined(\n input.evcs_surplus_state,\n input.victron_evcs_surplus_state,\n input['input_text.victron_evcs_surplus_state']\n ) ?? ''\n).toString().trim();\n\nconst evcsSurplusState = low(evcsSurplusStateRaw);\n\nconst evcsVehicleNoDemandSemantic =\n evcsSurplusState === 'vehicle_full_or_paused' ||\n evcsSurplusState === 'vehicle_not_requesting_power' ||\n evcsSurplusState === 'vehicle_full' ||\n evcsSurplusState === 'charge_complete' ||\n evcsSurplusState.includes('full_or_paused') ||\n evcsSurplusState.includes('not_requesting_power') ||\n evcsSurplusState.includes('charge_complete') ||\n evcsSurplusState.includes('no_demand');\n\n// ======================================================\n// OVERRIDE EVCS VERS GRID\n// CORRECTION:\n// - evcsPriorityActive seul ne doit PLUS suffire à allumer EDF\n// - seuls les cas explicitement réseau peuvent forcer le grid\n// - ainsi \"Réseau HC midi = OFF\" redevient réellement respecté\n// ======================================================\nconst evcsExplicitGridRequest =\n userStrategy === 'force_grid_now' ||\n userStrategy === 'force_charge_now' ||\n evcsNightGridTakeoverNeeded ||\n evcsSourceMode === 'réseau forcé' ||\n evcsSourceMode === 'réseau hc autorisé' ||\n evcsSourceMode === 'grid_forced' ||\n evcsSourceMode === 'grid_hc_allowed';\n\nconst evcsGridOverride =\n !evcsVehicleNoDemandSemantic &&\n evcsExplicitGridRequest;\n\n// ======================================================\n// DÉCISION\n// ======================================================\nlet state = 'observe_only';\nlet reason = [];\nlet desiredGrid = gridIsOn ? 'on' : 'off';\nlet desiredLatch = latchIsOn ? 'on' : 'off';\n\n// ======================================================\n// FORCE_GRID + VEHICLE FULL IMMEDIATE CUT\n// FIX V2.5: Si force_grid_now actif et voiture pleine,\n// couper EDF immédiatement sans attendre minOnRuntime.\n// ======================================================\nlet forcedCut = false;\nif (\n userStrategy === 'force_grid_now' &&\n evcsVehicleNoDemandSemantic &&\n latchIsOn &&\n !chargeEncoreElevee // la maison prime\n) {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_force_grid_vehicle_full';\n reason.push('Force grid + voiture pleine : coupure immediate du reseau');\n forcedCut = true;\n}\n\n// ======================================================\n// FIX C (08/08/2026): VEHICULE DEBRANCHE -> COUPURE IMMEDIATE\n// Incident nuit 07-08/08 : apres debranchement du vehicule, EDF restait\n// maintenu ON 25 min (hold_on_min_runtime) alors que plus rien ne le\n// justifiait (SOC >= seuil OFF). Ici : coupure immediate, bypass min_on.\n// ======================================================\nif (\n !forcedCut &&\n latchIsOn &&\n gridIsOn &&\n !evcsGridOverride &&\n evcsSurplusState === 'vehicle_not_connected' &&\n soc >= gridSocOff &&\n !chargeEncoreElevee // la maison prime\n) {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_vehicle_unplugged';\n reason.push('Vehicule debranche + SOC >= seuil OFF : coupure immediate du reseau (bypass min_on)');\n forcedCut = true;\n}\n\n// ======================================================\n// FIX E2 (09/08/2026): RECOVERY DONE -> COUPURE IMMEDIATE\n// 'night_recovery_done_idle' est un etat COLLANT du controleur EVCS :\n// charge terminee ET batterie a la cible, le takeover ne reviendra pas\n// (sauf SOC <= cible-2, qui repasserait par l'override). Attendre le\n// min_on 25 min ne protege ici de rien : coupure immediate.\n// ======================================================\nif (\n !forcedCut &&\n latchIsOn &&\n gridIsOn &&\n !evcsGridOverride &&\n (\n evcsSurplusState === 'night_recovery_done_idle' ||\n evcsSurplusState === 'departure_done_idle'\n ) &&\n soc >= gridSocOff &&\n !chargeEncoreElevee // la maison prime\n) {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_recovery_done';\n reason.push('Charge terminee + batterie a la cible : coupure immediate du reseau (bypass min_on)');\n forcedCut = true;\n}\n\n\n// ======================================================\n// CORE HYSTERESIS LOGIC\n// ======================================================\nif (gridSwitchUnavailable) {\n // Switch EDF hors ligne : mode sûr. Aucune action, aucun timestamp,\n // on garde le latch tel quel et on réévaluera au retour du switch.\n state = 'switch_unavailable';\n desiredGrid = gridIsOn ? 'on' : 'off';\n desiredLatch = latchIsOn ? 'on' : 'off';\n reason.push('Switch EDF indisponible : aucune action, attente du retour');\n\n} else if (forcedCut) {\n // Décision déjà prise plus haut (force_grid + voiture pleine) :\n // on ne laisse plus la chaîne principale l'écraser.\n\n} else if (evcsGridOverride) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n\n if (!gridIsOn) {\n state = 'turn_on_evcs_override';\n reason.push('Override EVCS actif : activation EDF forcée');\n } else if (!latchIsOn) {\n state = 'adopt_existing_grid_on_evcs_override';\n reason.push('Override EVCS actif : EDF déjà ON, adoption par latch');\n } else {\n state = 'hold_on_evcs_override';\n reason.push('Override EVCS actif : maintien EDF ON');\n }\n\n if (userStrategy === 'force_grid_now') {\n reason.push('Cause override : force_grid_now');\n } else if (userStrategy === 'force_charge_now') {\n reason.push('Cause override : force_charge_now');\n } else if (evcsNightGridTakeoverNeeded) {\n reason.push('Cause override : night_grid_takeover_needed');\n } else {\n reason.push(`Cause override : source_mode=${evcsSourceModeRaw || 'n/a'}`);\n }\n\n} else if (!hysteresisEnabled) {\n state = 'hysteresis_disabled';\n desiredGrid = gridIsOn ? 'on' : 'off';\n desiredLatch = latchIsOn ? 'on' : 'off';\n reason.push('Automatisme hystérésis réseau désactivé');\n\n} else if (chargeAuDessusDuParc) {\n // Priorite sur la bande SOC : c'est une contrainte de PUISSANCE, elle ne se\n // resout pas en attendant que le SOC descende.\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = gridIsOn ? 'hold_on_charge_au_dessus_du_parc' : 'turn_on_charge_au_dessus_du_parc';\n reason.push(\n `Charge maison ${Math.round(houseAcPowerW)}W au-dessus de ${Math.round(seuilFermetureW)}W `\n + `(80% du plafond de decharge BMS ${Math.round(plafondDechargeW)}W) : le reseau prend la difference`);\n\n} else if (soc <= gridSocOn) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n\n if (!cooldownElapsed && !gridIsOn) {\n state = 'turn_on_low_soc_cooldown_bypassed';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF malgré cooldown`);\n } else if (!gridIsOn) {\n state = 'turn_on_low_soc';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : activation EDF`);\n } else if (!latchIsOn) {\n state = 'adopt_existing_grid_on_low_soc';\n reason.push(`SOC ${round1(soc)}% <= seuil ON ${gridSocOn}% : EDF déjà ON, prise de contrôle par latch`);\n } else {\n state = 'hold_on_low_soc';\n reason.push(`SOC encore bas ${round1(soc)}% : maintien EDF ON`);\n }\n\n} else if (gridIsOn && !latchIsOn) {\n if (soc < gridSocOff) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'adopt_existing_grid_on';\n reason.push(`EDF déjà ON avec latch OFF et SOC ${round1(soc)}% < seuil OFF ${gridSocOff}% : adoption de l'état ON`);\n } else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_stray_grid_on';\n reason.push(`EDF ON avec latch OFF et SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure de reprise de contrôle`);\n }\n\n} else if (latchIsOn && chargeEncoreElevee) {\n // Tant que la maison consomme plus de la moitie de ce que la batterie sait\n // donner, on garde le reseau — meme si le SOC autoriserait de le lacher.\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'hold_on_charge_encore_elevee';\n reason.push(chargeSousSeuilBas\n ? `Charge maison retombee a ${Math.round(houseAcPowerW)}W depuis ${round1(minutesDepuisChargeHaute || 0)} min : `\n + `on garde le reseau jusqu'a ${dwellMinutes} min `\n + `(${reserveDejaCondamnee ? 'reserve deja condamnee, le reseau du soir est du de toute facon' : 'reserve suffisante, relachement rapide'})`\n : `Charge maison ${Math.round(houseAcPowerW)}W encore au-dessus de ${Math.round(seuilRelachementW)}W : on garde le reseau`);\n\n} else if (latchIsOn) {\n if (!minOnElapsed) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'hold_on_min_runtime';\n reason.push(`EDF maintenu ON : durée mini ${gridMinOnMinutes} min non atteinte (${round1(minutesSinceLastOn || 0)} min)`);\n } else if (soc < gridSocOff) {\n desiredGrid = 'on';\n desiredLatch = 'on';\n state = 'hold_on_until_soc_off';\n reason.push(`EDF maintenu ON : SOC ${round1(soc)}% < seuil OFF ${gridSocOff}%`);\n } else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'turn_off_recovered';\n reason.push(`SOC ${round1(soc)}% >= seuil OFF ${gridSocOff}% : coupure EDF`);\n }\n\n} else {\n desiredGrid = 'off';\n desiredLatch = 'off';\n state = 'observe_only';\n reason.push('Aucune action : hors zone d’hystérésis');\n}\n\n// ======================================================\n// COMPARE DESIRED VS CURRENT\n// ======================================================\nconst gridAction =\n desiredGrid === (gridIsOn ? 'on' : 'off')\n ? 'none'\n : desiredGrid === 'on'\n ? 'turn_on'\n : 'turn_off';\n\nconst latchAction =\n desiredLatch === (latchIsOn ? 'on' : 'off')\n ? 'none'\n : desiredLatch === 'on'\n ? 'turn_on'\n : 'turn_off';\n\n// ======================================================\n// TIMESTAMPS\n// ======================================================\nconst setLastOnAt =\n (gridAction === 'turn_on' && latchAction === 'turn_on') ||\n state === 'adopt_existing_grid_on' ||\n state === 'adopt_existing_grid_on_low_soc' ||\n state === 'adopt_existing_grid_on_evcs_override';\n\nconst setLastOffAt =\n gridAction === 'turn_off';\n\n// Horodatage de la derniere charge maison HAUTE. Ecrit quel que soit l'etat du\n// contacteur : c'est ce qui garantit que le compteur de dwell ne depend pas de\n// la decision qu'il alimente.\nconst setChargeHighLastAt = plafondDechargeW > 0 && !chargeSousSeuilBas;\n\n// ======================================================\n// CONTEXT / ALERTS\n// ======================================================\nreason.push(`Grid actuel ${gridIsOn ? 'ON' : 'OFF'}`);\nreason.push(`Latch actuel ${latchIsOn ? 'ON' : 'OFF'}`);\nreason.push(`SOC ${round1(soc)}% | seuils ${gridSocOn}%/${gridSocOff}%`);\nreason.push(`PV ${Math.round(pvPowerW)}W`);\nreason.push(`Charge AC maison ${Math.round(houseAcPowerW)}W`);\nreason.push(`Puissance réseau ${Math.round(gridPowerW)}W`);\nreason.push(`EVCS ${evcsSeemsActive ? 'actif' : 'inactif'} | ${Math.round(evcsAcPowerW)}W | status_raw=${Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : 'n/a'}`);\n\nif (minutesSinceLastOn !== null) {\n reason.push(`Dernier ON il y a ${round1(minutesSinceLastOn)} min`);\n}\nif (minutesSinceLastOff !== null) {\n reason.push(`Dernier OFF il y a ${round1(minutesSinceLastOff)} min`);\n}\n\nif (acOverSafeLimit) {\n reason.push(`⚠️ Charge AC maison ${Math.round(houseAcPowerW)}W >= limite sûre ${Math.round(acLoadsMaxSafeW)}W`);\n}\n\nreason.push(`EVCS override ${evcsGridOverride ? 'ON' : 'OFF'}`);\nreason.push(`EVCS explicit grid request ${evcsExplicitGridRequest ? 'ON' : 'OFF'}`);\nreason.push(`EVCS priority ${evcsPriorityActive ? 'ON' : 'OFF'}`);\nreason.push(`EVCS night takeover ${evcsNightGridTakeoverNeeded ? 'ON' : 'OFF'}`);\nreason.push(`EVCS source ${evcsSourceModeRaw || 'n/a'}`);\nreason.push(`EVCS strategy ${userStrategyRaw || 'n/a'}`);\nreason.push(`EVCS surplus state ${evcsSurplusStateRaw || 'n/a'}`);\nreason.push(`EVCS no-demand semantic ${evcsVehicleNoDemandSemantic ? 'ON' : 'OFF'}`);\n\n// ======================================================\n// NOTIFICATIONS\n// ======================================================\nlet notifyTitle = 'Victron Grid Hysteresis';\nlet notifyMessage = '';\nlet notifyWanted = false;\n\nif (\n state === 'turn_on_evcs_override' ||\n state === 'adopt_existing_grid_on_evcs_override' ||\n state === 'hold_on_evcs_override'\n) {\n notifyTitle = '🚗 EDF forcé par EVCS';\n notifyMessage = `Priorité EVCS active. EDF maintenu ON pour la recharge véhicule. Cause: ${userStrategyRaw || evcsSourceModeRaw || 'override EVCS'}.`;\n notifyWanted = true;\n\n} else if (state === 'turn_on_low_soc' || state === 'turn_on_low_soc_cooldown_bypassed') {\n notifyTitle = '⚡ EDF activé sur SOC bas';\n notifyMessage = `SOC ${round1(soc)}% <= ${gridSocOn}%. EDF activé et maintenu jusqu'à au moins ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\n notifyWanted = true;\n\n} else if (state === 'adopt_existing_grid_on_low_soc' || state === 'adopt_existing_grid_on') {\n notifyTitle = '🔁 EDF ON repris par hystérésis';\n notifyMessage = `EDF était déjà ON. Le workflow reprend la main et le maintient jusqu'à ${gridSocOff}% (min ${gridMinOnMinutes} min).`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_vehicle_unplugged') {\n notifyTitle = '✅ EDF coupé (véhicule débranché)';\n notifyMessage = `Véhicule débranché et SOC ${round1(soc)}% >= ${gridSocOff}%. Coupure immédiate du réseau.`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_recovery_done') {\n notifyTitle = '✅ EDF coupé (charge terminée)';\n notifyMessage = `Charge VE terminée et batterie à la cible (SOC ${round1(soc)}%). Coupure immédiate du réseau.`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_recovered') {\n notifyTitle = '✅ EDF coupé après recharge';\n notifyMessage = `SOC ${round1(soc)}% >= ${gridSocOff}%. Retour hors réseau autorisé.`;\n notifyWanted = true;\n\n} else if (state === 'turn_off_stray_grid_on') {\n notifyTitle = '✅ EDF coupé (reprise de contrôle)';\n notifyMessage = `EDF était ON sans latch. SOC ${round1(soc)}% >= ${gridSocOff}%, coupure réseau appliquée.`;\n notifyWanted = true;\n\n} else if (acOverSafeLimit && !gridIsOn) {\n notifyTitle = '🚨 Charge AC élevée hors réseau';\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W alors que EDF est OFF.`;\n notifyWanted = true;\n\n} else if (acOverSafeLimit && gridIsOn) {\n notifyTitle = '⚠️ Charge AC élevée';\n notifyMessage = `Charge AC maison ${Math.round(houseAcPowerW)}W proche/au-dessus de la limite sûre ${Math.round(acLoadsMaxSafeW)}W.`;\n notifyWanted = true;\n}\n\nconst shortReason = reason.join(' | ').slice(0, 255);\n\nconst signature = [\n now.date,\n state,\n `dg=${desiredGrid}`,\n `dl=${desiredLatch}`,\n `sb=${socBand}`,\n `ac=${acOverSafeLimit ? '1' : '0'}`,\n `ov=${evcsGridOverride ? '1' : '0'}`,\n `eg=${evcsExplicitGridRequest ? '1' : '0'}`,\n `ep=${evcsPriorityActive ? '1' : '0'}`,\n `es=${evcsSurplusState || 'na'}`\n].join('|');\n\nconst signatureChanged = signature !== lastSignature;\n\n// ======================================================\n// OUTPUT\n// ======================================================\nconst result = {\n timestamp_paris: now.stamp,\n\n inputs: {\n soc_percent: soc,\n grid_state: gridIsOn ? 'on' : 'off',\n pv_power_w: pvPowerW,\n house_ac_power_w: houseAcPowerW,\n grid_power_w: gridPowerW,\n evcs_ac_power_w: evcsAcPowerW,\n evcs_charging: evcsCharging,\n evcs_status_raw: Number.isFinite(evcsStatusRaw) ? evcsStatusRaw : null,\n\n hysteresis_enabled: hysteresisEnabled,\n notifications_enabled: notificationsEnabled,\n grid_latch: latchIsOn,\n grid_soc_on: gridSocOn,\n grid_soc_off: gridSocOff,\n grid_min_on_minutes: gridMinOnMinutes,\n grid_cooldown_minutes: gridCooldownMinutes,\n ac_loads_max_safe_w: acLoadsMaxSafeW,\n\n last_on_at: lastOnAt || null,\n last_off_at: lastOffAt || null,\n last_signature: lastSignature || null,\n\n evcs_priority_active: evcsPriorityActive,\n night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\n evcs_source_mode: evcsSourceModeRaw || null,\n user_strategy: userStrategyRaw || null,\n evcs_surplus_state: evcsSurplusStateRaw || null\n },\n\n computed: {\n desired_grid: desiredGrid,\n desired_latch: desiredLatch,\n minutes_since_last_on: minutesSinceLastOn !== null ? round1(minutesSinceLastOn) : null,\n minutes_since_last_off: minutesSinceLastOff !== null ? round1(minutesSinceLastOff) : null,\n min_on_elapsed: minOnElapsed,\n cooldown_elapsed: cooldownElapsed,\n ac_over_safe_limit: acOverSafeLimit,\n plafond_decharge_w: Math.round(plafondDechargeW),\n charge_au_dessus_du_parc: chargeAuDessusDuParc,\n charge_sous_seuil_bas: chargeSousSeuilBas,\n minutes_depuis_charge_haute:\n minutesDepuisChargeHaute !== null ? round1(minutesDepuisChargeHaute) : null,\n dwell_minutes: dwellMinutes,\n dwell_ecoule: dwellEcoule,\n balance_usable: balanceUsable,\n balance_required_soc_pct:\n Number.isFinite(balanceRequiredSocPct) ? round1(balanceRequiredSocPct) : null,\n balance_age_min: balanceAgeMin !== null ? round1(balanceAgeMin) : null,\n reserve_deja_condamnee: reserveDejaCondamnee,\n evcs_seems_active: evcsSeemsActive,\n\n evcs_grid_override: evcsGridOverride,\n evcs_explicit_grid_request: evcsExplicitGridRequest,\n evcs_priority_active: evcsPriorityActive,\n evcs_night_grid_takeover_needed: evcsNightGridTakeoverNeeded,\n evcs_source_mode: evcsSourceModeRaw || null,\n evcs_user_strategy: userStrategyRaw || null,\n evcs_surplus_state: evcsSurplusStateRaw || null,\n evcs_vehicle_no_demand_semantic: evcsVehicleNoDemandSemantic\n },\n\n decision: {\n state,\n grid_action: gridAction,\n latch_action: latchAction,\n set_last_on_at: setLastOnAt,\n set_last_off_at: setLastOffAt,\n set_charge_high_last_at: setChargeHighLastAt,\n should_notify: notificationsEnabled && signatureChanged && notifyWanted,\n signature,\n signature_changed: signatureChanged,\n reason,\n short_reason: shortReason\n },\n\n ha_service_calls: {\n helper_state: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_state',\n value: state\n }\n },\n\n helper_reason: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_reason',\n value: shortReason || 'Aucune raison'\n }\n },\n\n helper_signature: {\n domain: 'input_text',\n service: 'set_value',\n data: {\n entity_id: 'input_text.victron_grid_control_last_signature',\n value: signature\n }\n },\n\n helper_latch: {\n domain: 'input_boolean',\n service: desiredLatch === 'on' ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'input_boolean.victron_grid_hysteresis_latch'\n }\n },\n\n helper_last_on_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_grid_last_on_at',\n datetime: now.stamp\n }\n },\n\n helper_charge_high_last_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_grid_charge_high_last_at',\n datetime: now.stamp\n }\n },\n\n helper_last_off_at: {\n domain: 'input_datetime',\n service: 'set_datetime',\n data: {\n entity_id: 'input_datetime.victron_grid_last_off_at',\n datetime: now.stamp\n }\n },\n\n grid: {\n domain: 'switch',\n service: desiredGrid === 'on' ? 'turn_on' : 'turn_off',\n data: {\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n logbook: {\n domain: 'logbook',\n service: 'log',\n data: {\n name: 'Victron Grid Hysteresis',\n message: `${state} | Grid=${desiredGrid} | Latch=${desiredLatch} | SOC=${round1(soc)}% | AC=${Math.round(houseAcPowerW)}W | ${shortReason}`,\n entity_id: 'switch.edf_general_ok_switch'\n }\n },\n\n mobile_notification: {\n domain: cfg.mobileNotifyDomain,\n service: cfg.mobileNotifyService,\n data: {\n title: notifyTitle,\n message: notifyMessage,\n data: {\n push: {\n 'interruption-level': 'time-sensitive',\n sound: { name: 'default' }\n },\n ttl: 0,\n priority: 'high'\n }\n }\n }\n }\n};\n\nreturn [{ json: result }];\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2, @@ -87,7 +87,7 @@ }, { "parameters": { - "jsCode": "const root = $input.first().json || {};\nconst decision = root.decision || {};\nconst calls = root.ha_service_calls || {};\nconst actions = [];\n\nfunction pushAction(action, call, enabled = true) {\n if (!enabled) return;\n if (!call) return;\n\n actions.push({\n json: {\n action,\n ...call\n }\n });\n}\n\n// ======================================================\n// HELPERS TOUJOURS MIS À JOUR\n// ======================================================\npushAction('helper_state', calls.helper_state, true);\npushAction('helper_reason', calls.helper_reason, true);\n\n// ======================================================\n// SIGNATURE SI CHANGEMENT\n// ======================================================\nif (decision.signature_changed) {\n pushAction('helper_signature', calls.helper_signature, true);\n}\n\n// ======================================================\n// SÉQUENCEMENT ACTIONS RÉSEAU / LATCH\n//\n// IMPORTANT :\n// - pour ALLUMER : latch ON d'abord, puis grid ON\n// - pour ÉTEINDRE : grid OFF d'abord, puis latch OFF\n// ======================================================\n\n// ---------- CAS 1 : LATCH ON avant GRID ON ----------\nif (decision.latch_action === 'turn_on') {\n pushAction('helper_latch', calls.helper_latch, true);\n}\n\nif (decision.grid_action === 'turn_on') {\n pushAction('grid', calls.grid, true);\n}\n\n// ---------- CAS 2 : GRID OFF avant LATCH OFF ----------\nif (decision.grid_action === 'turn_off') {\n pushAction('grid', calls.grid, true);\n}\n\nif (decision.latch_action === 'turn_off') {\n pushAction('helper_latch', calls.helper_latch, true);\n}\n\n// ======================================================\n// TIMESTAMPS SI NÉCESSAIRE\n// ======================================================\nif (decision.set_last_on_at) {\n pushAction('helper_last_on_at', calls.helper_last_on_at, true);\n}\n\nif (decision.set_last_off_at) {\n pushAction('helper_last_off_at', calls.helper_last_off_at, true);\n}\n\n// ======================================================\n// LOGBOOK SI CHANGEMENT DE SIGNATURE\n// ======================================================\nif (decision.signature_changed) {\n pushAction('logbook', calls.logbook, true);\n}\n\n// ======================================================\n// NOTIFICATION SI UTILE\n// ======================================================\nif (decision.should_notify) {\n pushAction('mobile_notification', calls.mobile_notification, true);\n}\n\nreturn actions;\n" + "jsCode": "const root = $input.first().json || {};\nconst decision = root.decision || {};\nconst calls = root.ha_service_calls || {};\nconst actions = [];\n\nfunction pushAction(action, call, enabled = true) {\n if (!enabled) return;\n if (!call) return;\n\n actions.push({\n json: {\n action,\n ...call\n }\n });\n}\n\n// ======================================================\n// HELPERS TOUJOURS MIS À JOUR\n// ======================================================\npushAction('helper_state', calls.helper_state, true);\npushAction('helper_reason', calls.helper_reason, true);\n\n// ======================================================\n// SIGNATURE SI CHANGEMENT\n// ======================================================\nif (decision.signature_changed) {\n pushAction('helper_signature', calls.helper_signature, true);\n}\n\n// ======================================================\n// SÉQUENCEMENT ACTIONS RÉSEAU / LATCH\n//\n// IMPORTANT :\n// - pour ALLUMER : latch ON d'abord, puis grid ON\n// - pour ÉTEINDRE : grid OFF d'abord, puis latch OFF\n// ======================================================\n\n// ---------- CAS 1 : LATCH ON avant GRID ON ----------\nif (decision.latch_action === 'turn_on') {\n pushAction('helper_latch', calls.helper_latch, true);\n}\n\nif (decision.grid_action === 'turn_on') {\n pushAction('grid', calls.grid, true);\n}\n\n// ---------- CAS 2 : GRID OFF avant LATCH OFF ----------\nif (decision.grid_action === 'turn_off') {\n pushAction('grid', calls.grid, true);\n}\n\nif (decision.latch_action === 'turn_off') {\n pushAction('helper_latch', calls.helper_latch, true);\n}\n\n// ======================================================\n// TIMESTAMPS SI NÉCESSAIRE\n// ======================================================\nif (decision.set_last_on_at) {\n pushAction('helper_last_on_at', calls.helper_last_on_at, true);\n}\n\nif (decision.set_charge_high_last_at) {\n pushAction('helper_charge_high_last_at', calls.helper_charge_high_last_at, true);\n}\n\nif (decision.set_last_off_at) {\n pushAction('helper_last_off_at', calls.helper_last_off_at, true);\n}\n\n// ======================================================\n// LOGBOOK SI CHANGEMENT DE SIGNATURE\n// ======================================================\nif (decision.signature_changed) {\n pushAction('logbook', calls.logbook, true);\n}\n\n// ======================================================\n// NOTIFICATION SI UTILE\n// ======================================================\nif (decision.should_notify) {\n pushAction('mobile_notification', calls.mobile_notification, true);\n}\n\nreturn actions;\n" }, "type": "n8n-nodes-base.code", "typeVersion": 2,