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

@@ -2938,13 +2938,26 @@ par point de SOC, sur cinq fenetres et trois nuits :
| 02-03/09 03:00-06:00 (zero reseau) | 45 → 22 | 0,121 | **12,1 kWh** | | 02-03/09 03:00-06:00 (zero reseau) | 45 → 22 | 0,121 | **12,1 kWh** |
| 01-02/09 03:00-06:00 | 33 → 20 | 0,082 | **8,2 kWh** | | 01-02/09 03:00-06:00 | 33 → 20 | 0,082 | **8,2 kWh** |
Au-dessus de ~42 % la jauge est lineaire et coherente (~17 kWh, ce qui valide Au-dessus de ~42 % la jauge est lineaire et coherente (~17 kWh). **En dessous
le `batteryUsableCapacityKwh: 17.5` code en dur). **En dessous elle s'effondre : elle s'effondre : un point de SOC y vaut moitie moins.** Or `BALANCE.js`
un point de SOC y vaut moitie moins.** Or `BALANCE.js` convertit avec une convertit avec une constante unique (helper a 15 kWh).
constante unique : `socNeeded = energyToCover / usable_capacity_kwh * 100`, avec
le helper a 15 kWh. `required_soc` est donc systematiquement TROP BAS dans la > ⚠️ **CORRECTION apportee le 03/09 au soir (§41).** La phrase qui figurait ici
bande ou la nuit se termine — c'est le facteur 1,75 mesure la nuit du 02-03/09, > — « `required_soc` est donc systematiquement TROP BAS ... c'est le facteur
ou le SOC descendait bien plus vite que la ligne. > 1,75 » — etait FAUSSE, sur les deux points.
> 1. 15 kWh se situe ENTRE les deux pentes reelles (0,097 et 0,169), donc
> l'erreur CHANGE DE SIGNE : trop bas de 3 a 4 points pour un petit deficit
> (fin de nuit), trop haut de 4 a 6 points pour un gros (debut de nuit).
> 2. Le facteur 1,75 comparait la vitesse de descente du SOC a celle de la
> ligne. Ces deux grandeurs ne mesurent pas la meme chose : la ligne descend
> parce que le temps passe. L'ecart venait donc surtout de la
> **sous-estimation de la consommation** par le profil p50 (2,1 kWh annonces
> pour 03:00-06:00, 2,79 kWh mesures), et non de la conversion.
>
> La conception de la session (relachement sur la fenetre et non sur un seuil de
> SOC) reste juste : elle ne dependait pas de l'ampleur de l'erreur, seulement
> du fait que le relachement ne doit pas porter sur une grandeur approximative
> ni rouvrir l'oscillateur du 27/08.
**Conclusion de conception, et elle est structurante : le relachement de la **Conclusion de conception, et elle est structurante : le relachement de la
session reseau ne peut PAS etre un seuil de SOC.** Il porterait sur une grandeur session reseau ne peut PAS etre un seuil de SOC.** Il porterait sur une grandeur
@@ -3042,3 +3055,106 @@ distinct.
--- ---
*Document mis a jour le : 03/09/2026 (~23:20) — session reseau unique de la nuit, golden 78/78, deploye* *Document mis a jour le : 03/09/2026 (~23:20) — session reseau unique de la nuit, golden 78/78, deploye*
## 41. Session du 03/09/2026 (suite) — La conversion energie ↔ SOC devient une courbe mesuree
### La mesure
`tools/mesure-courbe-batterie.mjs` (nouveau, rejouable). Methode : pour chaque
palier ou le SOC reste a une valeur puis **descend d'un point**, integrer la
puissance batterie sur ce palier — en ecartant les paliers ou la batterie a
charge et ceux ou le reseau a apporte quoi que ce soit (> 20 Wh). Chaque palier
donne donc directement une mesure de « kWh par point de SOC », attribuee a ce
point.
**576 paliers retenus sur 8 jours** (105 ecartes) :
| tranche | paliers | kWh/point | capacite implicite |
|---|---:|---:|---:|
| 21-31 | 88 | **0,097** | 9,7 kWh |
| 35-44 | 78 | 0,171 | 17,1 kWh |
| 45-54 | 76 | 0,169 | 16,9 kWh |
| 55-64 | 85 | 0,163 | 16,3 kWh |
| 65-74 | 80 | 0,171 | 17,1 kWh |
| 75-84 | 81 | 0,170 | 17,0 kWh |
| 85-94 | 62 | 0,169 | 16,9 kWh |
Plate a **0,169** de 33 a 93 — remarquablement stable sur six tranches
independantes — **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. Coherent avec §0 (un
module en panne sur huit, un autre qui faiblit) et avec l'effondrement de la
jauge signale au §28 : ceci lui donne enfin sa courbe.
### Ce que la constante faisait vraiment
15 kWh se situe ENTRE les deux pentes, donc **l'erreur change de signe** :
| energie a couvrir | SOC requis annonce | reel | ecart |
|---:|---:|---:|---:|
| 1 kWh | 28,7 | 32,3 | **−3,6** |
| 4,3 kWh | 50,7 | 52,1 | −1,4 |
| 8 kWh | 75,3 | 74,0 | +1,3 |
| 11,7 kWh | 99,7 | 95,7 | **+4,1** |
Trop bas en fin de nuit — la direction dangereuse — et trop haut en debut de
nuit. Ce n'est pas le facteur 1,75 annonce au §40, qui est corrige en tete de
cette section.
**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 existantes etaient fausses, dans des sens opposes.** Chiffre a
retenir pour le dimensionnement du parc prevu cet hiver.
### Le correctif (deploye 03/09 23:27)
- **`src/energy-balance/BALANCE.js`** : trois fonctions —
`energyBetweenSoc(lo, hi)`, `socForEnergy(kWh, floor)`, `socAfterEnergy(soc, kWh)`
— remplacent les **cinq** conversions lineaires : `requiredSocPct`,
`gridEnergyNeededKwh`, `requiredSocAtStartPct`, `storageShortfallKwh` +
`reachable`, et le manque de la reserve d'orage.
`requiredSocAtStartPct` reste **NON ecrete** au-dela de 100 (§26 : signal de
dimensionnement) ; l'extrapolation y utilise la pente haute.
- **`src/nodes/INPUT_PARSE.js`** : `batteryUsableCapacityKwh` 17,5 → **16,9**
(valeur mesuree). Cette constante ne sert qu'a des ecarts SOC → cible, tous
deux au-dessus du genou. **Un calcul qui traverserait le genou doit lire la
COURBE, pas cette constante** — commentaire pose sur place.
- `usable_capacity_kwh` reste exige en entree (garde de completude du snapshot)
et publie, mais ne pilote plus aucune conversion.
### Tests : 42/42 apres mise a jour deliberee de 6 attendus
Les six valeurs ont ete **verifiees a la main** contre la courbe avant d'etre
figees (0,82 kWh → 30,5 ; 2,42 → 41,0 ; 12,77 → 102,2 ; manque 100→102,2 = 0,37 ;
orage 21→41 = 2,52 ; orage 95→102,2 = 1,22). Un libelle a ete corrige :
`bilan-rejeu-24-08-matin` affirmait « SOC requis 27,5 % au lieu du seuil fixe de
30 % » ; la courbe donne 30,5 %, donc **le seuil fixe tombait juste ce matin-la —
par coincidence, pas par construction**. Le dire ainsi est plus honnete que de
laisser croire a un ecart.
Mutation, deux directions : pente basse ramenee a la pente haute, puis genou
ramene au plancher — **6 echecs a chaque fois**, les memes six. Restauration →
42/42. Les trois autres suites (golden 78, hysteresis 7, advisor) sont
inchangees.
### ⚠️ A RE-MESURER apres l'extension du parc
Les trois nombres (`kneePct`, `kwhPerPointAbove`, `kwhPerPointBelow`) sont des
**resultats de mesure**, pas des reglages : `node tools/mesure-courbe-batterie.mjs 8`
puis mise a jour. C'est aussi la raison pour laquelle ils vivent dans le code
avec l'outil qui les produit, plutot que dans trois helpers de plus.
### Ce que ce correctif NE corrige pas
Le profil de consommation p50 sous-estimait de ~33 % les heures 03-06 la nuit du
02-03/09 (2,1 kWh annonces, 2,79 mesures). **C'est desormais la principale
source d'erreur du bilan**, devant la conversion. Les profils sont recalcules
quotidiennement par `evcs-energy-profiles-daily-v1` sur l'historique — a
instruire separement : fenetre d'apprentissage, saisonnalite, et le fait que
l'historique HA ne remonte qu'a ~7 jours (§28).
### Etat du cablage du bilan
| consommateur | statut |
|---|---|
| plancher de demarrage du soir | ✅ §38 |
| cible du matin | ✅ §39 |
| session reseau unique de la nuit | ✅ §40 |
| conversion kWh ↔ SOC | ✅ §41 (courbe mesuree) |
| profil de consommation p50 | ⚠️ sous-estime, principale erreur restante |
| hysteresis `grid_soc_on`/`off` (20/30) | ❌ niveaux fixes |
---
*Document mis a jour le : 03/09/2026 (~23:35) — conversion energie/SOC mesuree (576 paliers), bilan 42/42, golden 78/78, deploye*

