// EVCS Energy Balance — BALANCE // ===================================================================== // Rôle : produire les FAITS qui remplacent les seuils fixes. Rien d'autre. // Ce nœud ne pilote ni EDF, ni la borne : il publie des grandeurs que les deux // contrôleurs liront à la place de leurs constantes. // // La question à laquelle il répond n'est pas « le SOC est-il au-dessus de // 30 % ? » mais « la batterie contient-elle de quoi tenir jusqu'à ce que le // solaire prenne le relais ? ». Le 24/08/2026 à 06:43, la réponse du seuil fixe // et celle du bilan diffèrent : la production ne couvrait la maison qu'à partir // de 09 h, il fallait donc environ 0,8 kWh de réserve, soit ~5 points de SOC — // et non un palier arbitraire à 30 %. // // Entrées (toutes fournies, aucune constante physique en dur) : // consumption_p50[24] profil horaire mesuré, valeur typique (CONSUMPTION_PROFILE) // consumption_p80[24] idem, valeur prudente // horizon[] production attendue heure par heure (RESOURCE_ESTIMATE) // soc_percent SOC lissé pour le pilotage // usable_capacity_kwh capacité réellement disponible du parc batterie // soc_floor_percent plancher dur de sécurité // // Sorties : heure de croisement · énergie à couvrir · SOC requis · marge // disponible pour le véhicule · niveau de confiance. // ===================================================================== function num(v, fallback = NaN) { const n = Number.parseFloat(v); return Number.isFinite(n) ? n : fallback; } function round1(v) { return Number.isFinite(v) ? Math.round(v * 10) / 10 : null; } function round2(v) { return Number.isFinite(v) ? Math.round(v * 100) / 100 : null; } const input = $input.first().json; const CFG = { TIMEZONE: 'Europe/Paris', // Au-delà de cet horizon sans croisement trouvé, on cesse de chercher : la // réponse n'aurait plus de sens opérationnel. SEARCH_HOURS: 40, // PAS de marge de sécurité supplémentaire. La prudence est déjà portée, // entièrement, par le choix du percentile du profil de consommation (p80), // qui est mesuré. Vérifié le 24/08/2026 : sur toutes les fenêtres testées, // p80(somme des heures) est égal a somme(p80 par heure) — les heures de // consommation sont fortement corrélées, une matinée chargée l'étant sur // plusieurs heures d'affilée. Sommer les p80 ne cumule donc PAS deux // pessimismes, et ajouter un coefficient par-dessus ne serait qu'une // constante arbitraire de plus. }; function profileOf(key) { const a = input[key]; return Array.isArray(a) && a.length === 24 ? a.map(v => Math.max(0, num(v, 0))) : null; } const consumptionP50 = profileOf('consumption_p50'); const consumptionP80 = profileOf('consumption_p80'); const horizon = Array.isArray(input.horizon) ? input.horizon : null; const socPct = num(input.soc_percent, NaN); const usableCapacityKwh = num(input.usable_capacity_kwh, NaN); const socFloorPct = num(input.soc_floor_percent, 22); // Sans l'une de ces entrées, le bilan ne peut rien affirmer. Il le dit, et les // contrôleurs retombent sur leurs règles historiques. const missing = []; if (!consumptionP50) missing.push('consumption_p50'); if (!consumptionP80) missing.push('consumption_p80'); if (!horizon || !horizon.length) missing.push('horizon'); if (!Number.isFinite(socPct)) missing.push('soc_percent'); if (!Number.isFinite(usableCapacityKwh) || usableCapacityKwh <= 0) missing.push('usable_capacity_kwh'); if (missing.length) { return [{ json: { usable: false, missing, reason: `Bilan indisponible : ${missing.join(', ')}. Les contrôleurs doivent conserver leurs règles.`, cfg: CFG, }, }]; } // --------------------------------------------------------------------- // Bilan heure par heure, calculé DEUX FOIS. // // Pourquoi deux niveaux de prudence et pas un seul : la décision « faut-il du // réseau maintenant ? » est réévaluée à chaque cycle et reste réversible. Se // tromper par optimisme ne coûte qu'un démarrage réseau une heure plus tard, // le plancher dur garantissant la batterie ; se tromper par excès de prudence // coûte un import réseau bien réel, immédiatement. // // Vérifié sur la matinée du 24/08/2026 : avec le profil prudent, le bilan // exigerait 48 % de SOC pour franchir la matinée, alors que la consommation // réelle de ce jour-là n'a demandé que 0,8 kWh — le seuil fixe de 30 % aurait // été MOINS coûteux. Un bilan « intelligent » plus gourmand que le seuil bête // qu'il remplace n'a aucun intérêt. // // - profil TYPIQUE (p50) → décision réseau immédiate, réévaluée en continu. // - profil PRUDENT (p80) → planification : cible de nuit, dimensionnement // du stockage, choix qui engagent plusieurs heures. // --------------------------------------------------------------------- function buildSlots(consumption) { return horizon.slice(0, CFG.SEARCH_HOURS).map(slot => { const hour = num(slot.hour, 0); const productionKwh = Math.max(0, num(slot.expectedKwh, 0)); const consumptionKwh = consumption[hour] ?? 0; return { startMs: slot.startMs, hour, productionKwh: round2(productionKwh), consumptionKwh: round2(consumptionKwh), netKwh: round2(productionKwh - consumptionKwh), shapeQuality: slot.shapeQuality || 'inconnue', }; }); } // ===================================================================== // COURBE D'ENERGIE DE LA BATTERIE — MESUREE, PAS SUPPOSEE (03/09/2026) // ===================================================================== // La conversion energie <-> points de SOC etait une CONSTANTE // (`usable_capacity_kwh`, 15 kWh) la ou la realite est une COURBE. // // Mesure : 576 paliers de SOC sur 8 jours (`tools/mesure-courbe-batterie.mjs`). // Pour chaque palier ou le SOC reste a une valeur puis DESCEND d'un point, on // integre la puissance batterie — en ecartant les paliers ou la batterie a // charge ou ou le reseau a apporte quoi que ce soit. // // tranche paliers kWh/point // 21-31 88 0,097 <- effondrement pres du genou // 35-44 78 0,171 // 45-54 76 0,169 // 55-64 85 0,163 // 65-74 80 0,171 // 75-84 81 0,170 // 85-94 62 0,169 // // Plate a 0,169 de 33 a 93, genou net a 32-33, puis 0,097 : sous le genou, un // point de SOC vaut 57 % de ce qu'il vaut au-dessus. Coherent avec §0 (un // module en panne sur huit, un autre qui faiblit) et avec l'effondrement de la // jauge deja signale au §28 — ceci lui donne sa courbe. // // CE QUE LA CONSTANTE FAISAIT : 15 kWh se situe ENTRE les deux pentes, donc // l'erreur CHANGE DE SIGNE selon l'energie a couvrir — // 1 kWh a couvrir : SOC requis 28,7 % annonce contre 32,3 % reel (-3,6) // 4,3 kWh : 50,7 contre 52,1 (-1,4) // 8 kWh : 75,3 contre 74,0 (+1,3) // 11,7 kWh : 99,7 contre 95,7 (+4,1) // Trop BAS en fin de nuit (la direction dangereuse), trop HAUT en debut. // // Energie utile totale de 100 a 22 % : 12,39 kWh mesures, contre 11,70 vus par // le bilan et 13,65 par INPUT_PARSE. Les deux constantes existantes etaient // fausses, dans des sens opposes. // // ⚠️ A RE-MESURER apres toute evolution du parc (extension prevue cet hiver) : // `node tools/mesure-courbe-batterie.mjs 8`, puis mettre a jour ces trois // nombres. Ce ne sont pas des reglages, ce sont des resultats de mesure. // // ⚠️ CES PENTES ONT ETE MESUREES SUR SEPT MODULES. Retirer ou ajouter un module // divise ou multiplie la capacite sans changer la FORME de la courbe : on les // exprime donc par module, et on les multiplie par le nombre reellement en // service. Ce nombre se lit dans un helper — `input_number // .evcs_balance_modules_en_service` — et non dans le code : le parc a change // deux fois en une semaine et changera encore cet hiver. Helper absent ou // illisible => 7, la valeur sur laquelle la mesure a ete faite, donc aucun // changement de comportement tant que personne ne l'ecrit. const MODULES_MESURE = 7; const modulesEnService = (() => { const n = num(input.modules_en_service, MODULES_MESURE); return Number.isFinite(n) && n >= 1 && n <= 24 ? n : MODULES_MESURE; })(); const echelleParc = modulesEnService / MODULES_MESURE; const BATTERY_CURVE = { kneePct: 33, kwhPerPointAbove: 0.169 * echelleParc, kwhPerPointBelow: 0.097 * echelleParc, }; // ⚠️ LE GENOU EST CONSERVE PAR PRUDENCE, PAS PAR MESURE. L'hypothese la mieux // etayee est qu'il vient des cellules faibles du pack ancien, donc qu'il // disparait avec lui — auquel cas la pente reste plate et la reserve reelle est // MEILLEURE qu'annoncee ici. Le garder revient a se tromper du bon cote. // A trancher en remesurant : `node tools/mesure-courbe-batterie.mjs 8`. // Energie que la batterie peut delivrer en descendant de socHi a socLo. function energyBetweenSoc(socLo, socHi) { if (!Number.isFinite(socLo) || !Number.isFinite(socHi) || socHi <= socLo) return 0; const knee = BATTERY_CURVE.kneePct; const lowSpan = Math.max(0, Math.min(socHi, knee) - Math.min(socLo, knee)); const highSpan = Math.max(0, Math.max(socHi, knee) - Math.max(socLo, knee)); return lowSpan * BATTERY_CURVE.kwhPerPointBelow + highSpan * BATTERY_CURVE.kwhPerPointAbove; } // SOC necessaire pour delivrer energyKwh en descendant jusqu'a floorPct. // NON ecrete : au-dela de 100 la valeur reste le signal de sous-dimensionnement // (cf. §26). L'extrapolation au-dessus de 100 utilise la pente haute. function socForEnergy(energyKwh, floorPct) { if (!Number.isFinite(energyKwh) || energyKwh <= 0) return floorPct; const knee = Math.max(floorPct, BATTERY_CURVE.kneePct); const belowKwh = (knee - floorPct) * BATTERY_CURVE.kwhPerPointBelow; if (energyKwh <= belowKwh) { return floorPct + energyKwh / BATTERY_CURVE.kwhPerPointBelow; } return knee + (energyKwh - belowKwh) / BATTERY_CURVE.kwhPerPointAbove; } // SOC atteint en ajoutant addKwh a partir de socPct. function socAfterEnergy(socPct, addKwh) { if (!Number.isFinite(addKwh) || addKwh <= 0) return socPct; return socForEnergy(energyBetweenSoc(0, socPct) + addKwh, 0); } function coverage(slots) { const crossingIndex = slots.findIndex(s => s.netKwh > 0); const found = crossingIndex >= 0; const deficitSlots = found ? slots.slice(0, crossingIndex) : slots; const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0); const requiredSocRawPct = socForEnergy(energyToCoverKwh, socFloorPct); const socNeededPct = requiredSocRawPct - socFloorPct; const requiredSocPct = Math.min(100, requiredSocRawPct); const socMarginPct = socPct - requiredSocPct; return { crossingIndex, found, deficitSlots, slots, energyToCoverKwh, socNeededPct, requiredSocPct, socMarginPct, gridNeeded: socMarginPct < 0, gridEnergyNeededKwh: socMarginPct < 0 ? energyBetweenSoc(socPct, requiredSocPct) : 0, }; } const typical = coverage(buildSlots(consumptionP50)); const prudent = coverage(buildSlots(consumptionP80)); // --------------------------------------------------------------------- // PROCHAINE FENÊTRE DE DÉFICIT (ajout du 24/08/2026) // // Le calcul ci-dessus répond à « combien d'énergie jusqu'à ce que le solaire // prenne le relais ». À 15 h, le solaire a déjà pris le relais : la réponse est // zéro et le SOC requis retombe sur le plancher. C'est exact mais inutile à // cette heure-là, car le déficit qui approche est celui de la nuit. // // On cherche donc AUSSI la prochaine fenêtre de déficit après le surplus en // cours : son heure de début, son énergie, et le SOC qu'il faudra avoir atteint // à ce moment-là. C'est cette grandeur qui remplace la cible de nuit fixe, et // qui dit combien de surplus il faut avoir stocké avant le coucher du soleil. // // Profil PRUDENT : contrairement à la décision réseau immédiate, cette // projection engage plusieurs heures et n'est pas révisable à volonté — la nuit // tombée, il n'y a plus de solaire pour rattraper une sous-estimation. // --------------------------------------------------------------------- function nextDeficitWindow(slots) { const firstSurplus = slots.findIndex(s => s.netKwh > 0); if (firstSurplus < 0) return { found: false, reason: 'deficit_en_cours' }; const start = slots.findIndex((s, i) => i > firstSurplus && s.netKwh < 0); if (start < 0) return { found: false, reason: 'aucun_deficit_sur_horizon' }; let end = slots.findIndex((s, i) => i > start && s.netKwh > 0); if (end < 0) end = slots.length; const energyKwh = slots.slice(start, end) .reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0); // Surplus encore récupérable d'ici là : de quoi remplir, ou pas. const surplusBeforeKwh = slots.slice(0, start) .reduce((sum, s) => sum + Math.max(0, s.netKwh), 0); return { found: true, startHour: slots[start].hour, startMs: slots[start].startMs, hoursFromNow: start, endHour: end < slots.length ? slots[end].hour : null, complete: end < slots.length, energyKwh, // NON écrêté à 100 volontairement : une valeur au-dessus de 100 signifie // que même une batterie pleine ne suffit pas à traverser la fenêtre. C'est // exactement le signal de sous-dimensionnement du stockage — l'écrêter le // masquerait. requiredSocAtStartPct: socForEnergy(energyKwh, socFloorPct), surplusBeforeKwh, }; } const nextDeficit = nextDeficitWindow(prudent.slots); // --------------------------------------------------------------------- // RÉSERVE D'ORAGE (27/08/2026) // // Les coupures réseau sont fréquentes ici lors des orages. Point de départ // souvent mal posé : une coupure n'arrête PAS l'installation. Le solaire et // la batterie continuent de fonctionner, seul le filet de secours disparaît. // La question n'est donc pas « combien stocker pour survivre » mais « la // batterie traverse-t-elle la prochaine fenêtre de déficit SANS le réseau ». // // C'est exactement la couverture PRUDENTE (p80) déjà calculée plus haut : // rien à inventer, aucune constante de plus. Le profil prudent est le bon // choix ici parce qu'une coupure retire précisément la possibilité de // rattraper une sous-estimation. // // Le niveau vient de la vigilance Météo-France, dont l'échelle officielle est // vert/jaune/orange/rouge. « Actif » à partir d'orange : c'est le seuil de // l'émetteur, pas un nombre choisi ici. // --------------------------------------------------------------------- const STORM_SCALE = ['vert', 'jaune', 'orange', 'rouge']; const STORM_ACTIVE_FROM = STORM_SCALE.indexOf('orange'); const stormRaw = typeof input.storm_alert_level === 'string' ? input.storm_alert_level.trim().toLowerCase() : ''; const stormIndex = STORM_SCALE.indexOf(stormRaw); const stormKnown = stormIndex >= 0; const stormActive = stormIndex >= STORM_ACTIVE_FROM; // Phénomènes atteignant le niveau d'alerte, s'ils sont exposés en attributs // (« Orages », « Vent violent »…). Purement informatif. const stormDetail = (input.storm_alert_detail && typeof input.storm_alert_detail === 'object') ? input.storm_alert_detail : {}; const stormPhenomena = Object.keys(stormDetail) .filter(k => typeof stormDetail[k] === 'string' && STORM_SCALE.indexOf(stormDetail[k].trim().toLowerCase()) >= STORM_ACTIVE_FROM) .sort(); // En surplus, la fenêtre qui compte est la prochaine (la nuit qui vient) ; // en déficit, c'est celle qu'on est en train de traverser. const inSurplusNow = prudent.found && prudent.crossingIndex === 0; const stormWindow = (inSurplusNow && nextDeficit.found) ? { kwh: nextDeficit.energyKwh, socPct: nextDeficit.requiredSocAtStartPct, startHour: nextDeficit.startHour, } : { kwh: prudent.energyToCoverKwh, socPct: prudent.requiredSocPct, startHour: null, }; const stormMarginPct = socPct - stormWindow.socPct; const slots = typical.slots; const crossingFound = typical.found; const crossingIndex = typical.crossingIndex; const crossing = crossingFound ? slots[crossingIndex] : null; const deficitSlots = typical.deficitSlots; // --------------------------------------------------------------------- // Marge disponible pour le véhicule : surplus attendu après le croisement, // une fois la maison servie. C'est ce qui peut être chargé sans toucher au // réseau ni entamer la réserve. // --------------------------------------------------------------------- const surplusAfterCrossingKwh = crossingFound ? slots.slice(crossingIndex).reduce((sum, s) => sum + Math.max(0, s.netKwh), 0) : 0; // --------------------------------------------------------------------- // Confiance : la part du calcul qui repose sur une forme de production minorée // (après-midi censuré). Elle ne dégrade PAS la réponse « faut-il du réseau », // qui se joue sur les heures de déficit — celles-ci ne sont jamais censurées, // une heure censurée étant par construction une heure de surplus. // --------------------------------------------------------------------- const deficitOnWeakShape = deficitSlots.filter(s => s.shapeQuality === 'minoree').length; const confidence = { deficitHoursOnWeakShape: deficitOnWeakShape, deficitTrustworthy: deficitOnWeakShape === 0, surplusTrustworthy: !slots.slice(crossingFound ? crossingIndex : 0) .some(s => s.shapeQuality === 'minoree'), }; return [{ json: { usable: true, generatedAt: new Date(num(input.nowMs, Date.now())).toISOString(), crossing: { found: crossingFound, hour: crossing ? crossing.hour : null, startMs: crossing ? crossing.startMs : null, hoursFromNow: crossingFound ? crossingIndex : null, }, coverage: { // Décision réseau immédiate — remplace input_number.victron_grid_soc_off. energyToCoverKwh: round2(typical.energyToCoverKwh), requiredSocPct: round1(typical.requiredSocPct), currentSocPct: round1(socPct), socMarginPct: round1(typical.socMarginPct), gridNeeded: typical.gridNeeded, gridEnergyNeededKwh: round2(typical.gridEnergyNeededKwh), // Planification (cible de nuit, dimensionnement) — ne pilote PAS le réseau. prudent: { energyToCoverKwh: round2(prudent.energyToCoverKwh), requiredSocPct: round1(prudent.requiredSocPct), socMarginPct: round1(prudent.socMarginPct), crossingHour: prudent.found ? prudent.slots[prudent.crossingIndex].hour : null, }, }, // Ce qu'il faudra avoir stocké avant la prochaine traversée déficitaire. nextDeficit: nextDeficit.found ? { found: true, startHour: nextDeficit.startHour, hoursFromNow: nextDeficit.hoursFromNow, endHour: nextDeficit.endHour, complete: nextDeficit.complete, energyKwh: round2(nextDeficit.energyKwh), requiredSocAtStartPct: round1(nextDeficit.requiredSocAtStartPct), surplusBeforeKwh: round2(nextDeficit.surplusBeforeKwh), // Franchissable avec le stockage actuel, batterie pleine ? feasibleWithCurrentStorage: nextDeficit.requiredSocAtStartPct <= 100, // Si non : capacité utile manquante. C'est le chiffre du dimensionnement. storageShortfallKwh: energyBetweenSoc(100, nextDeficit.requiredSocAtStartPct), reachable: nextDeficit.surplusBeforeKwh >= 0 && socAfterEnergy(socPct, nextDeficit.surplusBeforeKwh) >= nextDeficit.requiredSocAtStartPct, } : { found: false, reason: nextDeficit.reason }, vehicle: { surplusAvailableKwh: round2(surplusAfterCrossingKwh), }, // Réserve à tenir pour traverser SANS réseau la fenêtre de déficit qui // compte. Publiée en permanence — elle documente le risque même par temps // calme ; `active` dit seulement si la vigilance la rend d'actualité. storm: { level: stormKnown ? stormRaw : 'indisponible', known: stormKnown, active: stormActive, phenomena: stormPhenomena, windowStartHour: stormWindow.startHour, reserveKwh: round2(stormWindow.kwh), reserveSocPct: round1(stormWindow.socPct), socMarginPct: round1(stormMarginPct), covered: stormMarginPct >= 0, shortfallKwh: stormMarginPct < 0 ? round2(energyBetweenSoc(socPct, stormWindow.socPct)) : 0, }, confidence, slots, inputs: { usableCapacityKwh: round2(usableCapacityKwh), socFloorPct: round1(socFloorPct), }, cfg: CFG, }, }];