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:
Tallagrand Thierry
2026-09-03 23:28:57 +02:00
parent 914ef021b2
commit 6603c5dfd2
8 changed files with 338 additions and 31 deletions

View File

@@ -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,
},

View File

@@ -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,