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,