Files
EVCS/src/energy-balance/BALANCE.js
Tallagrand Thierry 6ae25ce339 Le nombre de modules se lit, il ne se code plus
La courbe energie/SOC a ete mesuree sur sept modules. Retirer un
module divise la capacite sans changer la forme de la courbe : les
pentes sont donc mises a l'echelle du nombre reellement en service,
lu dans input_number.evcs_balance_modules_en_service. Le controleur
EVCS lit le meme helper pour sa capacite utile - les deux decrivent
forcement le meme parc.

Helper absent, illisible ou hors bornes => sept, la valeur de la
mesure. Les 44 scenarios du bilan et les 80 golden passent donc sans
qu'un seul attendu ait ete touche : la bascule ne change rien tant
que personne ne l'ecrit.

Deux scenarios couvrent la bascule elle-meme, l'un avec quatre
modules (SOC exige 30,5 -> 35,2 %), l'autre avec une valeur
aberrante qui doit retomber sur sept. Preuve par mutation : ignorer
l'echelle fait tomber le premier et lui seul.

Le genou a 33 % est conserve par PRUDENCE et non par mesure :
l'hypothese est qu'il vient des cellules faibles du pack retire, donc
qu'il disparait avec lui. Le garder revient a sous-estimer la
reserve, c'est-a-dire a se tromper du bon cote.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-09-05 18:54:57 +02:00

459 lines
20 KiB
JavaScript

// 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 <= 16 ? 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,
},
}];