spb/traite-evaluation-qc
Public
1#!/usr/bin/env python32"""Chapitre 22 — métré et quantités du sujet du cas 1 (Gatineau, DONNÉES SYNTHÉTIQUES).34Le devis de 38 postes (data/synthetic/cas1_gatineau_couts.csv) est celui du chapitre 21 : mêmes postes, mêmes5quantités, mêmes montants (D = 298 826,17 $, I = 62 598,68 $, C_N = 397 567,33 $). Le présent script :6 * fixe la géométrie du sujet (plan coté : empreinte 12,00 × 7,92 m = 95,0 m², garage intégré 3,96 × 6,20 m,7 deux étages, sous-sol sous la partie habitable) et en déduit les superficies selon plusieurs conventions8 (aire au sol et aire des étages au sens du MEFQ, superficie habitable, aire nette intérieure) ;9 * recalcule, par des règles géométriques déclarées, la quantité de chacun des postes « mesurés » du devis10 (m³, m², m lin.) et dénombre les postes « comptés » (unités, appareils) ; compare au devis (écart, verdict) ;11 * chiffre l'effet des écarts du métré de contrôle et d'erreurs de quantité sur D et C_N (via ch21_cout_neuf) ;12 * illustre les pertes et chutes (taux déclarés, hypothèses pédagogiques) et la propagation des erreurs de mesure ;13 * imprime le code TikZ du plan coté (--tikz) pour le chapitre.1415Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch22_metre.py [--tikz]16Fonctions de coût : traite.cout (cout_neuf, cout_par_composantes) et ch21_cout_neuf (CORPS, regrouper, cout_neuf_sujet)."""17from __future__ import annotations18import sys, math, pathlib19import numpy as np, pandas as pd20sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))21sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))22from traite import cout as ct23import ch21_cout_neuf as c212425ROOT = pathlib.Path(__file__).resolve().parents[3]26PI2_PAR_M2 = c21.PI2_PAR_M227MULT = None # (1+τI)(1+τE) du cas de base, rempli par cout_neuf_sujet2829# ----------------------------------------------------------------------------- géométrie du sujet (hypothèses du plan)30PLAN = {31 "L": 12.00, # façade (m), face externe des murs32 "W": 7.92, # profondeur (m)33 "gx": 3.96, "gy": 6.30, # garage intégré (coin avant gauche), face externe34 "t_ext": 0.30, # épaisseur des murs extérieurs hors sol (ossature 2×6, revêtement, finition)35 "t_sep": 0.15, # séparation garage / maison36 "t_int": 0.10, # cloisons intérieures37 "t_fond": 0.25, # murs de fondation (10 po)38 "h_fond": 2.44, # hauteur des murs de fondation (dalle → dessus du mur)39 "sem_l": 0.60, "sem_h": 0.30, # semelles filantes (largeur × hauteur)40 "n_col": 4, "sem_col": 0.90 * 0.90 * 0.30, # semelles de colonnes41 "h_mur": 2.44, # hauteur d'ossature d'un étage (murs)42 "h_etage": 2.75, # plancher à plancher43 "h_revet": 5.60, # hauteur revêtue (dessus de fondation → soffite), deux étages44 "pente": 0.5, # 6:1245 "debord": 0.40, # débords de toit46 "prof_exc": 2.60, # profondeur d'excavation (sous-sol + semelle + pierre nette)47 "degag": 0.90, # dégagement de travail au pied des murs48 "talus": 0.5, # pente des talus (horizontal / vertical)49 "gar_h_gel": 1.50, "gar_t": 0.25, "gar_sem_l": 0.50, "gar_sem_h": 0.25, # murs hors gel du garage50 "tranchee_gar": (1.00, 1.50), # largeur, profondeur de la tranchée des murs hors gel51 "tranchee_racc": (15.0, 0.90, 1.80), # longueur, largeur, profondeur de la tranchée aqueduc/égout52 "fen_hs": (14, 1.20 * 1.40), "fen_ss": (2, 1.70), # fenêtres hors sol / sous-sol (nombre, aire unitaire m²)53 "porte_ext": (3, 0.91 * 2.03), "porte_gar": (1, 2.74 * 2.13), "porte_int": (14, 0.91 * 2.03),54 "descentes": (4, 5.80), # descentes pluviales (nombre, longueur)55 "rive": 0.30, # hauteur des solives de rive (isolation)56 "perron": (1.50, 2.40), "balcon": (3.00, 3.50), "entree": (5.00, 11.00),57 "drain_raccord": 1.00, # raccord du drain au puisard58}5960# Pièces : (x0, y0, x1, y1) en coordonnées de face externe (origine : coin avant gauche ; x le long de la façade),61# nom, type de plancher. Les cloisons sont déduites des limites communes entre pièces d'un même niveau.62PIECES = {63 "rez": [64 (0.00, 0.00, 3.96, 6.30, "Garage", "béton"),65 (0.00, 6.30, 3.96, 7.92, "Buanderie", "céramique"),66 (3.96, 0.00, 6.30, 3.10, "Vestibule et escaliers", "céramique"),67 (3.96, 3.10, 5.60, 4.70, "Salle d'eau", "céramique"),68 (5.60, 3.10, 6.30, 4.70, "Garde-robe", "bois franc"),69 (3.96, 4.70, 8.50, 7.92, "Cuisine", "céramique"),70 (6.30, 0.00, 12.00, 4.70, "Salon", "bois franc"),71 (8.50, 4.70, 12.00, 7.92, "Salle à manger", "bois franc"),72 ],73 "etage": [74 (0.00, 0.00, 2.70, 3.10, "Salle de bain", "céramique"),75 (2.70, 0.00, 3.96, 3.10, "Lingerie", "bois franc"),76 (3.96, 0.00, 5.00, 3.10, "Trémie d'escalier", "trémie"),77 (5.00, 0.00, 6.30, 3.10, "Palier", "bois franc"),78 (6.30, 0.00, 12.00, 3.10, "Chambre principale", "bois franc"),79 (0.00, 3.10, 2.70, 4.10, "Garde-robe", "bois franc"),80 (2.70, 3.10, 8.30, 4.10, "Corridor", "bois franc"),81 (8.30, 3.10, 12.00, 7.92, "Chambre 2", "bois franc"),82 (4.90, 4.10, 8.30, 7.92, "Chambre 3", "bois franc"),83 (0.00, 4.10, 4.90, 7.92, "Chambre 4", "bois franc"),84 ],85 "soussol": [ # sous la partie habitable seulement (le garage est sur dalle au sol)86 (0.00, 6.30, 3.96, 7.92, "Rangement", "flottant"),87 (3.96, 0.00, 5.00, 3.10, "Escalier", "escalier"),88 (3.96, 3.10, 5.00, 7.92, "Hall", "flottant"),89 (5.00, 0.00, 12.00, 4.70, "Salle familiale", "flottant"),90 (5.00, 4.70, 7.50, 7.92, "Salle mécanique", "béton"),91 (7.50, 4.70, 12.00, 7.92, "Bureau", "flottant"),92 ],93}94# placards et walk-in (cloisons supplémentaires, m linéaires, étage)95CLOISONS_SUPPL = {"rez": 0.0, "etage": 1.50 + 1.80 + 3 * 1.80, "soussol": 0.0}96FENETRES = {"rez": {"Salon": 2, "Salle à manger": 2, "Cuisine": 1, "Salle d'eau": 1},97 "etage": {"Chambre principale": 2, "Chambre 2": 2, "Chambre 3": 1, "Chambre 4": 2, "Salle de bain": 1},98 "soussol": {"Salle familiale": 1, "Bureau": 1}}99PORTES_INT = {"rez": ["Salle d'eau", "Garde-robe", "Buanderie", "Garage (porte coupe-feu)"],100 "etage": ["Chambre principale", "Chambre 2", "Chambre 3", "Chambre 4", "Salle de bain", "Lingerie", "Walk-in"],101 "soussol": ["Bureau", "Salle mécanique", "Rangement"]}102APPAREILS = ["Évier de cuisine", "Lave-vaisselle (raccord)", "Réfrigérateur (raccord d'eau)", "Laveuse (raccord)", "Cuve de buanderie",103 "Lavabo (salle d'eau)", "Toilette (salle d'eau)", "Lavabo (salle de bain)", "Toilette (salle de bain)", "Baignoire", "Douche",104 "Robinet extérieur avant", "Robinet extérieur arrière", "Drain de plancher"]105106# Taux de pertes et chutes (HYPOTHÈSES PÉDAGOGIQUES déclarées, non sourcées) : quantité à commander = quantité mesurée × (1 + w)107PERTES = {"Gypse et tirage de joints": 0.12, "Couverture bardeaux d'asphalte": 0.10, "Planchers bois franc": 0.08,108 "Planchers céramique": 0.10, "Planchers flottants (sous-sol)": 0.08, "Revêtement extérieur (brique 40 % / fibrociment 60 %)": 0.10,109 "Isolation et pare-vapeur": 0.05, "Charpente murs et planchers": 0.10, "Fondations béton (semelles, murs)": 0.05, "Dalle de sous-sol": 0.05}110111TOL_CONFORME, TOL_PRECISER = 0.05, 0.10 # verdicts du métré de contrôle112113114# ----------------------------------------------------------------------------- géométrie élémentaire115116def aire(p): return (p[2] - p[0]) * (p[3] - p[1])117118def empreinte() -> dict:119 P = PLAN120 brute = P["L"] * P["W"]; garage = P["gx"] * P["gy"]121 perim = 2 * (P["L"] + P["W"]) # le périmètre d'une forme en L découpée dans un rectangle est celui du rectangle122 return {"aire_au_sol": brute, "garage": garage, "aire_sous_sol": brute - garage, "perimetre": perim,123 "aire_etages_mefq": 2 * brute, "perimetre_etages_mefq": 2 * perim}124125def _epaisseur_cote(x0, y0, x1, y1, cote) -> float:126 """Demi-épaisseur du mur à déduire sur un côté d'une pièce : mur extérieur (face externe → face interne : t_ext),127 séparation garage (t_sep/2) ou cloison (t_int/2)."""128 P = PLAN129 if cote == "x0" and x0 == 0.0: return P["t_ext"]130 if cote == "x1" and x1 == P["L"]: return P["t_ext"]131 if cote == "y0" and y0 == 0.0: return P["t_ext"]132 if cote == "y1" and y1 == P["W"]: return P["t_ext"]133 # séparation garage/maison : x = gx pour y < gy ; y = gy pour x < gx134 if (cote in ("x0", "x1") and (x0 == P["gx"] or x1 == P["gx"]) and y0 < P["gy"]) or \135 (cote in ("y0", "y1") and (y0 == P["gy"] or y1 == P["gy"]) and x0 < P["gx"]):136 return P["t_sep"] / 2137 return P["t_int"] / 2138139def pieces_nettes(niveau: str) -> pd.DataFrame:140 rows = []141 for (x0, y0, x1, y1, nom, fini) in PIECES[niveau]:142 w = (x1 - x0) - _epaisseur_cote(x0, y0, x1, y1, "x0") - _epaisseur_cote(x0, y0, x1, y1, "x1")143 h = (y1 - y0) - _epaisseur_cote(x0, y0, x1, y1, "y0") - _epaisseur_cote(x0, y0, x1, y1, "y1")144 rows.append({"niveau": niveau, "piece": nom, "fini": fini, "brute": (x1 - x0) * (y1 - y0), "nette": w * h,145 "perimetre_net": 2 * (w + h), "largeur": w, "profondeur": h, "x0": x0, "y0": y0, "x1": x1, "y1": y1})146 return pd.DataFrame(rows)147148def cloisons(niveau: str) -> float:149 """Longueur des cloisons intérieures (m) : limites communes entre pièces, hors murs extérieurs et hors séparation garage."""150 P = PLAN; segs = []151 pcs = PIECES[niveau]152 for i, a in enumerate(pcs):153 for b in pcs[i + 1:]:154 # côtés verticaux communs155 for xa, xb in ((a[2], b[0]), (a[0], b[2])):156 if abs(xa - xb) < 1e-9:157 lo, hi = max(a[1], b[1]), min(a[3], b[3])158 if hi - lo > 1e-9:159 if abs(xa - P["gx"]) < 1e-9 and lo < P["gy"]: continue # séparation garage160 segs.append(hi - lo)161 for ya, yb in ((a[3], b[1]), (a[1], b[3])):162 if abs(ya - yb) < 1e-9:163 lo, hi = max(a[0], b[0]), min(a[2], b[2])164 if hi - lo > 1e-9:165 if abs(ya - P["gy"]) < 1e-9 and lo < P["gx"]: continue166 segs.append(hi - lo)167 return sum(segs) + CLOISONS_SUPPL[niveau]168169def separation_garage() -> float:170 return PLAN["gx"] + PLAN["gy"]171172173def superficies() -> dict:174 """Superficies du sujet selon les conventions : MEFQ (aire au sol, aire des étages), habitable (brute hors garage),175 nette intérieure (pièces hors murs, hors trémie), sous-sol."""176 e = empreinte()177 rez, et, ss = pieces_nettes("rez"), pieces_nettes("etage"), pieces_nettes("soussol")178 nette_rez = rez.loc[rez["piece"] != "Garage", "nette"].sum()179 nette_etage = et.loc[et["fini"] != "trémie", "nette"].sum()180 return {"aire_au_sol": e["aire_au_sol"], "aire_etages_mefq": e["aire_etages_mefq"], "perimetre_etages_mefq": e["perimetre_etages_mefq"],181 "garage_brut": e["garage"], "habitable": e["aire_etages_mefq"] - e["garage"],182 "nette_rez": nette_rez, "nette_etage": nette_etage, "nette_hors_sol": nette_rez + nette_etage,183 "sous_sol_brut": e["aire_sous_sol"], "sous_sol_net": ss["nette"].sum(),184 "sous_sol_fini_net": ss.loc[ss["fini"] == "flottant", "nette"].sum(),185 "trémie": et.loc[et["fini"] == "trémie", "nette"].sum(), "pieces": pd.concat([rez, et, ss], ignore_index=True)}186187188# ----------------------------------------------------------------------------- règles de mesure (quantités géométriques)189190def aire_decalee(A: float, P: float, d: float) -> float:191 """Aire d'un polygone rectiligne décalé vers l'extérieur de d (angles droits) : A + P·d + 4·d² (4 coins nets)."""192 return A + P * d + 4 * d * d193194def volume_prismoidal(A_bas: float, A_mi: float, A_haut: float, h: float) -> float:195 return h / 6.0 * (A_bas + 4 * A_mi + A_haut)196197def quantites_geometriques() -> dict[str, dict]:198 """Quantité recalculée pour chaque poste mesuré ou dénombré du devis, avec la règle appliquée (texte) et le détail."""199 P = PLAN; e = empreinte(); S = superficies(); q = {}200 # --- excavation (prismoïde à talus) + tranchées201 A_b = aire_decalee(e["aire_sous_sol"], e["perimetre"], P["degag"])202 A_m = aire_decalee(e["aire_sous_sol"], e["perimetre"], P["degag"] + P["talus"] * P["prof_exc"] / 2)203 A_t = aire_decalee(e["aire_sous_sol"], e["perimetre"], P["degag"] + P["talus"] * P["prof_exc"])204 V_ss = volume_prismoidal(A_b, A_m, A_t, P["prof_exc"])205 V_gar = separation_garage() * 0 + (P["gx"] + P["gy"]) * P["tranchee_gar"][0] * P["tranchee_gar"][1]206 V_racc = P["tranchee_racc"][0] * P["tranchee_racc"][1] * P["tranchee_racc"][2]207 q["Excavation et remblai"] = {"q": V_ss + V_gar + V_racc, "regle": "prismoïde (fond, mi-hauteur, surface) + tranchées du garage et des raccordements",208 "detail": {"A_bas": A_b, "A_mi": A_m, "A_haut": A_t, "V_sous_sol": V_ss, "V_garage": V_gar, "V_racc": V_racc}}209 # --- fondations : murs à l'axe, semelles filantes, semelles de colonnes, murs hors gel du garage210 L_axe = e["perimetre"] - 4 * P["t_fond"]211 V_murs = L_axe * P["h_fond"] * P["t_fond"]; V_sem = L_axe * P["sem_l"] * P["sem_h"]; V_col = P["n_col"] * P["sem_col"]212 L_gar = P["gx"] + P["gy"]213 V_gar_m = L_gar * P["gar_h_gel"] * P["gar_t"]; V_gar_s = L_gar * P["gar_sem_l"] * P["gar_sem_h"]214 q["Fondations béton (semelles, murs)"] = {"q": V_murs + V_sem + V_col + V_gar_m + V_gar_s,215 "regle": "longueur à l'axe × hauteur × épaisseur (murs) + longueur × section (semelles) + semelles de colonnes + murs hors gel du garage",216 "detail": {"L_axe": L_axe, "V_murs": V_murs, "V_semelles": V_sem, "V_colonnes": V_col, "L_garage": L_gar, "V_garage_murs": V_gar_m, "V_garage_semelles": V_gar_s}}217 # --- dalle : aire au sol brute (sous-sol + garage sur sol), convention du devis218 q["Dalle de sous-sol"] = {"q": e["aire_sous_sol"] + e["garage"], "regle": "aire au sol brute (dalle du sous-sol + dalle du garage sur sol)",219 "detail": {"sous_sol": e["aire_sous_sol"], "garage": e["garage"]}}220 # --- drain : périmètre à la face externe des semelles + raccord221 d_sem = (P["sem_l"] - P["t_fond"]) / 2222 L_drain = e["perimetre"] + 8 * d_sem + P["drain_raccord"]223 q["Drain français et imperméabilisation"] = {"q": L_drain, "regle": "périmètre à la face externe des semelles (+ 8 × débord de semelle) + raccord au puisard",224 "detail": {"debord_semelle": d_sem}}225 # --- charpente : planchers en bois (rez hors garage + étage, aire brute) + murs extérieurs nets d'ouvertures (2 étages)226 A_planchers = e["aire_sous_sol"] + e["aire_au_sol"]227 A_murs_brut = e["perimetre"] * P["h_mur"] * 2228 ouv_hs = P["fen_hs"][0] * P["fen_hs"][1] + P["porte_ext"][0] * P["porte_ext"][1] + P["porte_gar"][0] * P["porte_gar"][1]229 A_murs_net = A_murs_brut - ouv_hs230 q["Charpente murs et planchers"] = {"q": A_planchers + A_murs_net, "regle": "aire brute des planchers en bois (rez hors garage + étage) + périmètre × 2,44 m × 2 − ouvertures",231 "detail": {"planchers": A_planchers, "murs_brut": A_murs_brut, "ouvertures": ouv_hs, "murs_net": A_murs_net}}232 # --- toit233 A_proj = (P["L"] + 2 * P["debord"]) * (P["W"] + 2 * P["debord"])234 fact = math.sqrt(1 + P["pente"] ** 2)235 q["Fermes de toit"] = {"q": A_proj, "regle": "projection horizontale du toit, débords compris", "detail": {"L": P["L"] + 2 * P["debord"], "W": P["W"] + 2 * P["debord"]}}236 q["Couverture bardeaux d'asphalte"] = {"q": A_proj * fact, "regle": "projection horizontale × √(1 + pente²), pente 6:12", "detail": {"facteur_pente": fact}}237 # --- revêtement : brut (ouvertures non déduites) + pignons238 h_pignon = P["W"] / 2 * P["pente"]; A_pignons = 2 * 0.5 * P["W"] * h_pignon239 q["Revêtement extérieur (brique 40 % / fibrociment 60 %)"] = {"q": e["perimetre"] * P["h_revet"] + A_pignons,240 "regle": "périmètre × hauteur revêtue + deux pignons ; ouvertures non déduites (convention du devis)", "detail": {"murs": e["perimetre"] * P["h_revet"], "pignons": A_pignons, "h_pignon": h_pignon}}241 # --- isolation : murs ext nets + plafond de l'étage + murs de fondation nets + séparation garage (murs, plafond) + solives de rive242 A_fond = L_axe * P["h_fond"] - P["fen_ss"][0] * P["fen_ss"][1]243 A_sep = separation_garage() * P["h_mur"]; A_rive = 2 * e["perimetre"] * P["rive"]244 q["Isolation et pare-vapeur"] = {"q": A_murs_net + e["aire_au_sol"] + A_fond + A_sep + e["garage"] + A_rive,245 "regle": "murs extérieurs nets + plafond de l'étage + murs de fondation nets + séparation garage (murs et plafond) + solives de rive",246 "detail": {"murs_net": A_murs_net, "plafond": e["aire_au_sol"], "fondation": A_fond, "sep_murs": A_sep, "sep_plafond": e["garage"], "rive": A_rive}}247 # --- gypse et peinture : plafonds des pièces finies + murs des pièces (périmètre net × 2,44) − ouvertures ; garage : plafond + séparation248 pcs = S["pieces"]249 g_plafonds = pcs.loc[(pcs["fini"].isin(["céramique", "bois franc", "flottant"])), "nette"].sum() + e["garage"]250 g_murs = pcs.loc[(pcs["fini"].isin(["céramique", "bois franc", "flottant"])), "perimetre_net"].sum() * P["h_mur"]251 g_sep_garage = separation_garage() * P["h_mur"] # face côté garage (la face côté maison est dans les périmètres des pièces)252 g_ouv = (P["fen_hs"][0] * P["fen_hs"][1] + P["fen_ss"][0] * P["fen_ss"][1] + P["porte_ext"][0] * P["porte_ext"][1]253 + 2 * P["porte_int"][0] * P["porte_int"][1])254 g_total = g_plafonds + g_murs + g_sep_garage - g_ouv255 q["Gypse et tirage de joints"] = {"q": g_total, "regle": "plafonds des pièces finies + périmètre net des pièces × 2,44 m + séparation côté garage − ouvertures (portes intérieures : deux faces)",256 "detail": {"plafonds": g_plafonds, "murs": g_murs, "sep_garage": g_sep_garage, "ouvertures": g_ouv}}257 q["Peinture intérieure"] = {"q": g_total, "regle": "surface de gypse (même mesure)", "detail": {}}258 # --- planchers259 for fini, poste in (("bois franc", "Planchers bois franc"), ("céramique", "Planchers céramique")):260 s = pcs.loc[pcs["fini"] == fini, "nette"].sum()261 q[poste] = {"q": s, "regle": f"aire nette des pièces finies en {fini}", "detail": dict(zip(pcs.loc[pcs["fini"] == fini, "piece"], pcs.loc[pcs["fini"] == fini, "nette"]))}262 q["Planchers flottants (sous-sol)"] = {"q": e["aire_sous_sol"], "regle": "aire brute du sous-sol (convention du devis ; aire finie nette en détail)",263 "detail": {"brute": e["aire_sous_sol"], "finie_nette": S["sous_sol_fini_net"]}}264 # --- gouttières : deux rives + descentes265 q["Gouttières et solins"] = {"q": 2 * (P["L"] + 2 * P["debord"]) + P["descentes"][0] * P["descentes"][1], "regle": "deux rives (façade + débords) + descentes",266 "detail": {"rives": 2 * (P["L"] + 2 * P["debord"]), "descentes": P["descentes"][0] * P["descentes"][1]}}267 q["Balcon et perron"] = {"q": P["perron"][0] * P["perron"][1] + P["balcon"][0] * P["balcon"][1], "regle": "perron avant + balcon arrière (aires)", "detail": {}}268 q["Entrée asphaltée"] = {"q": P["entree"][0] * P["entree"][1], "regle": "largeur × longueur de l'entrée", "detail": {}}269 # --- dénombrés270 q["Fenêtres (PVC double vitrage)"] = {"q": sum(sum(v.values()) for v in FENETRES.values()), "regle": "dénombrement sur plan (rez, étage, sous-sol)", "detail": {k: sum(v.values()) for k, v in FENETRES.items()}}271 q["Portes extérieures"] = {"q": 3, "regle": "entrée principale, porte-patio, porte de service du garage", "detail": {}}272 q["Porte de garage"] = {"q": 1, "regle": "garage simple", "detail": {}}273 q["Escaliers"] = {"q": 2, "regle": "rez → étage, rez → sous-sol", "detail": {}}274 q["Portes intérieures et moulures"] = {"q": sum(len(v) for v in PORTES_INT.values()), "regle": "dénombrement des portes intérieures", "detail": {k: len(v) for k, v in PORTES_INT.items()}}275 q["Plomberie brute et finition"] = {"q": len(APPAREILS), "regle": "dénombrement des appareils et raccords", "detail": {}}276 q["Supervision de chantier (salaires)"] = {"q": 26, "regle": "durée du chantier : 6 mois = 26 semaines", "detail": {}}277 return q278279280def verification(devis: pd.DataFrame) -> pd.DataFrame:281 """Tableau du métré de contrôle : quantité du devis, quantité recalculée, écart relatif, verdict ; 'forfait' pour les postes non mesurables."""282 q = quantites_geometriques(); rows = []283 for _, r in devis.iterrows():284 if r["poste"] in q:285 qc = q[r["poste"]]["q"]; ecart = qc / r["quantite"] - 1286 classe = "dénombré" if r["unite"] in ("unité", "appareil", "semaine") else "mesuré"287 verdict = "conforme" if abs(ecart) <= TOL_CONFORME else ("à préciser" if abs(ecart) <= TOL_PRECISER else "à justifier")288 rows.append({"poste": r["poste"], "corps": c21.CORPS[r["poste"]], "unite": r["unite"], "classe": classe, "q_devis": r["quantite"], "q_calc": qc,289 "ecart": ecart, "verdict": verdict, "prix_unitaire": r["prix_unitaire"], "montant": r["montant"],290 "delta_montant": (qc - r["quantite"]) * r["prix_unitaire"], "regle": q[r["poste"]]["regle"]})291 else:292 rows.append({"poste": r["poste"], "corps": c21.CORPS[r["poste"]], "unite": r["unite"], "classe": "forfait", "q_devis": r["quantite"], "q_calc": np.nan,293 "ecart": np.nan, "verdict": "ratio", "prix_unitaire": r["prix_unitaire"], "montant": r["montant"], "delta_montant": 0.0,294 "regle": "non mesurable : contrôle par ratio ($/m² habitable, part de D)"})295 return pd.DataFrame(rows)296297298def effet_metre_controle(devis: pd.DataFrame, verif: pd.DataFrame) -> dict:299 """D et C_N si les quantités mesurées du devis étaient remplacées par les quantités du métré de contrôle (postes directs)."""300 cn = c21.cout_neuf_sujet(devis)301 mult = (1 + cn["taux_indirects"]) * (1 + cn["taux_incitatif"])302 directs = verif[(verif["classe"] != "forfait") & (~verif["poste"].isin([p for p, c in c21.CORPS.items() if c == "Coûts indirects"]))]303 delta_D = float(directs["delta_montant"].sum())304 # indirects du devis inchangés (en dollars) : seul l'incitatif suit (assiette D + I)305 D2 = cn["direct"] + delta_D; I2 = cn["indirects"]306 CN2 = (D2 + I2) * (1 + cn["taux_incitatif"])307 return {"delta_D": delta_D, "D": cn["direct"], "D2": D2, "CN": cn["total"], "CN2": CN2, "delta_CN": CN2 - cn["total"],308 "mult": mult, "delta_CN_si_taux": delta_D * mult, "part_D": delta_D / cn["direct"], "part_CN": (CN2 - cn["total"]) / cn["total"]}309310311def sensibilite_quantites(devis: pd.DataFrame, erreurs=(-0.10, -0.05, 0.05, 0.10)) -> pd.DataFrame:312 """Effet sur C_N d'une erreur relative uniforme sur les quantités d'un corps de métier (indirects du devis en dollars fixes,313 incitatif 10 % sur D + I), et d'une erreur uniforme sur tous les postes directs."""314 cn = c21.cout_neuf_sujet(devis); g = c21.regrouper(devis); rows = []315 for corps in c21.ORDRE_CORPS[:-1] + ["Tous les postes directs"]:316 base = cn["direct"] if corps == "Tous les postes directs" else float(g.loc[corps, "montant"])317 row = {"corps": corps, "montant": base, "part_D": base / cn["direct"]}318 for e in erreurs:319 D2 = cn["direct"] + e * base320 CN2 = (D2 + cn["indirects"]) * (1 + cn["taux_incitatif"])321 row[f"dCN_{e:+.2f}"] = CN2 - cn["total"]322 row[f"pct_{e:+.2f}"] = CN2 / cn["total"] - 1323 rows.append(row)324 return pd.DataFrame(rows)325326327def propagation(devis: pd.DataFrame, sigma_rel: float = 0.05) -> dict:328 """Propagation d'erreurs de mesure indépendantes : σ_D/D = σ_rel · √(Σ ω_k²) pour les postes directs (ω_k = part du poste dans D)."""329 d = devis[~devis["indirect"]]330 w = (d["montant"] / d["montant"].sum()).to_numpy()331 H = float((w ** 2).sum())332 return {"herfindahl": H, "sigma_D_rel": sigma_rel * math.sqrt(H), "sigma_D": sigma_rel * math.sqrt(H) * float(d["montant"].sum()),333 "n": len(d), "equiv_uniforme": 1 / H, "max_part": float(w.max()), "poste_max": d.loc[d["montant"].idxmax(), "poste"],334 "sigma_si_parfaitement_correle": sigma_rel}335336337def pertes(devis: pd.DataFrame) -> pd.DataFrame:338 rows = []339 for poste, w in PERTES.items():340 r = devis[devis["poste"] == poste].iloc[0]341 rows.append({"poste": poste, "unite": r["unite"], "q_mesuree": r["quantite"], "taux": w, "q_commander": r["quantite"] * (1 + w),342 "surplus": r["quantite"] * w, "cout_pertes_si_prix_pose": r["quantite"] * w * r["prix_unitaire"] / (1 + w)})343 return pd.DataFrame(rows)344345346def repartition_par_type(devis: pd.DataFrame) -> pd.DataFrame:347 """Montant par corps de métier et par type d'unité (mesuré en volume/surface/longueur, dénombré, forfait)."""348 def typ(u):349 if u in ("m³", "m²", "m lin."): return "mesuré"350 if u in ("unité", "appareil", "semaine"): return "dénombré"351 return "forfait"352 d = devis.copy(); d["type"] = d["unite"].map(typ); d["corps"] = d["poste"].map(c21.CORPS)353 t = d.pivot_table(index="corps", columns="type", values="montant", aggfunc="sum", fill_value=0.0).reindex(c21.ORDRE_CORPS)354 return t[[c for c in ("mesuré", "dénombré", "forfait") if c in t.columns]]355356357# ----------------------------------------------------------------------------- TikZ du plan coté358359def tikz_plan(niveau: str, echelle: float = 0.42) -> str:360 """Code TikZ du plan d'un niveau (coordonnées en mètres × échelle cm)."""361 P = PLAN; s = echelle; out = []362 out.append(f"\\begin{{scope}}[scale={s}]")363 out.append(f"\\draw[line width=1.4pt] (0,0) rectangle ({P['L']},{P['W']});")364 for (x0, y0, x1, y1, nom, fini) in PIECES[niveau]:365 if niveau == "soussol" and nom == "Rangement":366 pass367 out.append(f"\\draw[line width=0.5pt] ({x0},{y0}) rectangle ({x1},{y1});")368 cx, cy = (x0 + x1) / 2, (y0 + y1) / 2369 lab = nom.replace("'", "'")370 a = (x1 - x0) * (y1 - y0)371 out.append(f"\\node[font=\\sffamily\\tiny, align=center] at ({cx:.2f},{cy:.2f}) {{{lab}\\\\{a:.1f}\\,m\\textsuperscript{{2}}}};")372 if niveau in ("rez",):373 out.append(f"\\draw[line width=1.1pt] ({P['gx']},0) -- ({P['gx']},{P['gy']}) -- (0,{P['gy']});")374 out.append("\\end{scope}")375 return "\n".join(out)376377378# ----------------------------------------------------------------------------- impression379380def main(tikz: bool = False) -> dict:381 devis, sujet = c21.charger()382 e = empreinte(); S = superficies(); q = quantites_geometriques(); V = verification(devis)383 eff = effet_metre_controle(devis, V); sens = sensibilite_quantites(devis); prop = propagation(devis); per = pertes(devis); rep = repartition_par_type(devis)384 cn = c21.cout_neuf_sujet(devis)385 f = lambda x: f"{x:,.2f}".replace(",", " ")386 if tikz:387 for n in ("rez", "etage", "soussol"):388 print(f"% ---- {n}"); print(tikz_plan(n))389 return {}390 print("== DONNÉES SYNTHÉTIQUES — sujet cas 1 : plan coté", PLAN["L"], "×", PLAN["W"], "m ; garage", PLAN["gx"], "×", PLAN["gy"], "m")391 print(f"== Empreinte (aire au sol MEFQ) {e['aire_au_sol']:.2f} m² ; périmètre au sol {e['perimetre']:.2f} m ; garage {e['garage']:.2f} m² ; sous-sol {e['aire_sous_sol']:.2f} m²")392 print(f"== Aire des étages (MEFQ, 2 étages) {S['aire_etages_mefq']:.2f} m² = {S['aire_etages_mefq']*PI2_PAR_M2:.0f} pi² ; périmètre des étages {S['perimetre_etages_mefq']:.2f} m")393 print(f"== Superficie habitable (brute hors garage) {S['habitable']:.2f} m² = {S['habitable']*PI2_PAR_M2:.0f} pi² ; sujet.json : {sujet['superficie_habitable_m2']} m²")394 print(f"== Aire nette intérieure hors sol {S['nette_hors_sol']:.2f} m² (rez {S['nette_rez']:.2f} + étage {S['nette_etage']:.2f}, trémie {S['trémie']:.2f} déduite) ; sous-sol net {S['sous_sol_net']:.2f} m², fini net {S['sous_sol_fini_net']:.2f} m²")395 print(f"== Cloisons : rez {cloisons('rez'):.2f} m ; étage {cloisons('etage'):.2f} m ; sous-sol {cloisons('soussol'):.2f} m ; séparation garage {separation_garage():.2f} m")396 print("== Pièces (aire brute / nette, m²) :")397 for _, r in S["pieces"].iterrows():398 print(f" {r['niveau']:8s} {r['piece']:24s} {r['fini']:10s} {r['brute']:6.2f} {r['nette']:6.2f} ({r['largeur']:.2f} × {r['profondeur']:.2f})")399 print("== Quantités géométriques (détail) :")400 for k, v in q.items():401 print(f" {k:56s} {v['q']:9.2f} {v['regle']}")402 if v["detail"]:403 print(" ", {kk: (round(vv, 2) if isinstance(vv, float) else vv) for kk, vv in v["detail"].items()})404 print("== Métré de contrôle (devis vs recalcul) :")405 for _, r in V.iterrows():406 if r["classe"] != "forfait":407 print(f" {r['poste']:56s} {r['unite']:8s} devis {r['q_devis']:8.2f} calc {r['q_calc']:8.2f} écart {r['ecart']*100:+6.1f} % {r['verdict']:11s} Δ$ {r['delta_montant']:+9.2f}")408 nb = V["verdict"].value_counts().to_dict(); print(" verdicts :", nb)409 print(f"== Effet du métré de contrôle : ΔD = {f(eff['delta_D'])} $ ({eff['part_D']*100:+.2f} % de D) ; D' = {f(eff['D2'])} $ ; C_N' = {f(eff['CN2'])} $ (Δ {f(eff['delta_CN'])} $, {eff['part_CN']*100:+.2f} %) ; multiplicateur (1+τI)(1+τE) = {eff['mult']:.4f}")410 print(f"== Rappel ch. 21 : D = {f(cn['direct'])} $, I = {f(cn['indirects'])} $, C_N = {f(cn['total'])} $")411 print("== Sensibilité aux erreurs de quantité (ΔC_N en $, par corps, erreur uniforme) :")412 for _, r in sens.iterrows():413 print(f" {r['corps']:28s} part de D {r['part_D']*100:5.1f} % " + " ".join(f"{c[4:]}: {r[c]:+9.0f} ({r['pct_'+c[4:]]*100:+.2f} %)" for c in sens.columns if c.startswith("dCN_")))414 print(f"== Propagation (σ = 5 % par poste direct, indépendants) : Herfindahl {prop['herfindahl']:.4f} (≈ {prop['equiv_uniforme']:.1f} postes équivalents), σ_D/D = {prop['sigma_D_rel']*100:.2f} %, σ_D = {f(prop['sigma_D'])} $ ; poste max {prop['poste_max']} ({prop['max_part']*100:.1f} %) ; si erreurs parfaitement corrélées : {prop['sigma_si_parfaitement_correle']*100:.1f} %")415 print("== Pertes et chutes (hypothèses pédagogiques) :")416 for _, r in per.iterrows():417 print(f" {r['poste']:56s} {r['q_mesuree']:7.1f} {r['unite']:6s} × (1 + {r['taux']*100:.0f} %) = {r['q_commander']:7.1f} (surplus {r['surplus']:.1f})")418 print(f" surplus total valorisé au prix posé : {f(per['cout_pertes_si_prix_pose'].sum())} $")419 print("== Répartition par corps et par type d'unité ($) :"); print(rep.round(2).to_string())420 tot = rep.sum(); print(" totaux :", {k: round(v, 2) for k, v in tot.items()}, " parts :", {k: f"{v/tot.sum()*100:.1f} %" for k, v in tot.items()})421 return {"devis": devis, "sujet": sujet, "empreinte": e, "superficies": S, "quantites": q, "verification": V, "effet": eff,422 "sensibilite": sens, "propagation": prop, "pertes": per, "repartition": rep, "cout_neuf": cn}423424425if __name__ == "__main__":426 main(tikz="--tikz" in sys.argv)427