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** |
|
| 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*
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
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