View File

@@ -912,6 +912,45 @@ reserve d'orage est optimiste dans la meme bande. §28 avait deja signale
l'effondrement de la jauge ; les mesures du 03/09 lui donnent sa courbe. C'est l'effondrement de la jauge ; les mesures du 03/09 lui donnent sa courbe. C'est
le prochain chantier du bilan energetique. le prochain chantier du bilan energetique.
#### H. La conversion energie ↔ SOC devient une courbe mesuree (03/09/2026)
Le prerequis ci-dessus, traite le soir meme. `tools/mesure-courbe-batterie.mjs`
(rejouable) integre la puissance batterie sur chaque palier de SOC qui descend
d'un point, en ecartant les paliers avec charge ou apport reseau : **576 paliers
sur 8 jours**.
| tranche | paliers | kWh/point |
|---|---:|---:|
| 21-31 | 88 | **0,097** |
| 35-94 | 462 | **0,169** (six tranches, 0,163 a 0,171) |
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 le parc reel
(§0 : 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 — chiffre a retenir pour le
dimensionnement du parc prevu cet hiver.
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 (fin de nuit, la
direction dangereuse), trop haut de 4 a 6 points pour un gros (debut de nuit).
Correctif : trois fonctions (`energyBetweenSoc`, `socForEnergy`,
`socAfterEnergy`) remplacent les cinq conversions lineaires de `BALANCE.js`, et
`INPUT_PARSE` passe de 17,5 a 16,9. Tests du bilan 42/42 apres mise a jour
deliberee de six attendus, chacun verifie a la main ; deux mutations font tomber
les six memes scenarios. Detail : HANDOFF §41.
⚠️ Ces trois nombres sont des **resultats de mesure, pas des reglages** :
`node tools/mesure-courbe-batterie.mjs 8` est a rejouer apres toute evolution du
parc.
⚠️ **Principale erreur restante du bilan** : le profil de consommation p50
sous-estimait de ~33 % les heures 03-06 la nuit du 02-03/09 (2,1 kWh annonces,
2,79 mesures). Devant la conversion, desormais. A instruire separement.
⚠️ Point structurel mis au jour par cet incident : le bilan energetique publie ⚠️ Point structurel mis au jour par cet incident : le bilan energetique publie
l'heure de croisement, l'energie a couvrir, le SOC requis, `grid_needed` et l'heure de croisement, l'energie a couvrir, le SOC requis, `grid_needed` et
`pv_censored` — et **aucun des deux workflows de decision n'en lit une seule `pv_censored` — et **aucun des deux workflows de decision n'en lit une seule

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) { function coverage(slots) {
const crossingIndex = slots.findIndex(s => s.netKwh > 0); const crossingIndex = slots.findIndex(s => s.netKwh > 0);
const found = crossingIndex >= 0; const found = crossingIndex >= 0;
const deficitSlots = found ? slots.slice(0, crossingIndex) : slots; const deficitSlots = found ? slots.slice(0, crossingIndex) : slots;
const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0); const energyToCoverKwh = deficitSlots.reduce((sum, s) => sum + Math.max(0, -s.netKwh), 0);
const socNeededPct = (energyToCoverKwh / usableCapacityKwh) * 100; const requiredSocRawPct = socForEnergy(energyToCoverKwh, socFloorPct);
const requiredSocPct = Math.min(100, socFloorPct + socNeededPct); const socNeededPct = requiredSocRawPct - socFloorPct;
const requiredSocPct = Math.min(100, requiredSocRawPct);
const socMarginPct = socPct - requiredSocPct; const socMarginPct = socPct - requiredSocPct;
return { return {
crossingIndex, found, deficitSlots, slots, crossingIndex, found, deficitSlots, slots,
energyToCoverKwh, socNeededPct, requiredSocPct, socMarginPct, energyToCoverKwh, socNeededPct, requiredSocPct, socMarginPct,
gridNeeded: socMarginPct < 0, 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 // 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 // exactement le signal de sous-dimensionnement du stockage — l'écrêter le
// masquerait. // masquerait.
requiredSocAtStartPct: socFloorPct + (energyKwh / usableCapacityKwh) * 100, requiredSocAtStartPct: socForEnergy(energyKwh, socFloorPct),
surplusBeforeKwh, surplusBeforeKwh,
}; };
} }
@@ -319,12 +395,9 @@ return [{
// Franchissable avec le stockage actuel, batterie pleine ? // Franchissable avec le stockage actuel, batterie pleine ?
feasibleWithCurrentStorage: nextDeficit.requiredSocAtStartPct <= 100, feasibleWithCurrentStorage: nextDeficit.requiredSocAtStartPct <= 100,
// Si non : capacité utile manquante. C'est le chiffre du dimensionnement. // Si non : capacité utile manquante. C'est le chiffre du dimensionnement.
storageShortfallKwh: Math.max( storageShortfallKwh: energyBetweenSoc(100, nextDeficit.requiredSocAtStartPct),
0,
((nextDeficit.requiredSocAtStartPct - 100) / 100) * usableCapacityKwh
),
reachable: nextDeficit.surplusBeforeKwh >= 0 reachable: nextDeficit.surplusBeforeKwh >= 0
&& (socPct + (nextDeficit.surplusBeforeKwh / usableCapacityKwh) * 100) && socAfterEnergy(socPct, nextDeficit.surplusBeforeKwh)
>= nextDeficit.requiredSocAtStartPct, >= nextDeficit.requiredSocAtStartPct,
} : { found: false, reason: nextDeficit.reason }, } : { found: false, reason: nextDeficit.reason },
@@ -346,7 +419,7 @@ return [{
socMarginPct: round1(stormMarginPct), socMarginPct: round1(stormMarginPct),
covered: stormMarginPct >= 0, covered: stormMarginPct >= 0,
shortfallKwh: stormMarginPct < 0 shortfallKwh: stormMarginPct < 0
? round2((Math.abs(stormMarginPct) / 100) * usableCapacityKwh) ? round2(energyBetweenSoc(socPct, stormWindow.socPct))
: 0, : 0,
}, },

