From c7e665cd5cf7b36b0b2713b8e208f1de5c0214b7 Mon Sep 17 00:00:00 2001 From: Tallagrand Thierry Date: Sun, 6 Sep 2026 13:50:28 +0200 Subject: [PATCH] La protection regardait la maison, pas ce qu on demande au parc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux fermetures ce dimanche midi. Mesurees a l instant de la decision, contacteur encore ouvert donc production non censuree : maison 4025 et 4981 W, mais parc reellement sollicite 1132 et 772 W pour un plafond de 5100. La decharge batterie mesuree aux memes instants — 1345 et 1223 W — colle a maison moins PV, pas a la maison brute. Le solaire couvrait la cuisson ; la regle a ferme le contacteur pour rien, 0,50 kWh importes en plein soleil. Elle n avait bien fonctionne hier soir que parce qu il n y avait plus de PV. Les deux seuils portent desormais sur max(0, maison - PV). Contacteur ouvert cette grandeur EST la decharge batterie ; ferme, un bridage du MPPT la fait surestimer, donc l erreur pousse a rester ferme et jamais a relacher tot. Golden 20 -> 24, dont les deux releves de midi et le contrefactuel du nuage. Le test de mutation a montre que la premiere serie ne couvrait que le seuil de fermeture : il a fallu ecrire le cas du soleil revenu pour qu elle morde. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ --- HANDOFF.md | 76 ++++++++++++ docs/MANUEL_COMPLET.md | 30 +++++ docs/mesures/batteries/202609052040-suivi.csv | 16 +++ tests/golden-hysteresis/run.mjs | 1 + tests/golden-hysteresis/scenarios.mjs | 108 +++++++++++++++++ tools/patch-20260906b-demande-sur-le-parc.mjs | 109 ++++++++++++++++++ workflows/victron-grid-hysteresis-v1.json | 2 +- 7 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 tools/patch-20260906b-demande-sur-le-parc.mjs diff --git a/HANDOFF.md b/HANDOFF.md index 7682085..8318101 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4520,6 +4520,82 @@ qu'apres 12:26. 4. si la tension batterie se remet a disparaitre, `plafond_decharge_w` doit rester a ~5100 et non tomber a 0. +### MEME JOUR, DEFAUT PLUS PROFOND : la regle comptait la maison BRUTE +Dimanche 06/09, deux fermetures du contacteur : **12:07 -> 12:22** (four + +plaque) et **13:34 -> 13:42** (cuisson + JEEP branchee a 13:31). Journee +nuageuse, parc reduit de moitie. Mesure a la resolution native **a l'instant ou +chaque decision a ete prise, contacteur encore ouvert** — donc sur une +production non censuree : + +| fermeture | maison | PV | **parc reellement sollicite** | decharge batterie mesuree | +|---|---:|---:|---:|---:| +| 12:07, cuisson seule | 4 025 W | ~2 890 W | **1 132 W** | 1 345 W | +| 13:34, cuisson + JEEP | 4 981 W | ~4 210 W | **772 W** | 1 223 W | + +La decharge batterie colle a `maison - PV`, pas a la maison brute : le controle +croise est net. Le plafond du parc est de 5 100 W — **il etait a 22 % et 15 % de +sa limite, et la regle a ferme le contacteur pour rien.** Meme en integrant les +periodes fermees (ou le PV peut etre bride, donc chiffres pessimistes), la +demande maximale sur le parc n'a atteint que 3 046 et 2 437 W. Cout : 0,50 kWh +importes en plein soleil, pour rien. + +Cause : la regle du 05/09 compare la MAISON BRUTE au plafond de decharge du +parc. Elle n'avait bien fonctionne la veille au soir que parce qu'il n'y avait +plus de PV — la maison brute valait alors la demande sur le parc, et le pic de +-97,4 A etait reel. Des qu'il y a du soleil, elle ferme sur une charge que le +solaire couvre deja. + +Correctif (deploye 06/09 13:49, +`tools/patch-20260906b-demande-sur-le-parc.mjs`) : les deux seuils portent sur +**`max(0, maison - PV)`**. `acOverSafeLimit` n'est PAS touche — il compare la +charge au debit des onduleurs, qui portent la maison entiere quelle qu'en soit +la source. + +⚠️ **Pourquoi ce n'est pas un oscillateur**, alors que la grandeur n'est pas +invariante a la decision (fermer peut brider le MPPT, §28) : contacteur OUVERT, +`maison - PV` EST la decharge batterie, grandeur physique exacte ; contacteur +FERME, un bridage du MPPT fait BAISSER PV donc MONTER `maison - PV` — l'erreur +pousse a rester ferme, jamais a relacher tot ; et le relachement exige de toute +facon le dwell (6 a 20 min), tres au-dela du transitoire d'etablissement mesure +a ~150 s. Erreur unilaterale et bornee, dans la direction qui menage le parc. + +Golden 20 -> 24 : les deux releves de ce midi (aucune fermeture attendue), le +contrefactuel du nuage (meme cuisson a 4 500 W avec PV 200 W -> on ferme), et +`h-midi-soleil-revenu-relache` (contacteur ferme, cuisson a 3 000 W mais PV +2 000 -> le parc n'est plus sollicite qu'a 1 000 W, on rend). Les scenarios de +protection existants portent desormais `pv_power_w: '0'` explicitement : ils +decrivent le soir, ou le parc est seul. + +⚠️ Piege evite grace au test de mutation : la premiere serie de scenarios ne +couvrait que le seuil de FERMETURE. Remettre le relachement sur la maison brute +ne faisait echouer aucun test — il a fallu ecrire `h-midi-soleil-revenu-relache` +pour que la mutation morde. + +### Ce que la JEEP n'a PAS cause, et la question qu'elle pose quand meme +Le propriétaire attribuait la fermeture de midi a la conjonction cuisson + +voiture. Les faits : **la voiture n'etait pas branchee** pendant l'episode de +12:07 (EVCS a 0 W de bout en bout) ; elle n'a joue que dans celui de 13:34, qui +n'aurait pas du fermer non plus. Les deux fermetures viennent de la regle, pas +de la charge. + +Reste que la question de fond est juste, et elle n'est pas traitee : `houseAcPowerW` +inclut l'EVCS, donc **la voiture est comptee comme une charge menagere +incompressible** alors qu'elle est la seule charge entierement compressible du +foyer — le controleur EVCS peut la ramener a 6 A ou l'arreter en un cycle. Le +jour ou la demande sur le parc depassera vraiment le plafond avec la voiture +dedans, la reponse economique est de brider la voiture d'abord et de ne fermer +le contacteur que pour le reste. Aucun des deux workflows ne sait faire cela +aujourd'hui : le controleur EVCS ignore le plafond de decharge du BMS, et +l'hysteresis ne sait pas commander la borne. A instruire. + +### Anomalie annexe : une session de charge comptee deux fois +`Session de charge terminee : JEEP, 4.20 kWh` a ete emis **deux fois** — a +02:52:31 (fin de la charge HC reelle) et a 13:42:32 (debranchement apres onze +minutes de charge diurne), avec la MEME valeur au centieme. Le compteur de +session de `tools/historise-evcs.mjs` semble rejouer la session de la nuit au +lieu d'en ouvrir une nouvelle. A corriger, sans quoi la table `session_charge` +double-compte. + ### 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. diff --git a/docs/MANUEL_COMPLET.md b/docs/MANUEL_COMPLET.md index d33e151..b7a3358 100644 --- a/docs/MANUEL_COMPLET.md +++ b/docs/MANUEL_COMPLET.md @@ -990,6 +990,36 @@ protection jusqu'a la fin du dwell. Golden-hysteresis 11 -> 20, six mutations tombant chacune sur un jeu different. Detail : HANDOFF §57. +#### D-ter. La protection comptait la maison brute, pas la demande sur le parc (06/09/2026) + +La regle de fermeture comparait `sensor.victron_consumption_power_l1` au +plafond de decharge du BMS. Or le parc n'est sollicite que de ce que le solaire +ne couvre pas. Dimanche 06/09, deux fermetures, mesurees a l'instant de la +decision (contacteur encore ouvert, donc production non censuree) : + +| fermeture | maison | PV | parc sollicite | decharge batterie | +|---|---:|---:|---:|---:| +| 12:07, cuisson | 4 025 W | ~2 890 W | **1 132 W** | 1 345 W | +| 13:34, cuisson + JEEP | 4 981 W | ~4 210 W | **772 W** | 1 223 W | + +Plafond du parc : 5 100 W. Il etait a 22 % et 15 % de sa limite — deux +fermetures pour rien, 0,50 kWh importes en plein soleil. La regle n'avait bien +fonctionne la veille au soir que parce qu'il n'y avait plus de PV. + +Les deux seuils portent desormais sur `max(0, maison - PV)`. Contacteur ouvert, +cette grandeur EST la decharge batterie ; ferme, elle est le contrefactuel +« ce que le parc devrait fournir si on ouvrait », qu'un eventuel bridage du MPPT +fait SURESTIMER — erreur unilaterale, qui pousse a rester ferme. Golden 20 -> 24. +Detail : HANDOFF §57. + +⚠️ Question ouverte que cet episode souleve : `houseAcPowerW` inclut l'EVCS, +donc la voiture est comptee comme une charge incompressible alors qu'elle est la +seule charge entierement compressible du foyer. Le jour ou la demande sur le +parc depassera vraiment le plafond avec la voiture dedans, il faudrait brider la +voiture avant de fermer le contacteur — ce qu'aucun des deux workflows ne sait +faire : le controleur EVCS ignore le plafond de decharge du BMS, et +l'hysteresis ne commande pas la borne. + #### 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 77b2ced..976ef07 100644 --- a/docs/mesures/batteries/202609052040-suivi.csv +++ b/docs/mesures/batteries/202609052040-suivi.csv @@ -388,3 +388,19 @@ heure_reelle,etat,courant_mA,c1_mV,c2_mV,c3_mV,c4_mV,c5_mV,c6_mV,c7_mV,c8_mV,c9_ 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 +2026-09-06T11:11:04.465Z,Y230202C30371426,,3391,3389,3389,3389,3391,3391,3388,3389,3389,3389,3391,3389,3390,3390,3391,3392,3390,3390,3391,3392,3392,3390,3390,3390,3391,3392,3391,3390,3390,3393,5 +2026-09-06T11:11:04.593Z,Y230202C30371425,,3390,3389,3389,3390,3389,3390,3389,3388,3388,3390,3391,3390,3390,3390,3390,3 +2026-09-06T11:11:04.688Z,Y230202C30371431,,3390,3388,3389,3388,3389,3389,3386,3387,3387,3388,3390,3387,3388,3388,3389,4 +2026-09-06T11:11:04.813Z,Y230202C30371427,,3389,3388,3387,3388,3389,3390,3387,3387,3387,3388,3390,3388,3388,3388,3390,3 +2026-09-06T11:21:04.908Z,Y230202C30371426,,3391,3389,3389,3390,3390,3391,3389,3389,3388,3389,3392,3390,3389,3388,3391,4 +2026-09-06T11:21:05.006Z,Y230202C30371425,,3384,3383,3383,3384,3382,3383,3384,3382,3383,3383,3385,3384,3385,3384,3383,3 +2026-09-06T11:21:05.132Z,Y230202C30371431,,3383,3382,3383,3381,3382,3382,3380,3381,3381,3381,3384,3382,3382,3381,3381,4 +2026-09-06T11:21:05.257Z,Y230202C30371427,,3383,3382,3382,3383,3384,3383,3382,3382,3383,3382,3383,3383,3383,3384,3384,2 +2026-09-06T11:31:05.360Z,Y230202C30371426,,3385,3384,3385,3386,3386,3386,3384,3385,3383,3385,3386,3385,3385,3384,3386,3 +2026-09-06T11:31:05.460Z,Y230202C30371425,,3393,3391,3391,3392,3392,3392,3391,3390,3391,3392,3394,3393,3393,3392,3393,4 +2026-09-06T11:31:05.587Z,Y230202C30371431,,3393,3391,3391,3390,3393,3392,3389,3390,3390,3391,3394,3390,3390,3390,3391,5 +2026-09-06T11:31:05.714Z,Y230202C30371427,,3391,3389,3388,3389,3391,3391,3389,3388,3389,3389,3391,3389,3390,3390,3390,3 +2026-09-06T11:41:05.817Z,Y230202C30371426,,3391,3389,3389,3390,3390,3392,3389,3389,3388,3389,3391,3391,3389,3389,3390,4 +2026-09-06T11:41:05.944Z,Y230202C30371425,,3350,3350,3350,3350,3350,3349,3350,3349,3349,3350,3350,3350,3350,3350,3350,1 +2026-09-06T11:41:06.039Z,Y230202C30371431,,3349,3348,3348,3348,3348,3349,3347,3348,3347,3347,3349,3348,3348,3348,3347,2 +2026-09-06T11:41:06.226Z,Y230202C30371427,,3342,3342,3343,3344,3343,3342,3343,3343,3343,3343,3342,3343,3343,3344,3344,2 diff --git a/tests/golden-hysteresis/run.mjs b/tests/golden-hysteresis/run.mjs index 7beebd3..fd39d3e 100644 --- a/tests/golden-hysteresis/run.mjs +++ b/tests/golden-hysteresis/run.mjs @@ -66,6 +66,7 @@ function summarize(out) { shouldNotify: !!d.should_notify, evcsGridOverride: !!c.evcs_grid_override, plafondDechargeW: c.plafond_decharge_w, + demandeParcW: c.demande_parc_w, dwellMinutes: c.dwell_minutes, dwellEcoule: c.dwell_ecoule, reserveDejaCondamnee: !!c.reserve_deja_condamnee, diff --git a/tests/golden-hysteresis/scenarios.mjs b/tests/golden-hysteresis/scenarios.mjs index 8e6243e..44ad964 100644 --- a/tests/golden-hysteresis/scenarios.mjs +++ b/tests/golden-hysteresis/scenarios.mjs @@ -63,6 +63,13 @@ const recoveryBase = { const parcQuatreModules = { battery_max_discharge_a: '100', battery_voltage_v: '51', + // ⚠️ PV NULLE, ET C'EST STRUCTUREL. Depuis le 06/09 les deux seuils portent + // sur `max(0, maison - PV)`, ce que le parc est REELLEMENT sollicite de + // fournir, et non sur la maison brute. Les scenarios de protection decrivent + // le soir : le parc est seul, la maison brute EST la demande sur le parc. Les + // scenarios `h-midi-*` couvrent le cas diurne, ou le solaire couvre la + // cuisson et ou la regle ne doit PAS fermer. + pv_power_w: '0', }; export const scenarios = [ @@ -405,6 +412,7 @@ export const scenarios = [ ...recoveryBase, battery_max_discharge_a: '100', battery_voltage_v: 'unavailable', + pv_power_w: '0', soc_percent: '65', house_ac_power_w: '2000', last_on_at: minutesAgo(T_MS, 40), @@ -426,6 +434,7 @@ export const scenarios = [ ...recoveryBase, battery_max_discharge_a: 'unavailable', battery_voltage_v: 'unavailable', + pv_power_w: '0', soc_percent: '65', house_ac_power_w: '2000', last_on_at: minutesAgo(T_MS, 40), @@ -447,6 +456,7 @@ export const scenarios = [ ...recoveryBase, battery_max_discharge_a: 'unavailable', battery_voltage_v: 'unavailable', + pv_power_w: '0', soc_percent: '65', house_ac_power_w: '2000', last_on_at: minutesAgo(T_MS, 40), @@ -459,4 +469,102 @@ export const scenarios = [ gridAction: 'turn_off', }, }, + // ===================================================== + // MIDI ENSOLEILLE : LE SOLAIRE COUVRE LA CUISSON (06/09/2026) + // ===================================================== + // Deux fermetures ce dimanche, mesurees a la resolution native A L'INSTANT + // DE LA DECISION, contacteur encore ouvert donc production non censuree : + // + // 12:07, cuisson seule maison 4 025 W parc sollicite 1 132 W + // 13:34, cuisson + JEEP maison 4 981 W parc sollicite 772 W + // + // Controle croise : la decharge batterie valait 1 345 et 1 223 W aux memes + // instants — elle colle a `maison - PV`, pas a la maison brute. Le plafond du + // parc est de 5 100 W : il etait a 22 % et 15 % de sa limite, et la regle a + // ferme le contacteur pour rien. + { + id: 'h-midi-solaire-couvre-la-cuisson', + label: 'RELEVE 06/09 12:07 : maison 4025 W mais PV 2893 W, le parc ne fournit que 1132 W — aucune fermeture', + time: T, + overrides: { + ...recoveryBase, + ...parcQuatreModules, + grid_latch: 'off', + grid_state: 'off', + soc_percent: '69', + house_ac_power_w: '4025', + pv_power_w: '2893', + }, + expect: { + state: 'observe_only', + gridAction: 'none', + latchAction: 'none', + demandeParcW: 1132, + }, + }, + { + id: 'h-midi-jeep-en-plus-solaire-couvre-encore', + label: 'RELEVE 06/09 13:34 : maison 4981 W dont la JEEP, PV 4209 W, parc a 772 W — aucune fermeture non plus', + time: T, + overrides: { + ...recoveryBase, + ...parcQuatreModules, + grid_latch: 'off', + grid_state: 'off', + soc_percent: '86', + house_ac_power_w: '4981', + pv_power_w: '4209', + evcs_ac_power_w: '1621', + evcs_charging: 'on', + evcs_surplus_state: 'charging_step_up_battery', + evcs_source_mode: 'Solaire', + }, + expect: { + state: 'observe_only', + gridAction: 'none', + demandeParcW: 772, + }, + }, + { + id: 'h-midi-nuage-le-parc-encaisse-tout', + label: 'CONTREFACTUEL : meme cuisson a 4500 W mais le nuage passe, PV 200 W — le parc encaisse 4300 W, on ferme', + time: T, + overrides: { + ...recoveryBase, + ...parcQuatreModules, + grid_latch: 'off', + grid_state: 'off', + soc_percent: '69', + house_ac_power_w: '4500', + pv_power_w: '200', + }, + expect: { + state: 'turn_on_charge_au_dessus_du_parc', + gridAction: 'turn_on', + latchAction: 'turn_on', + demandeParcW: 4300, + }, + }, + { + id: 'h-midi-soleil-revenu-relache', + label: "Le nuage est passe, contacteur ferme : la cuisson continue a 3000 W mais le PV en couvre 2000 — le parc n'est plus sollicite qu'a 1000 W, on rend le reseau", + time: T, + overrides: { + ...recoveryBase, + ...parcQuatreModules, + soc_percent: '69', + house_ac_power_w: '3000', + pv_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', + demandeParcW: 1000, + setChargeHighLastAt: false, + }, + }, ]; diff --git a/tools/patch-20260906b-demande-sur-le-parc.mjs b/tools/patch-20260906b-demande-sur-le-parc.mjs new file mode 100644 index 0000000..117c20b --- /dev/null +++ b/tools/patch-20260906b-demande-sur-le-parc.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node +// La regle de protection comptait la maison BRUTE, pas ce que le parc est +// reellement sollicite de fournir. +// +// Dimanche 06/09, deux fermetures du contacteur, mesurees a la resolution +// native (InfluxDB) A L'INSTANT OU LA DECISION A ETE PRISE, contacteur encore +// ouvert — donc sur une production non censurée : +// +// fermeture 1 (12:07, cuisson seule) maison 4 025 W parc sollicite 1 132 W +// fermeture 2 (13:34, cuisson + JEEP) maison 4 981 W parc sollicite 772 W +// +// Controle croise : la decharge batterie mesuree aux memes instants valait +// 1 345 et 1 223 W — elle colle a `maison - PV`, pas a la maison brute. +// +// Le plafond du parc est de 5 100 W. Le parc etait donc a 22 % et 15 % de sa +// limite, et la regle a ferme le contacteur pour rien : le solaire (3 a 4 kW) +// couvrait la cuisson. Elle n'avait bien fonctionne hier soir que parce qu'il +// n'y avait plus de PV — la maison brute valait alors la demande sur le parc. +// +// Correctif : les deux seuils portent sur `max(0, maison - PV)`. +// +// ⚠️ POURQUOI CE N'EST PAS UN OSCILLATEUR, alors que la grandeur n'est pas +// invariante a la decision (fermer le contacteur peut brider le MPPT, §28) : +// - contacteur OUVERT, `maison - PV` EST la decharge batterie, la grandeur +// physique exacte ; +// - contacteur FERME, brider le MPPT fait BAISSER PV, donc MONTER +// `maison - PV` : l'erreur pousse a rester ferme, jamais a relacher tot ; +// - et le relachement exige de toute facon le dwell (6 a 20 min), tres +// au-dela du transitoire d'etablissement mesure a ~150 s. +// L'erreur est donc unilaterale et bornee, dans la direction qui menage le parc. +// +// `acOverSafeLimit` n'est PAS touche : il compare la charge au debit des +// onduleurs, qui portent la maison entiere quelle qu'en soit la source. +// +// node tools/patch-20260906b-demande-sur-le-parc.mjs + +import fs from 'node:fs'; + +const F = 'workflows/victron-grid-hysteresis-v1.json'; +const wf = JSON.parse(fs.readFileSync(F, 'utf8')); +const calc = wf.nodes.find((n) => n.name === 'Compute Grid Hysteresis'); +if (!calc) throw new Error('noeud introuvable'); + +function remplace(t, a, b, quoi) { + if (!t.includes(a)) throw new Error(`ancre introuvable (${quoi}) : ${a.slice(0, 70)}`); + return t.replace(a, b); +} + +let c = calc.parameters.jsCode; + +if (c.includes('demandeParcW')) { + console.log(' deja fait'); +} else { + // 1. la grandeur + c = remplace(c, + 'const chargeAuDessusDuParc = plafondDechargeW > 0 && houseAcPowerW >= seuilFermetureW;', + `// CE QUE LE PARC EST REELLEMENT SOLLICITE DE FOURNIR (06/09/2026) +// La maison brute n'est pas la demande sur la batterie : le solaire en couvre +// une partie. Mesure du 06/09 a l'instant des deux fermetures, contacteur +// encore ouvert : maison 4 025 et 4 981 W, parc sollicite 1 132 et 772 W pour +// un plafond de 5 100 W — deux fermetures pour rien. Controle croise : la +// decharge batterie valait alors 1 345 et 1 223 W. +// +// Contacteur ouvert, cette grandeur EST la decharge batterie. Ferme, elle est +// le contrefactuel « ce que le parc devrait fournir si on ouvrait », et un +// eventuel bridage du MPPT par la presence du reseau la fait SURESTIMER — +// erreur unilaterale, qui pousse a rester ferme, jamais a relacher tot. +const demandeParcW = Math.max(0, houseAcPowerW - pvPowerW); + +const chargeAuDessusDuParc = plafondDechargeW > 0 && demandeParcW >= seuilFermetureW;`, + 'fermeture'); + + c = remplace(c, + 'const chargeSousSeuilBas = plafondDechargeW > 0 && houseAcPowerW < seuilRelachementW;', + 'const chargeSousSeuilBas = plafondDechargeW > 0 && demandeParcW < seuilRelachementW;', + 'relachement'); + + c = remplace(c, + "const setChargeHighLastAt = plafondDechargeW > 0 && !chargeSousSeuilBas;", + "const setChargeHighLastAt = plafondDechargeW > 0 && !chargeSousSeuilBas;", + 'ecriture'); // inchange : suit desormais demandeParcW via chargeSousSeuilBas + + // 2. les raisons + c = remplace(c, + ' `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`);', + ' `Demande sur le parc ${Math.round(demandeParcW)}W (maison ${Math.round(houseAcPowerW)}W - PV ${Math.round(pvPowerW)}W) `\n + `au-dessus de ${Math.round(seuilFermetureW)}W, soit 80% du plafond de decharge BMS ${Math.round(plafondDechargeW)}W : le reseau prend la difference`);', + 'raison fermeture'); + + c = remplace(c, + " ? `Charge maison retombee a ${Math.round(houseAcPowerW)}W depuis ${round1(minutesDepuisChargeHaute || 0)} min : `", + " ? `Demande sur le parc retombee a ${Math.round(demandeParcW)}W depuis ${round1(minutesDepuisChargeHaute || 0)} min : `", + 'raison dwell'); + + c = remplace(c, + " : `Charge maison ${Math.round(houseAcPowerW)}W encore au-dessus de ${Math.round(seuilRelachementW)}W : on garde le reseau`);", + " : `Demande sur le parc ${Math.round(demandeParcW)}W (maison ${Math.round(houseAcPowerW)}W - PV ${Math.round(pvPowerW)}W) encore au-dessus de ${Math.round(seuilRelachementW)}W : on garde le reseau`);", + 'raison maintien'); + + // 3. le debug + c = remplace(c, ' plafond_decharge_w: Math.round(plafondDechargeW),', + ' plafond_decharge_w: Math.round(plafondDechargeW),\n demande_parc_w: Math.round(demandeParcW),', + 'debug'); + + calc.parameters.jsCode = c; + console.log(' calcul : les deux seuils portent sur maison - PV'); +} + +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 eefa267..02baad5 100644 --- a/workflows/victron-grid-hysteresis-v1.json +++ b/workflows/victron-grid-hysteresis-v1.json @@ -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\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" + "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;\n// CE QUE LE PARC EST REELLEMENT SOLLICITE DE FOURNIR (06/09/2026)\n// La maison brute n'est pas la demande sur la batterie : le solaire en couvre\n// une partie. Mesure du 06/09 a l'instant des deux fermetures, contacteur\n// encore ouvert : maison 4 025 et 4 981 W, parc sollicite 1 132 et 772 W pour\n// un plafond de 5 100 W — deux fermetures pour rien. Controle croise : la\n// decharge batterie valait alors 1 345 et 1 223 W.\n//\n// Contacteur ouvert, cette grandeur EST la decharge batterie. Ferme, elle est\n// le contrefactuel « ce que le parc devrait fournir si on ouvrait », et un\n// eventuel bridage du MPPT par la presence du reseau la fait SURESTIMER —\n// erreur unilaterale, qui pousse a rester ferme, jamais a relacher tot.\nconst demandeParcW = Math.max(0, houseAcPowerW - pvPowerW);\n\nconst chargeAuDessusDuParc = plafondDechargeW > 0 && demandeParcW >= seuilFermetureW;\nconst chargeSousSeuilBas = plafondDechargeW > 0 && demandeParcW < 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 `Demande sur le parc ${Math.round(demandeParcW)}W (maison ${Math.round(houseAcPowerW)}W - PV ${Math.round(pvPowerW)}W) `\n + `au-dessus de ${Math.round(seuilFermetureW)}W, soit 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 ? `Demande sur le parc retombee a ${Math.round(demandeParcW)}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 : `Demande sur le parc ${Math.round(demandeParcW)}W (maison ${Math.round(houseAcPowerW)}W - PV ${Math.round(pvPowerW)}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 demande_parc_w: Math.round(demandeParcW),\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,