La conversion energie/SOC devient une courbe mesuree, pas une constante
576 paliers de SOC mesures sur 8 jours (tools/mesure-courbe-batterie.mjs, ajoute) : 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 et ceux ou le reseau a apporte quoi que ce soit. Resultat : plate a 0,169 kWh/point de 33 a 93 — six tranches independantes entre 0,163 et 0,171 — genou net a 32-33, puis 0,097 en dessous. Sous le genou un point de SOC vaut 57 % de ce qu il vaut au-dessus, ce qui est coherent avec le parc reel (un module en panne sur huit, un autre qui faiblit). Energie utile totale de 100 a 22 % : 12,39 kWh mesures, contre 11,70 vus par le bilan (15 kWh a plat) et 13,65 par INPUT_PARSE (17,5). Les deux constantes etaient fausses, dans des sens opposes. Comme 15 kWh se situe ENTRE les deux pentes, l erreur changeait de signe : SOC requis trop bas de 3 a 4 points pour un petit deficit, trop haut de 4 a 6 pour un gros. Cela corrige au passage une affirmation inexacte de la section 40, ou j avais ecrit que required_soc etait systematiquement trop bas d un facteur 1,75 : ce facteur comparait la vitesse de descente du SOC a celle de la ligne, deux grandeurs differentes, et l ecart venait surtout de la sous-estimation de la consommation par le profil p50. La correction est posee en tete de la section 40. Trois fonctions remplacent les cinq conversions lineaires de BALANCE.js ; requiredSocAtStartPct reste non ecrete au-dela de 100, l extrapolation utilisant la pente haute. INPUT_PARSE passe de 17,5 a 16,9 avec la consigne explicite qu un calcul traversant le genou doit lire la courbe et non cette constante. Tests du bilan 42/42 apres mise a jour deliberee de six attendus, chacun verifie a la main contre la courbe. Un libelle corrige : le seuil fixe de 30 % tombait juste ce matin-la par coincidence, pas par construction. Deux mutations — pente basse ramenee a la pente haute, puis genou ramene au plancher — font tomber les six memes scenarios. Golden 78, hysteresis 7 et advisor inchanges. Les trois nombres sont des resultats de mesure, pas des reglages, et vivent dans le code avec l outil qui les produit ; a rejouer apres l extension du parc. Principale erreur restante du bilan, desormais devant la conversion : le profil de consommation p50 sous-estimait de 33 % les heures 03-06 de la nuit du 02-03/09. A instruire separement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
This commit is contained in:
@@ -121,19 +121,95 @@ function buildSlots(consumption) {
|
||||
});
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 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.
|
||||
const BATTERY_CURVE = {
|
||||
kneePct: 33,
|
||||
kwhPerPointAbove: 0.169,
|
||||
kwhPerPointBelow: 0.097,
|
||||
};
|
||||
|
||||
// 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 socNeededPct = (energyToCoverKwh / usableCapacityKwh) * 100;
|
||||
const requiredSocPct = Math.min(100, socFloorPct + socNeededPct);
|
||||
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 ? (Math.abs(socMarginPct) / 100) * usableCapacityKwh : 0,
|
||||
gridEnergyNeededKwh: socMarginPct < 0 ? energyBetweenSoc(socPct, requiredSocPct) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,7 +262,7 @@ function nextDeficitWindow(slots) {
|
||||
// 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: socFloorPct + (energyKwh / usableCapacityKwh) * 100,
|
||||
requiredSocAtStartPct: socForEnergy(energyKwh, socFloorPct),
|
||||
surplusBeforeKwh,
|
||||
};
|
||||
}
|
||||
@@ -319,12 +395,9 @@ return [{
|
||||
// Franchissable avec le stockage actuel, batterie pleine ?
|
||||
feasibleWithCurrentStorage: nextDeficit.requiredSocAtStartPct <= 100,
|
||||
// Si non : capacité utile manquante. C'est le chiffre du dimensionnement.
|
||||
storageShortfallKwh: Math.max(
|
||||
0,
|
||||
((nextDeficit.requiredSocAtStartPct - 100) / 100) * usableCapacityKwh
|
||||
),
|
||||
storageShortfallKwh: energyBetweenSoc(100, nextDeficit.requiredSocAtStartPct),
|
||||
reachable: nextDeficit.surplusBeforeKwh >= 0
|
||||
&& (socPct + (nextDeficit.surplusBeforeKwh / usableCapacityKwh) * 100)
|
||||
&& socAfterEnergy(socPct, nextDeficit.surplusBeforeKwh)
|
||||
>= nextDeficit.requiredSocAtStartPct,
|
||||
} : { found: false, reason: nextDeficit.reason },
|
||||
|
||||
@@ -346,7 +419,7 @@ return [{
|
||||
socMarginPct: round1(stormMarginPct),
|
||||
covered: stormMarginPct >= 0,
|
||||
shortfallKwh: stormMarginPct < 0
|
||||
? round2((Math.abs(stormMarginPct) / 100) * usableCapacityKwh)
|
||||
? round2(energyBetweenSoc(socPct, stormWindow.socPct))
|
||||
: 0,
|
||||
},
|
||||
|
||||
|
||||
@@ -213,7 +213,14 @@ const CFG = {
|
||||
mobileNotifyDomain: 'notify',
|
||||
mobileNotifyService: 'mobile_app_iphone_16_de_tt',
|
||||
|
||||
batteryUsableCapacityKwh: 17.5,
|
||||
// 03/09/2026 — valeur MESUREE (576 paliers de SOC sur 8 jours,
|
||||
// tools/mesure-courbe-batterie.mjs) : 0,169 kWh par point au-dessus de 33 %,
|
||||
// soit 16,9 kWh. Le 17,5 precedent etait une valeur de plaque.
|
||||
// NB : sous 33 % un point ne vaut plus que 0,097 kWh. Cette constante ne sert
|
||||
// ici qu'a des ecarts SOC -> cible, tous deux au-dessus du genou. Si un calcul
|
||||
// devait un jour traverser le genou, il faut la COURBE (src/energy-balance/
|
||||
// BALANCE.js) et non cette constante.
|
||||
batteryUsableCapacityKwh: 16.9,
|
||||
nominalAcVoltageV: 230,
|
||||
evcsDedicatedMaxW: 4900,
|
||||
systemReserveW: 300,
|
||||
|
||||
Reference in New Issue
Block a user