View File

@@ -213,7 +213,14 @@ const CFG = {
mobileNotifyDomain: 'notify', mobileNotifyDomain: 'notify',
mobileNotifyService: 'mobile_app_iphone_16_de_tt', 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, nominalAcVoltageV: 230,
evcsDedicatedMaxW: 4900, evcsDedicatedMaxW: 4900,
systemReserveW: 300, systemReserveW: 300,

View File

@@ -39,7 +39,7 @@ export const balanceScenarios = [
{ {
node: 'BALANCE', node: 'BALANCE',
id: 'bilan-rejeu-24-08-matin', id: 'bilan-rejeu-24-08-matin',
label: 'Rejeu réel du 24/08 06:43 : croisement à 08 h, 0,82 kWh à couvrir, SOC requis 27,5 % au lieu du seuil fixe de 30 %', label: 'Rejeu réel du 24/08 06:43 : croisement à 08 h, 0,82 kWh à couvrir → SOC requis 30,5 % (courbe mesurée). Le seuil fixe de 30 % tombait juste ce matin-là — par coïncidence, pas par construction',
snapshot: { snapshot: {
nowMs: Date.parse('2026-08-24T06:43:00+02:00'), nowMs: Date.parse('2026-08-24T06:43:00+02:00'),
consumption_p50: P50, consumption_p50: P50,
@@ -53,9 +53,9 @@ export const balanceScenarios = [
usable: true, usable: true,
crossingHour: 8, crossingHour: 8,
energyToCoverKwh: 0.82, energyToCoverKwh: 0.82,
requiredSocPct: 27.5, requiredSocPct: 30.5,
gridNeeded: true, gridNeeded: true,
prudentRequiredSocPct: 38.1, prudentRequiredSocPct: 41,
deficitTrustworthy: true, deficitTrustworthy: true,
}, },
}, },
@@ -75,7 +75,7 @@ export const balanceScenarios = [
expect: { expect: {
usable: true, usable: true,
gridNeeded: false, gridNeeded: false,
requiredSocPct: 27.5, requiredSocPct: 30.5,
}, },
}, },
{ {
@@ -125,8 +125,8 @@ export const balanceScenarios = [
nextDeficitEnergyKwh: 12.77, nextDeficitEnergyKwh: 12.77,
// Au-dessus de 100 : même pleine, la batterie actuelle ne passe pas la // Au-dessus de 100 : même pleine, la batterie actuelle ne passe pas la
// nuit sur le profil prudent. C'est le chiffre du dimensionnement. // nuit sur le profil prudent. C'est le chiffre du dimensionnement.
nextDeficitRequiredSocPct: 107.1, nextDeficitRequiredSocPct: 102.2,
storageShortfallKwh: 1.07, storageShortfallKwh: 0.38,
}, },
}, },
{ {
@@ -172,7 +172,7 @@ export const balanceScenarios = [
// La réserve se calcule quand même : 2,42 kWh pour la matinée du 24/08 // La réserve se calcule quand même : 2,42 kWh pour la matinée du 24/08
// sur le profil prudent, soit SOC 38,1 %. // sur le profil prudent, soit SOC 38,1 %.
stormReserveKwh: 2.42, stormReserveKwh: 2.42,
stormReserveSocPct: 38.1, stormReserveSocPct: 41,
}, },
}, },
{ {
@@ -216,11 +216,11 @@ export const balanceScenarios = [
stormLevel: 'orange', stormLevel: 'orange',
stormActive: true, stormActive: true,
stormReserveKwh: 2.42, stormReserveKwh: 2.42,
stormReserveSocPct: 38.1, stormReserveSocPct: 41,
stormCovered: false, stormCovered: false,
// SOC 21 contre 38,1 requis : il manque 2,57 kWh pour tenir la matinée // SOC 21 contre 38,1 requis : il manque 2,57 kWh pour tenir la matinée
// sans réseau. // sans réseau.
stormShortfallKwh: 2.57, stormShortfallKwh: 2.52,
}, },
}, },
{ {
@@ -249,9 +249,9 @@ export const balanceScenarios = [
// 107,1 % — même pleine, la batterie actuelle ne passe pas la nuit sans // 107,1 % — même pleine, la batterie actuelle ne passe pas la nuit sans
// réseau. C'est le signal de dimensionnement, il ne doit pas être écrêté. // réseau. C'est le signal de dimensionnement, il ne doit pas être écrêté.
stormReserveKwh: 12.77, stormReserveKwh: 12.77,
stormReserveSocPct: 107.1, stormReserveSocPct: 102.2,
stormCovered: false, stormCovered: false,
stormShortfallKwh: 1.82, stormShortfallKwh: 1.22,
}, },
}, },
]; ];

