#!/usr/bin/env python3 """Figure ch22 — métré de contrôle du sujet du cas 1 : écart relatif entre la quantité recalculée depuis le plan coté et la quantité du devis, par poste mesuré ou dénombré, avec les bandes de tolérance (±5 % conforme, ±10 % à préciser). DONNÉES SYNTHÉTIQUES, graine 20261031. Sortie : assets/figures/ch22/ch22-fig-ecarts-metre.pdf""" import sys, pathlib ROOT = pathlib.Path(__file__).resolve().parents[3] sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases")) import matplotlib.pyplot as plt from matplotlib.lines import Line2D from traite.style import setup, palette, savefig, LARGEUR import ch21_cout_neuf as c21 import ch22_metre as cas COURT = {"Excavation et remblai": "Excavation et remblai (m³)", "Fondations béton (semelles, murs)": "Fondations béton (m³)", "Dalle de sous-sol": "Dalle de sous-sol (m²)", "Drain français et imperméabilisation": "Drain français (m lin.)", "Charpente murs et planchers": "Charpente murs et planchers (m²)", "Fermes de toit": "Fermes de toit (m²)", "Couverture bardeaux d'asphalte": "Couverture (m²)", "Revêtement extérieur (brique 40 % / fibrociment 60 %)": "Revêtement extérieur (m²)", "Isolation et pare-vapeur": "Isolation et pare-vapeur (m²)", "Fenêtres (PVC double vitrage)": "Fenêtres (unités)", "Portes extérieures": "Portes extérieures (unités)", "Porte de garage": "Porte de garage (unité)", "Gypse et tirage de joints": "Gypse (m²)", "Peinture intérieure": "Peinture (m²)", "Planchers bois franc": "Planchers bois franc (m²)", "Planchers céramique": "Planchers céramique (m²)", "Planchers flottants (sous-sol)": "Planchers flottants (m²)", "Escaliers": "Escaliers (unités)", "Portes intérieures et moulures": "Portes intérieures (unités)", "Plomberie brute et finition": "Plomberie (appareils)", "Gouttières et solins": "Gouttières et solins (m lin.)", "Balcon et perron": "Balcon et perron (m²)", "Entrée asphaltée": "Entrée asphaltée (m²)", "Supervision de chantier (salaires)": "Supervision (semaines)"} COUL = {"conforme": palette.cat[0], "à préciser": palette.status["warning"], "à justifier": palette.status["critical"]} def main(): setup() devis, _ = c21.charger() V = cas.verification(devis) V = V[V["classe"] != "forfait"].copy() V["ordre"] = V["corps"].map({c: i for i, c in enumerate(c21.ORDRE_CORPS)}) V = V.sort_values(["ordre"], kind="stable").reset_index(drop=True) V = V.iloc[::-1].reset_index(drop=True) # premier poste en haut y = list(range(len(V))) fig, ax = plt.subplots(figsize=(LARGEUR, 4.6)) ax.axvspan(-10, -5, color=palette.status["warning"], alpha=0.10, lw=0); ax.axvspan(5, 10, color=palette.status["warning"], alpha=0.10, lw=0) ax.axvspan(-5, 5, color=palette.cat[0], alpha=0.07, lw=0) ax.axvline(0, color=palette.ink2, lw=0.8) for k, r in V.iterrows(): ax.plot([0, r["ecart"] * 100], [k, k], color=palette.grid, lw=1.2, zorder=1) ax.plot(r["ecart"] * 100, k, "o", color=COUL[r["verdict"]], ms=5.5, zorder=3) if abs(r["ecart"]) > 0.0005: ax.text(r["ecart"] * 100 + (0.6 if r["ecart"] >= 0 else -0.6), k, f"{r['ecart']*100:+.1f} %", va="center", ha="left" if r["ecart"] >= 0 else "right", fontsize=7.2, color=palette.ink) ax.set_yticks(y); ax.set_yticklabels([COURT.get(p, p) for p in V["poste"]], fontsize=7.6) ax.set_xlim(-22, 26); ax.set_xlabel("Écart du métré de contrôle par rapport au devis (%) : (quantité recalculée − quantité du devis) / devis") ax.grid(axis="y", visible=False) # séparateurs de corps prev = None for k, c in enumerate(V["corps"]): if prev is not None and c != prev: ax.axhline(k - 0.5, color=palette.grid, lw=0.6) prev = c nb = V["verdict"].value_counts() handles = [Line2D([], [], marker="o", color=COUL[v], ls="", ms=5.5, label=f"{v} ({int(nb.get(v, 0))})") for v in ("conforme", "à préciser", "à justifier")] ax.legend(handles=handles, loc="lower right", fontsize=7.5, title="Verdict (tolérances ±5 % et ±10 %)", title_fontsize=7.5) out = savefig(fig, "ch22/ch22-fig-ecarts-metre.pdf"); print("OK", out) if __name__ == "__main__": main()