Files
EVCS/tools/gen-rapport-batteries.py
Tallagrand Thierry e6405d6b49 Le rapport en trois langues, avec ce qu'on demande a Pylontech
Le generateur separe desormais les MESURES des MOTS : les chiffres
vivent dans gen-rapport-batteries.py, les trois langues dans
rapport_textes.py. Une correction de traduction ne peut donc pas
toucher un chiffre, ni l'inverse - et les trois versions partagent
exactement les memes donnees puisqu'elles lisent la meme source.

La version destinee au fabricant gagne une section que la francaise
n'avait pas : trois questions precises, sur la recuperabilite des
cellules face a leur composante resistive, sur la procedure
recommandee pour un module dans cet etat, et sur ce que compte
exactement le SOHCount par cellule.

Le chinois charge Noto Sans SC et desserre l'interligne ; les mesures
de colonne passent en em plutot qu'en ch, un caractere han n'ayant
pas la largeur d'un zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKzkkVTG6KXnGXFFC7X9XJ
2026-09-05 17:23:19 +02:00

239 lines
12 KiB
Python

# -*- coding: utf-8 -*-
"""Genere le rapport sur l'etat du parc batterie, en trois langues.
Les MESURES vivent ici, les MOTS dans tools/rapport_textes.py. Une correction
de traduction ne peut donc jamais toucher un chiffre, ni l'inverse.
python3 tools/gen-rapport-batteries.py
"""
import importlib.util, pathlib
_sp = importlib.util.spec_from_file_location(
"rt", str(pathlib.Path(__file__).with_name("rapport_textes.py")))
rt = importlib.util.module_from_spec(_sp); _sp.loader.exec_module(rt)
MAJ = {"fr": "5 septembre 2026, 17 h", "en": "5 September 2026, 17:00",
"zh": "2026 年 9 月 5 日 17:00"}
# Amplitude min->max de chaque cellule, relevee sur l'historique du BMS. C'est
# LA grandeur qui separe une cellule saine d'une cellule morte sans demontage.
CODES = ["K220803C30160323", "K220803C30160322", "K220803C30160319", "K220803C30160324"]
AMPL = [
[191,188,188,189,190,197,187,190,189,192,191,188,187,192,198],
[182,178,178,178,177,548,371,181,179,179,182,691,178,273,178],
[183,521,184,186,182,318,183,186,184,473,184,443,184,188,424],
[138,124,138,137,139,132,134,142,143,135,136,137,128,135,683],
]
# code-barres, pack, adresse, nb atteintes, lesquelles, HV, OV, ecart median, etat
PARC = [
("K220803C30160322","a","1","4","6 · 7 · 12 · 14", 498, 86,108,"s"),
("K220803C30160323","a","2","0","—", 0, 0, 6,"s"),
("K220803C30160319","a","3","5","2 · 6 · 10 · 12 · 15", 416,179, 45,"s"),
("K220803C30160324","a","4","1","15", 29, 59, 57,"i"),
("Y230202C30371426","r","1","0","—", 1, 1, 2,"s"),
("Y230202C30371425","r","2","0","—", 1, 1, 2,"s"),
("Y230202C30371431","r","3","0","—", 1, 1, 2,"s"),
("Y230202C30371427","r","4","0","—", 1, 0, 2,"s"),
]
def couleur(a):
return ("var(--mort)" if a >= 600 else "var(--grave)" if a >= 400
else "var(--attention)" if a >= 250 else "var(--sain)")
def figure(lang):
LARG, LIGNE, GAUCHE, HAUT, ech = 760, 15, 96, 26, 0.86
out, y = [], 0
for code, place, nrec, amps in zip(CODES, rt.PLACES[lang], rt.RELEVES[lang], AMPL):
out.append(f'<text x="0" y="{y+13}" class="fm">{code[-4:]}</text>'
f'<text x="0" y="{y+25}" class="fp">{place}</text>')
y += HAUT
for i, a in enumerate(amps, 1):
l = a * ech
out.append(f'<text x="{GAUCHE-8}" y="{y+10}" class="fc">{i}</text>'
f'<rect x="{GAUCHE}" y="{y+2}" width="{l:.1f}" height="10" rx="2" fill="{couleur(a)}"/>'
f'<text x="{GAUCHE+l+6}" y="{y+11}" class="fv">{a}</text>')
y += LIGNE
y += 14
rep = []
for mv in (200, 400, 600):
x = GAUCHE + mv * ech
rep.append(f'<line x1="{x}" y1="0" x2="{x}" y2="{y-14}" class="rep"/>'
f'<text x="{x}" y="{y-2}" class="fr">{mv} mV</text>')
return (f'<svg viewBox="0 0 {LARG} {y+4}" role="img">' + "".join(rep) + "".join(out) + '</svg>')
def page(lang):
t = rt.T[lang]
cjk = lang == "zh"
police = ('"Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif' if cjk
else '"Source Serif 4", Georgia, serif')
lien = ("https://fonts.googleapis.com/css2?family=Chivo:wght@400;700;900"
"&family=Noto+Sans+SC:wght@400;500;700&family=JetBrains+Mono:wght@400;700&display=swap"
if cjk else
"https://fonts.googleapis.com/css2?family=Chivo:wght@400;700;900"
"&family=Source+Serif+4:opsz,wght@8..60,400;8..60,600"
"&family=JetBrains+Mono:wght@400;700&display=swap")
titre_pol = ('"Noto Sans SC", sans-serif' if cjk else 'Chivo, "Helvetica Neue", Arial, sans-serif')
verdict = "".join(f'<div><div class="n">{n}</div><div class="l">{l}</div></div>'
for n, l in t["v"])
parc = "".join(
f'<tr class="{"mal" if int(n) else ""}"><td class="mono">{c}</td>'
f'<td>{rt.PACKS[lang][p]}</td><td class="num">{a}</td><td class="num gros">{n}</td>'
f'<td class="cel">{d}</td><td class="num">{med}</td><td class="num">{hv}</td>'
f'<td class="num">{ov}</td><td class="etat">{rt.ETATS[lang][e]}</td></tr>'
for c, p, a, n, d, hv, ov, med, e in PARC)
chrono = "".join(f'<tr><td class="mono h">{h}</td><td>{q}</td><td class="note">{x}</td></tr>'
for h, q, x in rt.CHRONO_L[lang])
seuils = "".join(f'<li><span class="mono">{mv} mV</span> {nom}</li>'
for mv, nom in rt.SEUILS_L[lang])
ths = "".join(f"<th>{x}</th>" for x in t["th"])
thc = "".join(f"<th>{x}</th>" for x in t["thc"])
leg = "".join(f'<span><i class="pastille" style="background:var(--{v})"></i>{n}</span>'
for v, n in zip(("sain","attention","grave","mort"), t["leg"]))
quest = "".join(f"<li>{q}</li>" for q in t["q"])
return f"""<title>{t['titre']}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="{lien}">
<style>
:root {{
--fond:#F6F8F7; --carte:#FFFFFF; --encre:#14191B; --sourd:#5B6669;
--trait:#DCE2E1; --trait-fort:#B9C3C1;
--sain:#2E7D62; --attention:#A8760C; --grave:#C2521E; --mort:#9B1D20;
--lueur:rgba(46,125,98,.08);
}}
@media (prefers-color-scheme: dark) {{
:root:not([data-theme="light"]) {{
--fond:#0E1112; --carte:#161A1B; --encre:#E7EBEA; --sourd:#97A2A4;
--trait:#252B2C; --trait-fort:#3A4344;
--sain:#5BB894; --attention:#D9A63C; --grave:#E8794A; --mort:#E05C60;
--lueur:rgba(91,184,148,.10);
}}
}}
:root[data-theme="dark"] {{
--fond:#0E1112; --carte:#161A1B; --encre:#E7EBEA; --sourd:#97A2A4;
--trait:#252B2C; --trait-fort:#3A4344;
--sain:#5BB894; --attention:#D9A63C; --grave:#E8794A; --mort:#E05C60;
--lueur:rgba(91,184,148,.10);
}}
* {{ box-sizing:border-box; }}
body {{ background:var(--fond); color:var(--encre);
font:400 17px/{1.75 if cjk else 1.62} {police}; margin:0; -webkit-font-smoothing:antialiased; }}
.page {{ max-width:1000px; margin:0 auto; padding:0 24px 96px; }}
.mesure {{ max-width:{'46em' if cjk else '64ch'}; }}
h1, h2, h3, .fm, .etiq {{ font-family:{titre_pol}; }}
h1 {{ font-weight:900; font-size:clamp(2.2rem,6vw,3.7rem); line-height:1.08;
letter-spacing:{'-.01em' if cjk else '-.025em'}; margin:0 0 .4em; text-wrap:balance; }}
h2 {{ font-weight:700; font-size:1.5rem; margin:4.2rem 0 .2em; text-wrap:balance; }}
h2 .num {{ color:var(--sourd); font-weight:400; margin-right:.5em;
font-variant-numeric:tabular-nums; }}
p {{ margin:0 0 1.05em; }}
strong, b {{ font-weight:{700 if cjk else 600}; }}
.mono {{ font-family:"JetBrains Mono", ui-monospace, monospace; font-size:.86em;
font-variant-numeric:tabular-nums; }}
header {{ padding:72px 0 8px; border-bottom:1px solid var(--trait); margin-bottom:2.5rem; }}
.etiq {{ font-size:.72rem; font-weight:700; letter-spacing:.14em;
text-transform:uppercase; color:var(--sourd); margin-bottom:1.6rem; }}
.chapeau {{ font-size:1.2rem; line-height:1.55; color:var(--sourd);
max-width:{'40em' if cjk else '56ch'}; margin-bottom:2rem; }}
.maj {{ font-size:.8rem; color:var(--sourd); padding-bottom:1.4rem; }}
.verdict {{ display:grid; gap:1px; background:var(--trait);
grid-template-columns:repeat(auto-fit,minmax(155px,1fr));
border:1px solid var(--trait); border-radius:10px; overflow:hidden; margin:2rem 0 0; }}
.verdict div {{ background:var(--carte); padding:18px 20px; }}
.verdict .n {{ font-family:{titre_pol}; font-weight:900; font-size:2.3rem;
line-height:1; font-variant-numeric:tabular-nums; }}
.verdict .l {{ font-size:.86rem; color:var(--sourd); margin-top:.45em; line-height:1.35; }}
figure {{ margin:2rem 0; }}
.cadre {{ background:var(--carte); border:1px solid var(--trait); border-radius:10px;
padding:22px 20px; overflow-x:auto; }}
svg {{ display:block; min-width:640px; width:100%; height:auto; }}
.fm {{ font-size:12.5px; font-weight:700; fill:var(--encre); }}
.fp {{ font-size:10.5px; fill:var(--sourd); font-family:{police}; }}
.fc {{ font-size:9.5px; fill:var(--sourd); text-anchor:end; font-family:"JetBrains Mono",monospace; }}
.fv {{ font-size:9.5px; fill:var(--sourd); font-family:"JetBrains Mono",monospace; }}
.fr {{ font-size:9.5px; fill:var(--sourd); text-anchor:middle; font-family:"JetBrains Mono",monospace; }}
.rep {{ stroke:var(--trait-fort); stroke-width:1; stroke-dasharray:2 4; }}
figcaption {{ font-size:.88rem; color:var(--sourd); margin-top:.9rem;
max-width:{'44em' if cjk else '62ch'}; line-height:1.65; }}
.tabl {{ overflow-x:auto; margin:1.6rem 0; border:1px solid var(--trait);
border-radius:10px; background:var(--carte); }}
table {{ border-collapse:collapse; width:100%; min-width:680px;
font-family:{police}; font-size:.94rem; }}
th {{ font-family:{titre_pol}; font-size:.7rem; font-weight:700;
letter-spacing:.08em; {'' if cjk else 'text-transform:uppercase;'} color:var(--sourd);
text-align:left; padding:13px 14px; border-bottom:1px solid var(--trait); }}
td {{ padding:11px 14px; border-bottom:1px solid var(--trait); vertical-align:top; }}
tr:last-child td {{ border-bottom:none; }}
tr.mal td {{ background:var(--lueur); }}
.num {{ text-align:right; font-variant-numeric:tabular-nums;
font-family:"JetBrains Mono",monospace; font-size:.85rem; }}
.gros {{ font-weight:700; font-size:1rem; }}
.cel {{ font-family:"JetBrains Mono",monospace; font-size:.8rem; color:var(--sourd); }}
.etat {{ font-size:.85rem; color:var(--sourd); }}
.h {{ font-weight:700; white-space:nowrap; }}
.note {{ color:var(--sourd); font-size:.88rem; }}
table.mini {{ min-width:0; width:auto; margin:1.2rem 0; border:1px solid var(--trait);
border-radius:8px; }}
table.mini th {{ padding:9px 16px; }} table.mini td {{ padding:8px 16px; }}
.legende {{ display:flex; flex-wrap:wrap; gap:18px; font-size:.85rem;
color:var(--sourd); margin-top:1rem; }}
.legende span {{ display:inline-flex; align-items:center; gap:7px; }}
.pastille {{ width:11px; height:11px; border-radius:3px; display:inline-block; }}
.encart {{ border-left:3px solid var(--grave); background:var(--carte);
padding:16px 20px; margin:1.8rem 0; border-radius:0 8px 8px 0; }}
.encart p:last-child {{ margin-bottom:0; }}
ol, ul {{ padding-left:1.3em; }} li {{ margin-bottom:.7em; }}
footer {{ margin-top:5rem; padding-top:1.6rem; border-top:1px solid var(--trait);
font-size:.85rem; color:var(--sourd); line-height:1.6; }}
</style>
<div class="page">
<header>
<div class="etiq">{t['etiq']}</div>
<h1>{t['titre']}</h1>
<p class="chapeau">{t['chapeau']}</p>
<div class="maj">{t['maj']}&nbsp;: {MAJ[lang]}</div>
</header>
<div class="verdict">{verdict}</div>
<div class="mesure"><h2><span class="num">1</span>{t['h1']}</h2>{t['p1']}</div>
<figure>
<div class="cadre">{figure(lang)}</div>
<div class="legende">{leg}</div>
<figcaption>{t['figcap']}</figcaption>
</figure>
<div class="mesure"><h2><span class="num">2</span>{t['h2']}</h2>{t['p2']}</div>
<div class="tabl"><table><thead><tr>{ths}</tr></thead><tbody>{parc}</tbody></table></div>
<div class="mesure">{t['p3']}
<h2><span class="num">3</span>{t['h3']}</h2>{t['p4']}</div>
<div class="tabl"><table><thead><tr>{thc}</tr></thead><tbody>{chrono}</tbody></table></div>
<div class="mesure">
<div class="encart">{t['enc1']}</div>
{t['p5']}<ul>{seuils}</ul>
<h2><span class="num">4</span>{t['h4']}</h2>{t['p6']}
<h2><span class="num">5</span>{t['h5']}</h2>{t['p7']}
<h2><span class="num">6</span>{t['h6']}</h2>
<ol>{quest}</ol>
</div>
<footer>{t['meth']}</footer>
</div>
"""
SORTIES = {"fr": "rapport-batteries.html", "en": "battery-report.html",
"zh": "battery-report-zh.html"}
base = pathlib.Path("docs/pages"); base.mkdir(parents=True, exist_ok=True)
for lang, nom in SORTIES.items():
h = page(lang)
(base / nom).write_text(h, encoding="utf-8")
print(f" {nom:26s} {lang} {len(h):6d} caracteres")