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:
130
HANDOFF.md
130
HANDOFF.md
@@ -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** |
|
||||
| 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
|
||||
le `batteryUsableCapacityKwh: 17.5` code en dur). **En dessous elle s'effondre :
|
||||
un point de SOC y vaut moitie moins.** Or `BALANCE.js` convertit avec une
|
||||
constante unique : `socNeeded = energyToCover / usable_capacity_kwh * 100`, avec
|
||||
le helper a 15 kWh. `required_soc` est donc systematiquement TROP BAS dans la
|
||||
bande ou la nuit se termine — c'est le facteur 1,75 mesure la nuit du 02-03/09,
|
||||
ou le SOC descendait bien plus vite que la ligne.
|
||||
Au-dessus de ~42 % la jauge est lineaire et coherente (~17 kWh). **En dessous
|
||||
elle s'effondre : un point de SOC y vaut moitie moins.** Or `BALANCE.js`
|
||||
convertit avec une constante unique (helper a 15 kWh).
|
||||
|
||||
> ⚠️ **CORRECTION apportee le 03/09 au soir (§41).** La phrase qui figurait ici
|
||||
> — « `required_soc` est donc systematiquement TROP BAS ... c'est le facteur
|
||||
> 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
|
||||
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*
|
||||
|
||||
## 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*
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -39,7 +39,7 @@ export const balanceScenarios = [
|
||||
{
|
||||
node: 'BALANCE',
|
||||
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: {
|
||||
nowMs: Date.parse('2026-08-24T06:43:00+02:00'),
|
||||
consumption_p50: P50,
|
||||
@@ -53,9 +53,9 @@ export const balanceScenarios = [
|
||||
usable: true,
|
||||
crossingHour: 8,
|
||||
energyToCoverKwh: 0.82,
|
||||
requiredSocPct: 27.5,
|
||||
requiredSocPct: 30.5,
|
||||
gridNeeded: true,
|
||||
prudentRequiredSocPct: 38.1,
|
||||
prudentRequiredSocPct: 41,
|
||||
deficitTrustworthy: true,
|
||||
},
|
||||
},
|
||||
@@ -75,7 +75,7 @@ export const balanceScenarios = [
|
||||
expect: {
|
||||
usable: true,
|
||||
gridNeeded: false,
|
||||
requiredSocPct: 27.5,
|
||||
requiredSocPct: 30.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -125,8 +125,8 @@ export const balanceScenarios = [
|
||||
nextDeficitEnergyKwh: 12.77,
|
||||
// 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.
|
||||
nextDeficitRequiredSocPct: 107.1,
|
||||
storageShortfallKwh: 1.07,
|
||||
nextDeficitRequiredSocPct: 102.2,
|
||||
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
|
||||
// sur le profil prudent, soit SOC 38,1 %.
|
||||
stormReserveKwh: 2.42,
|
||||
stormReserveSocPct: 38.1,
|
||||
stormReserveSocPct: 41,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -216,11 +216,11 @@ export const balanceScenarios = [
|
||||
stormLevel: 'orange',
|
||||
stormActive: true,
|
||||
stormReserveKwh: 2.42,
|
||||
stormReserveSocPct: 38.1,
|
||||
stormReserveSocPct: 41,
|
||||
stormCovered: false,
|
||||
// SOC 21 contre 38,1 requis : il manque 2,57 kWh pour tenir la matinée
|
||||
// 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
|
||||
// réseau. C'est le signal de dimensionnement, il ne doit pas être écrêté.
|
||||
stormReserveKwh: 12.77,
|
||||
stormReserveSocPct: 107.1,
|
||||
stormReserveSocPct: 102.2,
|
||||
stormCovered: false,
|
||||
stormShortfallKwh: 1.82,
|
||||
stormShortfallKwh: 1.22,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
72
tools/mesure-courbe-batterie.mjs
Normal file
72
tools/mesure-courbe-batterie.mjs
Normal 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
Reference in New Issue
Block a user