#!/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.`);