View File

@@ -0,0 +1,72 @@
// Energie delivree par POINT de SOC, mesuree point par point.
// Methode : pour chaque palier ou le SOC reste a la valeur v puis DESCEND a
// v-1, on integre la puissance batterie sur ce palier. Uniquement si la
// batterie decharge tout du long et si le reseau n'apporte rien (sinon la
// mesure porte sur autre chose que la batterie).
import fs from 'node:fs';
const ROOT='/mnt/projects/EVCS/EVCS';
function token(){for(const l of fs.readFileSync(ROOT+'/.env','utf8').split('\n')){const m=l.match(/^\s*HA_TOKEN\s*=\s*(.+?)\s*$/);if(m)return m[1].replace(/^["']|["']$/g,'');}throw 0;}
const HA='http://10.10.10.17:8123';const H={Authorization:'Bearer '+token()};
async function hist(ent,t0,t1){
const u=`${HA}/api/history/period/${t0.toISOString()}?end_time=${t1.toISOString()}&filter_entity_id=${ent}&minimal_response&significant_changes_only=0`;
const d=await (await fetch(u,{headers:H})).json();
return (d[0]||[]).map(s=>({t:new Date(s.last_changed||s.last_updated).getTime(),v:Number(s.state)})).filter(p=>Number.isFinite(p.v));
}
const DAYS=Number(process.argv[2]||8);
const end=new Date(process.argv[3]||Date.now());
const bins={}; // soc -> {kwh, count, secs}
let paliers=0, rejetes=0;
for(let d=0; d<DAYS; d++){
const t1=new Date(end.getTime()-d*86400000);
const t0=new Date(t1.getTime()-86400000);
const [soc,bp,grid]=await Promise.all([
hist('sensor.victron_battery_soc',t0,t1),
hist('sensor.victron_battery_power',t0,t1),
hist('sensor.victron_grid_power_l1',t0,t1),
]);
if(soc.length<5||bp.length<5) continue;
const at=(arr,t)=>{let v=null;for(const p of arr){if(p.t<=t)v=p.v;else break;}return v;};
// paliers de SOC
for(let i=0;i<soc.length-1;i++){
const v=soc[i].v, vn=soc[i+1].v;
if(vn !== v-1) continue; // on ne garde que les descentes d'un point
const a=soc[i].t, b=soc[i+1].t;
if(b-a < 60000 || b-a > 4*3600000) { rejetes++; continue; }
// integration de la puissance batterie sur [a,b], et controle reseau
const pts=bp.filter(p=>p.t>=a&&p.t<=b);
const first=at(bp,a);
if(first===null) { rejetes++; continue; }
const seq=[{t:a,v:first},...pts,{t:b,v:pts.length?pts[pts.length-1].v:first}];
let kwh=0, positif=false;
for(let k=0;k<seq.length-1;k++){
const dt=(seq[k+1].t-seq[k].t)/3600000;
if(dt<=0) continue;
if(seq[k].v>50) positif=true; // la batterie s'est mise a charger
kwh += -seq[k].v*dt/1000;
}
if(positif||kwh<=0){ rejetes++; continue; }
// reseau : on rejette si import moyen significatif
const gpts=grid.filter(p=>p.t>=a&&p.t<=b);
const g0=at(grid,a);
const gseq=[{t:a,v:g0===null?0:g0},...gpts];
let gkwh=0;
for(let k=0;k<gseq.length-1;k++){const dt=(gseq[k+1].t-gseq[k].t)/3600000;if(dt>0)gkwh+=gseq[k].v*dt/1000;}
if(gkwh>0.02){ rejetes++; continue; }
const key=Math.round(v);
bins[key]=bins[key]||{kwh:0,n:0};
bins[key].kwh+=kwh; bins[key].n++;
paliers++;
}
}
console.log(`paliers retenus: ${paliers} | rejetes: ${rejetes}`);
const keys=Object.keys(bins).map(Number).sort((a,b)=>a-b);
console.log('\nSOC n kWh/point');
for(const k of keys) console.log(String(k).padStart(3), String(bins[k].n).padStart(3), (bins[k].kwh/bins[k].n).toFixed(3).padStart(7));
// regroupement par tranches de 10
console.log('\ntranche paliers kWh/point capacite implicite');
for(let lo=15; lo<100; lo+=10){
const ks=keys.filter(k=>k>=lo&&k<lo+10);
const n=ks.reduce((s,k)=>s+bins[k].n,0), e=ks.reduce((s,k)=>s+bins[k].kwh,0);
if(!n) continue;
console.log(`${lo}-${lo+9}`.padEnd(9), String(n).padStart(7), (e/n).toFixed(3).padStart(11), (100*e/n).toFixed(1).padStart(18),'kWh');
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long