#!/usr/bin/env node // Ferme le contacteur EDF quand la maison demande plus que ce que la batterie // sait delivrer. // // Le 05/09/2026 le pack ancien a ete isole : quatre modules au lieu de sept, et // le plafond de decharge annonce par le BMS est tombe de 150 a 100 A, soit // ~5,1 kW. La maison, elle, a deja pointe a 8 kW en cuisinant. Contacteur // ouvert, la batterie est seule source et se fait tirer au-dela de sa limite. // // ⚠️ LE DECLENCHEMENT PORTE SUR LA CHARGE MAISON, PAS SUR LE COURANT BATTERIE. // Fermer le contacteur fait chuter le courant batterie : s'en servir comme // critere de relachement ferait que la decision detruirait sa propre cause — // exactement l'oscillateur corrige trois fois cette annee. La charge maison ne // depend pas de l'etat du contacteur : c'est l'invariant qu'il faut regarder. // // node tools/patch-20260905-decharge-forte-ferme-edf.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'); if (!snap || !calc) throw new Error('noeuds introuvables'); // ------------------------------------------------------- 1. le snapshot let p = JSON.stringify(snap.parameters); if (!p.includes('battery_max_discharge_a')) { const ancre = 'house_ac_power_w\\": states(\\"sensor.victron_consumption_power_l1\\"),'; if (!p.includes(ancre)) throw new Error('ancre snapshot introuvable'); p = p.replace(ancre, ancre + '\\n \\"battery_max_discharge_a\\": states(\\"sensor.airtria_8_us2000c_maximum_allowed_discharge_current\\"),' + '\\n \\"battery_voltage_v\\": states(\\"sensor.victron_battery_voltage\\"),'); snap.parameters = JSON.parse(p); console.log(' snapshot : +battery_max_discharge_a, +battery_voltage_v'); } else console.log(' snapshot : deja fait'); // ------------------------------------------------------- 2. les grandeurs let c = calc.parameters.jsCode; const ancreCalc = 'const acOverSafeLimit = houseAcPowerW >= acLoadsMaxSafeW;'; if (!c.includes('plafondDechargeW')) { c = c.replace(ancreCalc, ancreCalc + ` // ====================================================== // CHARGE MAISON AU-DELA DE CE QUE LA BATTERIE SAIT DONNER // ====================================================== // Le plafond vient du BMS lui-meme et suit le nombre de modules : rien n'est // code en dur, retirer ou ajouter un module deplace le seuil tout seul. const plafondDechargeA = num(input.battery_max_discharge_a, 0); const tensionParcV = num(input.battery_voltage_v, 0); const plafondDechargeW = plafondDechargeA > 0 && tensionParcV > 0 ? plafondDechargeA * tensionParcV : 0; // Bande large et volontairement dissymetrique : on ferme a 80 % du plafond, on // ne relache qu'en dessous de 50 %. Les deux seuils portent sur la charge // maison, grandeur que le contacteur ne modifie pas. const seuilFermetureW = plafondDechargeW * 0.80; const seuilRelachementW = plafondDechargeW * 0.50; const chargeAuDessusDuParc = plafondDechargeW > 0 && houseAcPowerW >= seuilFermetureW; const chargeEncoreElevee = plafondDechargeW > 0 && houseAcPowerW >= seuilRelachementW;`); console.log(' calcul : seuils derives du plafond BMS'); } // ------------------------------------------------------- 3. la fermeture if (!c.includes('turn_on_charge_au_dessus_du_parc')) { const ancreSoc = `} else if (soc <= gridSocOn) {`; if (!c.includes(ancreSoc)) throw new Error('ancre SOC bas introuvable'); c = c.replace(ancreSoc, `} else if (chargeAuDessusDuParc) { // Priorite sur la bande SOC : c'est une contrainte de PUISSANCE, elle ne se // resout pas en attendant que le SOC descende. desiredGrid = 'on'; desiredLatch = 'on'; state = gridIsOn ? 'hold_on_charge_au_dessus_du_parc' : 'turn_on_charge_au_dessus_du_parc'; reason.push( \`Charge maison \${Math.round(houseAcPowerW)}W au-dessus de \${Math.round(seuilFermetureW)}W \` + \`(80% du plafond de decharge BMS \${Math.round(plafondDechargeW)}W) : le reseau prend la difference\`); } else if (soc <= gridSocOn) {`); console.log(' calcul : branche de fermeture posee avant la bande SOC'); } // ------------------------------------------------------- 4. le maintien if (!c.includes('hold_on_charge_encore_elevee')) { const ancreLatch = `} else if (latchIsOn) {`; if (!c.includes(ancreLatch)) throw new Error('ancre latch introuvable'); c = c.replace(ancreLatch, `} else if (latchIsOn && chargeEncoreElevee) { // Tant que la maison consomme plus de la moitie de ce que la batterie sait // donner, on garde le reseau — meme si le SOC autoriserait de le lacher. desiredGrid = 'on'; desiredLatch = 'on'; 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\`); } else if (latchIsOn) {`); console.log(' calcul : branche de maintien posee avant le relachement'); } // ------------------------- 4bis. les coupures immediates cedent le pas // ⚠️ Les trois coupures immediates s'executent AVANT la cascade principale et // posent forcedCut. Sans garde, elles ouvriraient le contacteur que la regle de // charge vient de fermer, et la regle refermerait au cycle suivant : // oscillateur. Elles existent pour relacher le reseau quand l'EVCS n'en a plus // besoin — elles n'ont rien a dire quand c'est la MAISON qui en a besoin. const gardes = [ [" userStrategy === 'force_grid_now' &&\n evcsVehicleNoDemandSemantic &&\n latchIsOn\n) {", " userStrategy === 'force_grid_now' &&\n evcsVehicleNoDemandSemantic &&\n latchIsOn &&\n !chargeEncoreElevee // la maison prime\n) {"], [" evcsSurplusState === 'vehicle_not_connected' &&\n soc >= gridSocOff\n) {", " evcsSurplusState === 'vehicle_not_connected' &&\n soc >= gridSocOff &&\n !chargeEncoreElevee // la maison prime\n) {"], [" ) &&\n soc >= gridSocOff\n) {", " ) &&\n soc >= gridSocOff &&\n !chargeEncoreElevee // la maison prime\n) {"], ]; if (!c.includes('!chargeEncoreElevee')) { for (const [avant, apres] of gardes) { if (!c.includes(avant)) throw new Error('ancre de garde introuvable : ' + avant.slice(0, 40)); c = c.replace(avant, apres); } console.log(' calcul : les trois coupures immediates cedent devant la charge maison'); } // ------------------------------------------------------- 5. tracabilite if (!c.includes('plafond_decharge_w:')) { c = c.replace(' ac_over_safe_limit: acOverSafeLimit,', ` ac_over_safe_limit: acOverSafeLimit, plafond_decharge_w: Math.round(plafondDechargeW), charge_au_dessus_du_parc: chargeAuDessusDuParc,`); console.log(' calcul : debug expose'); } calc.parameters.jsCode = c; fs.writeFileSync(F, JSON.stringify(wf, null, 2) + '\n'); console.log(`\n${F} ecrit.`);