"""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'' ) 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'' ) parts.append( f'' ) parts.append( f'' ) parts.append( f'' ) parts.append( f'' ) parts.append( f''.replace(",", " ") ) parts.append( f'' ) parts.append("") parts.append("
' f'{_e(hdr)}
' f'{_e(engine.get("name", ""))}{ece * 100:.2f} %' f'{mce * 100:.2f} %' f'{acc * 100:.1f} %' f'{conf * 100:.1f} %' f'{n_pred:,}' f'{doc_count}
") 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'' ) # Axes parts.append( f'' ) parts.append( f'' ) # Diagonale (calibration parfaite) parts.append( f'' ) # Barres par bin for b in bins: n = int(b.get("count") or 0) if n == 0: continue bin_low = float(b.get("bin_low", 0.0)) bin_high = float(b.get("bin_high", 1.0)) accuracy = float(b.get("accuracy") or 0.0) x = _svg_x(bin_low) w = _svg_x(bin_high) - x y = _svg_y(accuracy) h = _svg_y(0) - y # Barre semi-transparente pour ne pas masquer la diagonale parts.append( f'' ) # Points (avg_confidence, accuracy) reliés par une ligne points = [] for b in bins: n = int(b.get("count") or 0) if n == 0: continue avg_conf = float(b.get("avg_confidence") or 0.0) accuracy = float(b.get("accuracy") or 0.0) points.append((_svg_x(avg_conf), _svg_y(accuracy))) if len(points) >= 2: path_d = "M " + " L ".join(f"{x:.2f},{y:.2f}" for x, y in points) parts.append( f'' ) for x, y in points: parts.append( f'' ) # Étiquettes d'axes parts.append( f'{_e(conf_axis)}' ) parts.append( f'' f'{_e(acc_axis)}' ) # Graduations 0 / 0.5 / 1 sur les deux axes for v in (0.0, 0.5, 1.0): parts.append( f'{v:.1f}' ) parts.append( f'{v:.1f}' ) parts.append("") 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('
') parts.append( f'
{_e(name)}
' ) parts.append(svg) parts.append("
") parts.append("
") return "".join(parts) __all__ = [ "build_calibration_summary_html", "build_reliability_diagram_svg", "build_reliability_diagrams_grid_html", ]