Files
EVCS/tools/gen-page-batteries.py
Tallagrand Thierry 7c2813a60a Page de decision : dimensionnement du parc batterie
Generateur rejouable (tools/gen-page-batteries.py) plutot qu'un HTML ecrit a la
main : les chiffres viennent des mesures et se recalculent.

Base : 37 nuits completes issues des statistiques long terme de Home Assistant
(fin juillet au 4 septembre, maison seule, voiture deduite) et la courbe
energie/SOC etablie sur 576 paliers.

Le chiffre qui cadre la decision : les 7 modules actifs delivrent 10,7 kWh a la
maison, la nuit mediane en demande 13,2 — la batterie passe la nuit seule
1 fois sur 37. Reparer le module en panne porte cela a 5/37 seulement ; deux
modules de plus le portent a 32/37. Reparer seul ne resout rien.

Second point, moins attendu : au-dela de la consommation nocturne le stockage
ne cycle plus. Le surplus de milieu de journee est un probleme de preneur, pas
de stockage — la voiture absorbe deja 9,8 kWh/jour en mediane.

Reserve inscrite en clair : toutes les mesures sont d'ete, les compteurs
exploitables ne remontant qu'a fin juillet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-09-04 20:15:44 +02:00

536 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Page de decision : dimensionnement du parc batterie.
Rejouable. Toutes les valeurs viennent des mesures du 04/09/2026 :
37 nuits completes (statistiques long terme Home Assistant, juillet-septembre)
et la courbe energie/SOC etablie sur 576 paliers.
"""
import json, pathlib
NUITS = [10.2,11,11.2,11.6,11.9,12.4,12.4,12.5,12.5,12.6,12.6,12.7,12.8,12.9,
12.9,12.9,13.1,13.1,13.2,13.4,13.4,13.5,13.6,13.7,13.7,13.7,13.9,13.9,
14.2,14.4,15,15.2,15.6,15.9,16.4,16.7,17.2]
# kWh utiles par module, mesures. 1,65 dans la fenetre reelle (blocage a 94 %,
# plancher ESS a 20 %) ; 1,77 si le blocage a 94 % disparait avec le vieux pack.
BRIDE, LIBRE = 1.65, 1.77
ONDULEUR = 0.93 # rendement de restitution vers la maison
SCENARIOS = [
("Actuel", "7 modules actifs — un en panne, un qui faiblit", 7, BRIDE, "etat"),
("A", "Réparer le module en panne — 8 modules", 8, BRIDE, "faible"),
("B", "A + 2 neufs — 10 modules", 10, BRIDE, "bon"),
("C", "A + 4 neufs — 12 modules", 12, BRIDE, "bon"),
("D", "4 récents + 6 neufs, vieux pack retiré", 10, LIBRE, "bon"),
]
TRANCHES = [("21-31", 0.097, 88), ("35-44", 0.171, 78), ("45-54", 0.169, 76),
("55-64", 0.163, 85), ("65-74", 0.171, 80), ("75-84", 0.170, 81),
("85-94", 0.169, 62)]
JOURNEE = [("00",0.00,0,0,0),("01",0.00,1.88,1.57,1.30),("02",0.00,2.83,2.49,1.83),
("03",0.00,0.03,0.64,0),("04",0.00,0.02,0.69,0),("05",0.00,0.02,0.67,0),
("06",0.00,1.36,0.80,0),("07",0.02,0.72,0.67,0),("08",0.35,0.87,0.83,0),
("09",0.78,0.38,0.83,0),("10",2.18,0.16,1.23,0),("11",4.20,0.02,1.27,0),
("12",2.34,0.00,1.22,0),("13",3.51,0.00,2.99,0.96),("14",3.98,0.00,2.29,0.14),
("15",3.44,0.00,1.98,0),("16",3.59,0.00,1.87,0),("17",1.33,0.00,0.67,0),
("18",0.86,0.00,0.76,0.07),("19",0.12,0.00,0.12,0.09)]
def couvre(ac):
return sum(1 for n in NUITS if n <= ac + 1e-9)
def calc():
out = []
for code, nom, mods, par, ton in SCENARIOS:
dc = mods * par
ac = dc * ONDULEUR
n = couvre(ac)
out.append(dict(code=code, nom=nom, mods=mods, dc=dc, ac=ac,
n=n, pct=round(100 * n / len(NUITS)), ton=ton))
return out
# ----------------------------------------------------------------- graphiques
def histo_nuits(sc):
"""Repartition des 37 nuits, avec les seuils de chaque scenario."""
x0, x1, lo, hi = 74, 940, 9.5, 19.2
px = lambda v: x0 + (v - lo) / (hi - lo) * (x1 - x0)
bins = {}
for v in NUITS:
b = int((v - lo) // 0.5)
bins[b] = bins.get(b, 0) + 1
mx = max(bins.values())
base, hmax = 250, 168
s = [f'<svg viewBox="0 0 1000 330" role="img" aria-label="Répartition des 37 nuits">']
# grille
for v in range(10, 20):
s.append(f'<line class="grille" x1="{px(v):.0f}" y1="46" x2="{px(v):.0f}" y2="{base}"/>')
s.append(f'<text class="axe" x="{px(v):.0f}" y="{base+24}" text-anchor="middle">{v}</text>')
s.append(f'<text class="axe unite" x="{x1}" y="{base+48}" text-anchor="end">kWh consommés par la maison, de 20 h à 9 h</text>')
for b, n in sorted(bins.items()):
g = lo + b * 0.5
w = (px(g + 0.5) - px(g)) - 3
h = n / mx * hmax
s.append(f'<rect class="barre-nuit" x="{px(g)+1.5:.1f}" y="{base-h:.1f}" '
f'width="{w:.1f}" height="{h:.1f}" rx="2"><title>{n} nuit(s) entre '
f'{g:.1f} et {g+0.5:.1f} kWh</title></rect>')
s.append(f'<text class="compte" x="{px(g+0.25):.0f}" y="{base-h-7:.0f}" text-anchor="middle">{n}</text>')
# seuils
for i, r in enumerate(sc):
if r['ac'] > hi or r['ac'] < lo: continue
x = px(r['ac'])
cl = 'seuil-etat' if r['ton'] == 'etat' else 'seuil'
s.append(f'<line class="{cl}" x1="{x:.0f}" y1="34" x2="{x:.0f}" y2="{base}"/>')
anc = 'end' if r['ac'] > 17.4 else 'start'
dx = -7 if anc == 'end' else 7
y = 34 + 15 + (i % 2) * 17
s.append(f'<text class="etiq {cl}" x="{x+dx:.0f}" y="{y}" text-anchor="{anc}">'
f'{r["code"]} · {r["ac"]:.1f}'.replace(".", ",") + '</text>')
s.append('</svg>')
return '\n'.join(s)
def courbe_batterie():
x0, x1, base, hmax = 96, 940, 236, 156
n = len(TRANCHES)
w = (x1 - x0) / n
mx = 0.18
s = ['<svg viewBox="0 0 1000 320" role="img" aria-label="Énergie par point de SOC">']
for v, lab in [(0.05,'0,05'), (0.10,'0,10'), (0.15,'0,15')]:
y = base - v / mx * hmax
s.append(f'<line class="grille" x1="{x0}" y1="{y:.0f}" x2="{x1}" y2="{y:.0f}"/>')
s.append(f'<text class="axe" x="{x0-12}" y="{y+4:.0f}" text-anchor="end">{lab}</text>')
for i, (lab, k, np_) in enumerate(TRANCHES):
h = k / mx * hmax
x = x0 + i * w + 9
cl = 'barre-bas' if k < 0.12 else 'barre-haut'
s.append(f'<rect class="{cl}" x="{x:.0f}" y="{base-h:.0f}" width="{w-18:.0f}" '
f'height="{h:.0f}" rx="3"><title>{np_} paliers mesurés</title></rect>')
s.append(f'<text class="val" x="{x+(w-18)/2:.0f}" y="{base-h-10:.0f}" text-anchor="middle">'
f'{k:.3f}".replace"</text>'.replace('".replace"', '').replace('.', ','))
s.append(f'<text class="axe" x="{x+(w-18)/2:.0f}" y="{base+24:.0f}" text-anchor="middle">{lab} %</text>')
xg = x0 + 1 * w
s.append(f'<line class="genou" x1="{xg:.0f}" y1="40" x2="{xg:.0f}" y2="{base}"/>')
s.append(f'<text class="etiq genou" x="{xg+9:.0f}" y="54">genou à 33 %</text>')
s.append(f'<text class="axe unite" x="{x1}" y="{base+52}" text-anchor="end">kWh délivrés par point de SOC · 576 paliers mesurés sur 8 jours</text>')
s.append('</svg>')
return '\n'.join(s)
def journee():
x0, x1, base, hmax = 74, 950, 226, 150
n = len(JOURNEE)
w = (x1 - x0) / n
mx = 4.3
s = ['<svg viewBox="0 0 1000 300" role="img" aria-label="Énergie heure par heure">']
for v in (1, 2, 3, 4):
y = base - v / mx * hmax
s.append(f'<line class="grille" x1="{x0}" y1="{y:.0f}" x2="{x1}" y2="{y:.0f}"/>')
s.append(f'<text class="axe" x="{x0-10}" y="{y+4:.0f}" text-anchor="end">{v}</text>')
for i, (h, sol, res, mai, voi) in enumerate(JOURNEE):
x = x0 + i * w
bw = (w - 8) / 2
for j, (v, cl) in enumerate([(sol, 'sol'), (res, 'res')]):
if v <= 0: continue
ht = v / mx * hmax
s.append(f'<rect class="b-{cl}" x="{x+3+j*bw:.1f}" y="{base-ht:.1f}" '
f'width="{bw-1.5:.1f}" height="{ht:.1f}" rx="1.5"><title>{h} h · {v} kWh</title></rect>')
if i % 2 == 0:
s.append(f'<text class="axe" x="{x+w/2:.0f}" y="{base+22:.0f}" text-anchor="middle">{h}</text>')
pts = ' '.join(f'{x0+i*w+w/2:.1f},{base-m/mx*hmax:.1f}' for i,(_,_,_,m,_) in enumerate(JOURNEE))
s.append(f'<polyline class="ligne-mai" points="{pts}"/>')
for i, (h, _, _, mai, _) in enumerate(JOURNEE):
y = base - mai / mx * hmax
s.append(f'<circle class="pt-mai" cx="{x0+i*w+w/2:.1f}" cy="{y:.1f}" r="3.4">'
f'<title>{h} h · maison {mai} kWh</title></circle>')
s.append(f'<text class="axe unite" x="{x1}" y="{base+50}" text-anchor="end">kWh par heure · jeudi 4 septembre 2026</text>')
s.append('</svg>')
return '\n'.join(s)
sc = calc()
act = sc[0]
b = next(r for r in sc if r['code'] == 'B')
c = next(r for r in sc if r['code'] == 'C')
lignes_sc = '\n'.join(f''' <tr class="{r['ton']}">
<td class="code">{r['code']}</td>
<td>{r['nom']}</td>
<td class="n">{r['mods']}</td>
<td class="n">{r['dc']:.1f}</td>
<td class="n">{r['ac']:.1f}</td>
<td class="n forte">{r['n']}<span class="sur">/37</span></td>
<td class="jauge"><span style="--p:{r['pct']}%"></span><em>{r['pct']} %</em></td>
</tr>'''.replace('.', ',').replace(',1f', '.1f') for r in sc)
html = f'''<title>Dimensionner le parc batterie</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700&family=IBM+Plex+Mono:wght@400;600&family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,600;1,6..72,400&display=swap">
<style>
:root {{
--ground:#e7eaee; --surface:#f6f8f9; --surface-2:#dde3e8;
--ink:#101821; --ink-2:#3d4a57; --muted:#6b7885; --rule:#c6cfd7;
--solaire:#c9791a; --solaire-d:#a35f10; --stock:#166b78; --stock-d:#0f4e58;
--reseau:#a8422c; --vert:#3f6b3a;
--mono:"IBM Plex Mono",ui-monospace,"SF Mono",Menlo,monospace;
--titre:"Bricolage Grotesque","Helvetica Neue",Arial,sans-serif;
--corps:"Newsreader",Georgia,"Times New Roman",serif;
}}
@media (prefers-color-scheme: dark) {{
:root:not([data-theme="light"]) {{
--ground:#0d1319; --surface:#141c24; --surface-2:#1d2831;
--ink:#e4eaef; --ink-2:#b3c0cb; --muted:#8695a2; --rule:#2b3945;
--solaire:#e79a3c; --solaire-d:#c47d24; --stock:#3aa5b5; --stock-d:#2b8290;
--reseau:#d9705a; --vert:#7ab070;
}}
}}
:root[data-theme="dark"] {{
--ground:#0d1319; --surface:#141c24; --surface-2:#1d2831;
--ink:#e4eaef; --ink-2:#b3c0cb; --muted:#8695a2; --rule:#2b3945;
--solaire:#e79a3c; --solaire-d:#c47d24; --stock:#3aa5b5; --stock-d:#2b8290;
--reseau:#d9705a; --vert:#7ab070;
}}
* {{ box-sizing:border-box; }}
body {{ background:var(--ground); color:var(--ink); font-family:var(--corps);
font-size:17.5px; line-height:1.62; font-weight:300;
-webkit-font-smoothing:antialiased; padding:0 20px 96px; }}
.page {{ max-width:1040px; margin:0 auto; }}
.col {{ max-width:64ch; }}
h1,h2,h3,.chapeau,.code,.n,.axe,.val,.etiq,.compte,table,.jauge em,.tag,.puce b {{
font-family:var(--titre); }}
h1 {{ font-size:clamp(2.3rem,5.4vw,3.9rem); line-height:1.02; font-weight:700;
letter-spacing:-.028em; text-wrap:balance; margin:0 0 .5rem;
max-width:15ch; }}
.surtitre {{ font-family:var(--mono); font-size:.7rem; letter-spacing:.16em;
text-transform:uppercase; color:var(--muted); margin:56px 0 20px; }}
.chapeau {{ font-family:var(--corps); font-size:1.24rem; line-height:1.55;
color:var(--ink-2); max-width:56ch; font-weight:300; }}
h2 {{ font-size:1.62rem; font-weight:700; letter-spacing:-.018em;
margin:72px 0 4px; text-wrap:balance; }}
h2 + .sous {{ font-family:var(--mono); font-size:.72rem; letter-spacing:.1em;
text-transform:uppercase; color:var(--muted); margin:0 0 22px; }}
h3 {{ font-size:1.06rem; font-weight:700; margin:34px 0 6px; letter-spacing:-.01em; }}
p {{ margin:0 0 1.05em; }}
strong {{ font-weight:600; color:var(--ink); }}
em.i {{ font-style:italic; }}
hr {{ border:0; border-top:1px solid var(--rule); margin:64px 0; }}
a {{ color:var(--stock); }}
/* --- verdict --- */
.verdict {{ background:var(--surface); border:1px solid var(--rule);
border-left:4px solid var(--solaire); border-radius:3px;
padding:26px 30px; margin:38px 0 8px; max-width:66ch; }}
.verdict p:last-child {{ margin-bottom:0; }}
.verdict .gros {{ font-family:var(--titre); font-weight:700;
font-size:1.3rem; line-height:1.3; letter-spacing:-.015em; }}
/* --- planches --- */
.planche {{ background:var(--surface); border:1px solid var(--rule);
border-radius:4px; padding:26px 26px 18px; margin:26px 0 30px; }}
.planche h4 {{ font-family:var(--titre); font-size:.95rem; font-weight:700;
margin:0 0 3px; letter-spacing:-.005em; }}
.planche .note {{ font-size:.88rem; color:var(--muted); margin:0 0 18px;
max-width:64ch; line-height:1.5; }}
svg {{ width:100%; height:auto; display:block; overflow:visible; }}
.grille {{ stroke:var(--rule); stroke-width:1; }}
.axe {{ fill:var(--muted); font-size:12.5px; }}
.axe.unite {{ font-size:11.5px; letter-spacing:.02em; }}
.compte {{ fill:var(--muted); font-size:11px; }}
.val {{ fill:var(--ink-2); font-size:12px; font-weight:500; }}
.barre-nuit {{ fill:var(--stock); opacity:.62; }}
.barre-haut {{ fill:var(--stock); }}
.barre-bas {{ fill:var(--reseau); }}
.genou {{ stroke:var(--reseau); stroke-width:1.5; stroke-dasharray:5 4; }}
text.genou {{ fill:var(--reseau); stroke:none; }}
.seuil {{ stroke:var(--solaire); stroke-width:1.5; stroke-dasharray:4 3; }}
text.seuil {{ fill:var(--solaire-d); stroke:none; }}
.seuil-etat {{ stroke:var(--reseau); stroke-width:2; }}
text.seuil-etat {{ fill:var(--reseau); stroke:none; }}
.etiq {{ font-size:12.5px; font-weight:600; letter-spacing:.01em; }}
.b-sol {{ fill:var(--solaire); }}
.b-res {{ fill:var(--reseau); opacity:.85; }}
.pt-mai {{ fill:var(--ground); stroke:var(--ink-2); stroke-width:1.6; }}
.ligne-mai {{ fill:none; stroke:var(--ink-2); stroke-width:1.6; opacity:.55; }}
.legende {{ display:flex; flex-wrap:wrap; gap:20px; margin-top:14px;
font-family:var(--mono); font-size:.72rem; color:var(--muted); }}
.legende span {{ display:inline-flex; align-items:center; gap:7px; }}
.legende i {{ width:11px; height:11px; border-radius:2px; display:inline-block; }}
/* --- tableau --- */
.enrobe {{ overflow-x:auto; margin:26px 0 10px;
border:1px solid var(--rule); border-radius:4px; background:var(--surface); }}
table {{ border-collapse:collapse; width:100%; min-width:720px; font-size:.9rem; }}
th {{ font-size:.68rem; letter-spacing:.1em; text-transform:uppercase;
color:var(--muted); font-weight:600; text-align:left; padding:15px 14px 11px;
border-bottom:1px solid var(--rule); white-space:nowrap; }}
td {{ padding:13px 14px; border-bottom:1px solid var(--rule); vertical-align:middle; }}
tr:last-child td {{ border-bottom:0; }}
td.n, th.n {{ text-align:right; font-family:var(--mono);
font-variant-numeric:tabular-nums; }}
td.code {{ font-weight:700; font-size:1rem; width:42px; }}
td.forte {{ font-weight:600; font-size:1rem; }}
.sur {{ color:var(--muted); font-size:.78rem; font-weight:400; }}
tr.etat {{ background:color-mix(in srgb, var(--reseau) 9%, transparent); }}
tr.etat td.code {{ color:var(--reseau); font-size:.8rem; font-weight:600; }}
tr.bon td.code {{ color:var(--stock); }}
tr.faible td.code {{ color:var(--muted); }}
.jauge {{ min-width:126px; }}
.jauge span {{ display:block; height:7px; border-radius:4px;
background:var(--surface-2); position:relative; overflow:hidden; }}
.jauge span::after {{ content:""; position:absolute; inset:0 auto 0 0;
width:var(--p); background:var(--stock); border-radius:4px; }}
.jauge em {{ font-style:normal; font-size:.74rem; color:var(--muted);
display:block; margin-top:5px; font-variant-numeric:tabular-nums; }}
/* --- chiffres cles --- */
.cles {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(168px,1fr));
gap:1px; background:var(--rule); border:1px solid var(--rule);
border-radius:4px; overflow:hidden; margin:28px 0; }}
.cles div {{ background:var(--surface); padding:20px 20px 18px; }}
.cles b {{ font-family:var(--titre); font-size:2.1rem; font-weight:700;
display:block; line-height:1; letter-spacing:-.03em; }}
.cles b small {{ font-size:.9rem; font-weight:500; color:var(--muted);
letter-spacing:0; }}
.cles span {{ font-size:.84rem; color:var(--muted); display:block; margin-top:8px;
line-height:1.42; }}
/* --- listes --- */
ol.etapes {{ list-style:none; counter-reset:e; padding:0; margin:26px 0; }}
ol.etapes li {{ counter-increment:e; position:relative; padding:0 0 22px 46px; }}
ol.etapes li::before {{ content:counter(e); position:absolute; left:0; top:1px;
font-family:var(--titre); font-weight:700; font-size:.82rem;
width:28px; height:28px; border-radius:50%; background:var(--stock);
color:var(--surface); display:grid; place-items:center; }}
ol.etapes b {{ font-family:var(--titre); font-weight:700; }}
ul.puce {{ list-style:none; padding:0; margin:18px 0; }}
ul.puce li {{ padding:0 0 13px 22px; position:relative; }}
ul.puce li::before {{ content:""; position:absolute; left:2px; top:.72em;
width:6px; height:6px; border-radius:50%; background:var(--stock); }}
.tag {{ font-family:var(--mono); font-size:.66rem; letter-spacing:.09em;
text-transform:uppercase; padding:3px 8px; border-radius:3px;
background:var(--surface-2); color:var(--ink-2); white-space:nowrap; }}
.reserve {{ border-left:3px solid var(--rule); padding-left:22px;
color:var(--ink-2); font-size:.97rem; margin:26px 0; max-width:62ch; }}
.reserve strong {{ color:var(--ink); }}
footer {{ margin-top:78px; padding-top:26px; border-top:1px solid var(--rule);
font-family:var(--mono); font-size:.72rem; color:var(--muted); line-height:1.8; }}
</style>
<div class="page">
<p class="surtitre">Installation Airtria · 4 septembre 2026</p>
<h1>Dimensionner le parc batterie</h1>
<p class="chapeau col">Combien de stockage faut-il pour traverser la nuit sans réseau —
et à partir de quel point chaque module supplémentaire cesse de servir à quelque chose.</p>
<div class="verdict">
<p class="gros">Aujourd'hui, la batterie passe la nuit seule 1 fois sur 37.</p>
<p>Les 7 modules actifs délivrent {act['ac']:.1f}&nbsp;kWh à la maison. La nuit médiane
en demande <strong>13,2</strong>, et la plus lourde observée <strong>17,2</strong>.
Le réseau n'est pas un confort : c'est de l'arithmétique.</p>
</div>
<div class="cles">
<div><b>13,2<small> kWh</small></b><span>nuit médiane, maison seule, de 20&nbsp;h à 9&nbsp;h</span></div>
<div><b>15,9<small> kWh</small></b><span>9 nuits sur 10 restent en dessous</span></div>
<div><b>1,65<small> kWh</small></b><span>délivré par module, mesuré — pas 2,4 de plaque</span></div>
<div><b>37</b><span>nuits complètes mesurées, juillet à septembre</span></div>
</div>
<h2>Ce que la nuit demande</h2>
<p class="sous">37 nuits complètes · voiture déduite</p>
<div class="col">
<p>Chaque barre compte les nuits dont la consommation tombe dans une tranche
d'un demi-kilowattheure. La distribution est <strong>serrée</strong> : les deux
tiers des nuits tiennent entre 12,4 et 14,4&nbsp;kWh. C'est une bonne nouvelle
pour dimensionner — il n'y a pas de queue lointaine à couvrir.</p>
<p>Les traits verticaux marquent ce que chaque scénario sait délivrer. Tout ce
qui est <em class="i">à gauche</em> du trait est couvert.</p>
</div>
<div class="planche">
<h4>Répartition des nuits, et ce que chaque scénario couvre</h4>
<p class="note">Consommation de la maison seule entre 20&nbsp;h et 9&nbsp;h, la
charge du véhicule soustraite. Source : statistiques long terme de Home Assistant,
fin juillet au 4 septembre 2026.</p>
{histo_nuits(sc)}
<div class="legende">
<span><i style="background:var(--stock);opacity:.62"></i>nuits mesurées</span>
<span><i style="background:var(--reseau)"></i>parc actuel</span>
<span><i style="background:var(--solaire)"></i>seuil d'un scénario</span>
</div>
</div>
<h2>Ce qu'un module délivre vraiment</h2>
<p class="sous">576 paliers de SOC mesurés</p>
<div class="col">
<p>Un US2000C annonce 2,4&nbsp;kWh. Ce n'est pas le chiffre à utiliser. En
intégrant la puissance sur chaque palier où le SOC descend d'un point — 576
paliers sur huit jours, en écartant ceux où le réseau apportait quoi que ce
soit — la réalité de <em class="i">ce</em> parc apparaît :</p>
</div>
<div class="planche">
<h4>Énergie délivrée par point de SOC</h4>
<p class="note">Plate à 0,169 kWh de 33 à 93 %, sur six tranches indépendantes.
Puis un genou net : en dessous de 33 %, un point de SOC ne vaut plus que
<strong>57&nbsp;%</strong> de ce qu'il vaut en haut d'échelle. Signature
classique d'un parc vieillissant — cohérent avec un module en panne sur huit
et un autre qui faiblit.</p>
{courbe_batterie()}
</div>
<div class="col">
<p>Dans la fenêtre réellement exploitée — le blocage de charge à 94&nbsp;% en
haut, le plancher ESS à 20&nbsp;% en bas — les 7 modules actifs contiennent
<strong>11,6&nbsp;kWh</strong>, soit <strong>1,65&nbsp;kWh par module</strong>.
Après restitution par l'onduleur, {act['ac']:.1f}&nbsp;kWh arrivent à la maison.</p>
</div>
<h2>Les scénarios</h2>
<p class="sous">colonne décisive : le nombre de nuits couvertes</p>
<div class="enrobe">
<table>
<thead><tr>
<th></th><th>Scénario</th><th class="n">Modules</th>
<th class="n">Utile&nbsp;DC</th><th class="n">Livré&nbsp;AC</th>
<th class="n">Nuits</th><th>Couverture</th>
</tr></thead>
<tbody>
{lignes_sc}
</tbody>
</table>
</div>
<div class="col">
<p>Le saut décisif est entre <strong>A</strong> et <strong>B</strong> : réparer
le module en panne ne fait passer la couverture que de 1 à 5 nuits sur 37.
Deux modules de plus la portent à 32. <strong>Réparer seul ne résout rien.</strong></p>
<p>Le scénario <strong>D</strong> mérite un regard : mêmes 10 modules que B, mais
le vieux groupe retiré. Le blocage de charge à 94&nbsp;% disparaît avec lui, et
la même quantité de matériel délivre 1,1&nbsp;kWh de plus.</p>
</div>
<h2>Le plafond que personne ne mentionne</h2>
<p class="sous">au-delà de la nuit, le stockage ne tourne plus</p>
<div class="col">
<p>Du stockage au-delà de la consommation nocturne <strong>ne cycle pas</strong>.
Il se remplit, il attend, et le lendemain midi il est déjà plein — donc il
n'absorbe rien. La nuit demande 13 à 17&nbsp;kWh : au-delà d'environ douze
modules, en été, chaque module supplémentaire est un investissement qui dort.</p>
<p>La journée du 4 septembre le montre bien. Le réseau se tait à midi et ne
revient jamais ; la production dépasse largement la maison tout l'après-midi ;
et pourtant la batterie sature avant 18&nbsp;h.</p>
</div>
<div class="planche">
<h4>Une journée entière — jeudi 4 septembre</h4>
<p class="note">Le réseau n'intervient qu'avant midi, pendant les heures creuses
et la reprise du plancher ESS. À partir de 11&nbsp;h la production dépasse la
maison de plusieurs kilowattheures par heure. Le surplus de l'après-midi n'a
nulle part où aller.</p>
{journee()}
<div class="legende">
<span><i style="background:var(--solaire)"></i>production solaire</span>
<span><i style="background:var(--reseau);opacity:.85"></i>réseau importé</span>
<span><i style="background:var(--ink-2);opacity:.55"></i>maison</span>
</div>
</div>
<div class="col">
<p>Le lendemain est annoncé à <strong>57,4&nbsp;kWh</strong>. En face : une maison
à 30, une batterie qui peut en prendre 8, et pas de voiture — elle part à 8&nbsp;h&nbsp;50.
<strong>Une trentaine de kilowattheures seront refusés</strong>, et même douze
modules n'y changeraient presque rien.</p>
<p>Le surplus de milieu de journée n'est pas un problème de stockage. C'est un
problème de preneur. La voiture absorbe déjà 9,8&nbsp;kWh par jour en médiane :
c'est le meilleur rendement de la liste, et il ne coûte rien.</p>
</div>
<h2>Le point dur : les quatre vieux modules</h2>
<p class="sous">là où se joue vraiment la décision</p>
<div class="col">
<p>Un module mort, un autre qui faiblit, et un pack qui <strong>bloque la charge
à 94&nbsp;%</strong> — tous dans le groupe le plus ancien. Or dans un groupe
Pylontech en parallèle, le BMS gouverne l'ensemble : <strong>le pack faible impose
sa loi aux modules neufs</strong>.</p>
<p>Ajouter des modules neufs derrière quatre modules fatigués, c'est les faire
vieillir au rythme des anciens et hériter du plafond de 94&nbsp;%. L'observation
du propriétaire va dans le même sens : rebrancher le module isolé ferait
peut-être <em class="i">descendre</em> le blocage à 85&nbsp;%.</p>
<p>Ce n'est pas un module à récupérer. C'est un groupe à trancher.</p>
</div>
<h3>Réparation, garantie, reprise</h3>
<div class="col">
<p><span class="tag">à confirmer auprès du distributeur</span></p>
<ul class="puce">
<li><b>Lire les logs, oui.</b> L'outil est <strong>BatteryView</strong>, de
Pylontech, sur le port console RS232 du module : historique d'alarmes, tensions
par cellule, nombre de cycles. C'est ce qui distingue une <em class="i">cellule
dérivante</em> — souvent récupérable par plusieurs charges lentes et complètes
qui laissent le BMS équilibrer — d'un <em class="i">verrouillage par décharge
profonde</em>, que certains distributeurs relancent par une source externe à
faible courant.</li>
<li><b>Réparation : non.</b> Le BMS est intégré et le module scellé. Un
remplacement de cellules par un atelier tiers annule la garantie.</li>
<li><b>Garantie : c'est la vraie piste.</b> Les US2000C sont couverts une dizaine
d'années sous conditions, et le recours passe par le <strong>distributeur</strong>,
pas par Pylontech. Avec les logs, le nombre de cycles et six mois de traçabilité
d'usage, le dossier est défendable.</li>
<li><b>Reprise : aucun programme connu.</b> La fin de vie relève de la filière
DEEE — du recyclage, pas de la valeur.</li>
<li><b>À vérifier avant de commander :</b> l'US2000C est-il encore commercialisé ?
S'il ne l'est plus, les scénarios B et C tombent et la question devient celle
d'un parc homogène neuf.</li>
</ul>
</div>
<h2>Ce que je ferais</h2>
<p class="sous">dans cet ordre, sans tout décider aujourd'hui</p>
<ol class="etapes col">
<li><b>Faire lire les logs des quatre vieux modules</b>, pas seulement celui en
panne. Vous saurez si vous avez un module à remplacer ou un groupe à retirer —
et cela ne coûte rien.</li>
<li><b>Brancher le second véhicule</b> les jours de forte production. Vous saurez
en une journée combien de kilowattheures un second preneur absorbe réellement,
et ce chiffre déplace le dimensionnement.</li>
<li><b>Viser 10 à 12 modules équivalents neufs</b>, soit 16 à 20&nbsp;kWh utiles.
C'est la plage qui couvre 85 à 100&nbsp;% des nuits d'été et qui cycle tous les jours.</li>
<li><b>Si les logs condamnent le vieux groupe, ne pas rafistoler.</b> Un parc
homogène neuf donne la même capacité en moins d'unités, avec une garantie neuve
et un plafond de charge qui ne vous suit plus.</li>
</ol>
<div class="reserve">
<p><strong>Ces chiffres sont tous d'été.</strong> Les compteurs exploitables ne
remontent qu'à fin juillet. L'hiver, la nuit dure 17 heures au lieu de 13 et la
production ne recharge pas : aucun dimensionnement raisonnable ne fera passer un
hiver breton sans réseau, et la stratégie des deux charges en heures creuses
restera la bonne.</p>
<p><strong>Dimensionnez pour vos nuits d'été, pas contre l'hiver.</strong> C'est
en été que le stockage travaille tous les jours, et c'est en été que chaque
kilowattheure stocké est gratuit.</p>
</div>
<footer>
Sources : 37 nuits complètes et 38 journées issues des statistiques long terme de
Home Assistant (fin juillet – 4 septembre 2026) · courbe énergie/SOC établie sur
576 paliers mesurés · relevé horaire du 4 septembre issu de la base
d'historisation.<br>
Capacité par module mesurée sur le parc réel, blocage de charge à 94 % et
plancher ESS à 20 % inclus. Rendement de restitution retenu : 93 %.
</footer>
</div>
'''
p = pathlib.Path('docs/pages/dimensionnement-batteries.html')
p.write_text(html, encoding='utf-8')
print(f"{p} — {len(html)} caracteres")
for r in sc:
print(f" {r['code']:<12} {r['mods']:>2} modules {r['dc']:>5.1f} DC {r['ac']:>5.1f} AC {r['n']:>2}/37 {r['pct']:>3} %")