"""Rendu HTML server-side de la section calibration (Sprint 43).
Suite directe des Sprints 39+42 : la couche de calcul (ECE, MCE,
reliability_diagram) et le câblage runner sont en place ; ce module
produit les blocs HTML qui rendent ces données visibles dans le
rapport.
- ``build_calibration_summary_html`` — tableau résumé par moteur :
ECE, MCE, nombre de prédictions évaluées, accuracy moyenne,
confidence moyenne. Cellule ECE colorée par gradient vert (bien
calibré) → rouge (mal calibré).
- ``build_reliability_diagram_svg`` — SVG d'un reliability diagram
pour un moteur donné : barres d'accuracy par bin, ligne idéale
(calibration parfaite) en diagonale, axes annotés.
Principe — cohérent avec le SVG du CDD (Sprint 18) et les renderers
Sprint 37/41 : strictement server-side, déterministe, pas de
JavaScript. Si aucun moteur n'a de ``aggregated_calibration``, le
masquage adaptatif fait que les fonctions retournent ``""`` et la
section est silencieusement omise.
Anti-injection : tous les noms de moteurs et étiquettes sont passés à
``html.escape`` avant insertion.
"""
from __future__ import annotations
from html import escape as _e
from typing import Optional
from picarones.report.render_helpers import color_traffic_light
def _engines_with_calibration(engines_summary: list[dict]) -> list[dict]:
return [e for e in engines_summary if e.get("aggregated_calibration")]
def build_calibration_summary_html(
engines_summary: list[dict],
labels: Optional[dict[str, str]] = None,
) -> str:
"""Tableau résumé : ECE/MCE/N par moteur."""
relevant = _engines_with_calibration(engines_summary)
if not relevant:
return ""
labels = labels or {}
caption = labels.get(
"calibration_summary_caption",
"Calibration des moteurs (ECE, MCE)",
)
engine_label = labels.get("calibration_engine_label", "Moteur")
ece_label = labels.get("calibration_ece_label", "ECE")
mce_label = labels.get("calibration_mce_label", "MCE")
n_label = labels.get("calibration_n_label", "Prédictions")
acc_label = labels.get("calibration_acc_label", "Précision moyenne")
conf_label = labels.get("calibration_conf_label", "Confiance moyenne")
docs_label = labels.get("calibration_docs_label", "Docs évalués")
parts: list[str] = []
parts.append('
')
parts.append(
f'
{_e(caption)}
'
)
parts.append(
'
'
)
parts.append("
")
for hdr in (engine_label, ece_label, mce_label,
acc_label, conf_label, n_label, docs_label):
parts.append(
f'
'
f'{_e(hdr)}
'
)
parts.append("
")
for engine in relevant:
agg = engine["aggregated_calibration"]
ece = float(agg.get("ece") or 0.0)
mce = float(agg.get("mce") or 0.0)
n_pred = int(agg.get("n_predictions") or 0)
acc = float(agg.get("overall_accuracy") or 0.0)
conf = float(agg.get("overall_confidence") or 0.0)
doc_count = int(agg.get("doc_count") or 0)
bg = color_traffic_light(ece, low_is_good=True, scale_max=0.5)
parts.append("
")
parts.append(
f'
'
f'{_e(engine.get("name", ""))}
'
)
parts.append(
f'
{ece * 100:.2f} %
'
)
parts.append(
f'
'
f'{mce * 100:.2f} %
'
)
parts.append(
f'
'
f'{acc * 100:.1f} %
'
)
parts.append(
f'
'
f'{conf * 100:.1f} %
'
)
parts.append(
f'
'
f'{n_pred:,}
'.replace(",", " ")
)
parts.append(
f'
'
f'{doc_count}
'
)
parts.append("
")
parts.append("
")
return "".join(parts)
# ──────────────────────────────────────────────────────────────────────────
# SVG reliability diagram
# ──────────────────────────────────────────────────────────────────────────
# Géométrie SVG (en unités viewBox)
_SVG_W = 240
_SVG_H = 240
_PAD_LEFT = 38
_PAD_RIGHT = 8
_PAD_TOP = 8
_PAD_BOTTOM = 30
def _svg_x(value: float) -> float:
"""Mappe une confidence ∈ [0, 1] sur l'axe x du SVG."""
return _PAD_LEFT + value * (_SVG_W - _PAD_LEFT - _PAD_RIGHT)
def _svg_y(value: float) -> float:
"""Mappe une accuracy ∈ [0, 1] sur l'axe y (inversé : 0 en bas)."""
return _SVG_H - _PAD_BOTTOM - value * (_SVG_H - _PAD_TOP - _PAD_BOTTOM)
def build_reliability_diagram_svg(
aggregated_calibration: Optional[dict],
labels: Optional[dict[str, str]] = None,
*,
engine_name: str = "",
) -> str:
"""Construit un SVG du reliability diagram pour un moteur.
Conventions
-----------
- Axe x : confidence moyenne par bin ∈ [0, 1]
- Axe y : accuracy par bin ∈ [0, 1]
- Diagonale en pointillé : calibration parfaite (référence)
- Barre par bin : largeur = bin_width, hauteur = accuracy
- Cercle par bin : (avg_confidence, accuracy) — position réelle
Returns
-------
str
SVG complet, ou ``""`` si pas de bin non vide.
"""
if not aggregated_calibration:
return ""
bins = aggregated_calibration.get("bins") or []
non_empty = [b for b in bins if (b.get("count") or 0) > 0]
if not non_empty:
return ""
labels = labels or {}
title = labels.get(
"reliability_diagram_title", "Diagramme de fiabilité",
)
conf_axis = labels.get("reliability_x_axis", "Confiance")
acc_axis = labels.get("reliability_y_axis", "Précision")
parts: list[str] = []
parts.append(
f'")
return "".join(parts)
def build_reliability_diagrams_grid_html(
engines_summary: list[dict],
labels: Optional[dict[str, str]] = None,
) -> str:
"""Construit une grille de reliability diagrams (un par moteur).
Layout : grid auto-fit, chaque cellule a son SVG + le nom du moteur
en titre. Vide si aucun moteur n'a d'``aggregated_calibration``.
"""
relevant = _engines_with_calibration(engines_summary)
if not relevant:
return ""
parts: list[str] = []
parts.append(
'
'
)
for engine in relevant:
name = engine.get("name", "")
svg = build_reliability_diagram_svg(
engine["aggregated_calibration"],
labels=labels,
engine_name=name,
)
if not svg:
continue
parts.append('