spb/traite-evaluation-qc
Public
1#!/usr/bin/env python32"""Chapitre 21 — coût neuf (remplacement) du sujet du cas 1 (Gatineau, DONNÉES SYNTHÉTIQUES) et indexation IPCB (données réelles).34Imprime tous les chiffres cités dans le chapitre 21 :5 * devis par composantes (data/synthetic/cas1_gatineau_couts.csv, 38 postes) regroupé par corps de métier ;6 * coûts directs, indirects, incitatif de l'entrepreneur (traite.cout.cout_neuf), coût neuf total, $/m² et $/pi² ;7 * comparaison unitaire avec la fourchette Altus 2026 (155-215 $/pi², Montréal, unifamiliale) ;8 * indexation par ratio d'indices IPCB Montréal (Statistique Canada 18-10-0289-01, données réelles dans data/raw) ;9 * ajustement de taille (élasticité) ;10 * analyse de sensibilité (taux d'indirects, incitatif, indice, élasticité).1112Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch21_cout_neuf.py13Toutes les fonctions financières proviennent de analysis/python/traite/cout.py (aucune réécriture)."""14from __future__ import annotations15import sys, json, csv, pathlib16import numpy as np, pandas as pd17sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))18from traite import cout as ct1920ROOT = pathlib.Path(__file__).resolve().parents[3]21D = ROOT / "data/synthetic"22RAW = ROOT / "data/raw/statcan_18100289/18100289.csv"2324PI2_PAR_M2 = 10.763925TAUX_INCITATIF = 0.10 # hypothèse pédagogique (voir texte, §21.6) ; assiette : directs + indirects26ALTUS_MIN, ALTUS_MAX = 155.0, 215.0 # $/pi², Altus Canadian Cost Guide 2026, Montréal, unifamiliale (tel que relayé)2728# Regroupement des 38 postes du devis en sept corps de métier (convention du chapitre)29CORPS = {30 "Excavation et remblai": "Fondations et site", "Fondations béton (semelles, murs)": "Fondations et site",31 "Dalle de sous-sol": "Fondations et site", "Drain français et imperméabilisation": "Fondations et site",32 "Raccordements aqueduc/égout": "Fondations et site",33 "Charpente murs et planchers": "Structure et charpente", "Fermes de toit": "Structure et charpente",34 "Escaliers": "Structure et charpente",35 "Couverture bardeaux d'asphalte": "Enveloppe", "Revêtement extérieur (brique 40 % / fibrociment 60 %)": "Enveloppe",36 "Isolation et pare-vapeur": "Enveloppe", "Fenêtres (PVC double vitrage)": "Enveloppe", "Portes extérieures": "Enveloppe",37 "Porte de garage": "Enveloppe", "Gouttières et solins": "Enveloppe",38 "Gypse et tirage de joints": "Finition intérieure", "Peinture intérieure": "Finition intérieure",39 "Planchers bois franc": "Finition intérieure", "Planchers céramique": "Finition intérieure",40 "Planchers flottants (sous-sol)": "Finition intérieure", "Portes intérieures et moulures": "Finition intérieure",41 "Armoires de cuisine et comptoirs": "Finition intérieure", "Vanités et accessoires (2 sdb)": "Finition intérieure",42 "Plomberie brute et finition": "Mécanique et électricité", "Chauffe-eau": "Mécanique et électricité",43 "Électricité (entrée 200 A, filage, appareils)": "Mécanique et électricité", "Ventilation (VRC) et conduits": "Mécanique et électricité",44 "Thermopompe centrale et plinthes": "Mécanique et électricité", "Éclairage et finition électrique": "Mécanique et électricité",45 "Balcon et perron": "Aménagements extérieurs", "Entrée asphaltée": "Aménagements extérieurs",46 "Aménagement paysager de base": "Aménagements extérieurs", "Nettoyage et conteneurs": "Aménagements extérieurs",47 "Permis et études (arpentage, sol)": "Coûts indirects", "Supervision de chantier (salaires)": "Coûts indirects",48 "Assurances chantier et cautionnement": "Coûts indirects", "Plans et honoraires professionnels": "Coûts indirects",49 "Financement intérimaire": "Coûts indirects",50}51ORDRE_CORPS = ["Fondations et site", "Structure et charpente", "Enveloppe", "Finition intérieure",52 "Mécanique et électricité", "Aménagements extérieurs", "Coûts indirects"]535455def charger() -> tuple[pd.DataFrame, dict]:56 devis = pd.read_csv(D / "cas1_gatineau_couts.csv")57 devis["corps"] = devis["poste"].map(CORPS)58 assert devis["corps"].notna().all(), "poste sans corps de métier"59 sujet = json.loads((D / "cas1_gatineau_sujet.json").read_text())60 return devis, sujet616263def regrouper(devis: pd.DataFrame) -> pd.DataFrame:64 g = devis.groupby("corps", sort=False)["montant"].agg(["sum", "count"]).reindex(ORDRE_CORPS)65 g.columns = ["montant", "postes"]66 g["part_direct_indirect"] = g["montant"] / g["montant"].sum()67 return g686970def cout_neuf_sujet(devis: pd.DataFrame, taux_incitatif: float = TAUX_INCITATIF, base: str = "direct+indirect") -> dict:71 """Coût neuf du sujet : les indirects viennent du devis (5 postes), l'incitatif est appliqué par traite.cout.cout_neuf."""72 direct = float(devis.loc[~devis["indirect"], "montant"].sum())73 indirects = float(devis.loc[devis["indirect"], "montant"].sum())74 taux_ind = indirects / direct75 cn = ct.cout_neuf(direct, taux_ind, taux_incitatif, base)76 cn["taux_indirects"] = taux_ind77 cn["taux_incitatif"] = taux_incitatif78 return cn798081def superficies(sujet: dict, devis: pd.DataFrame) -> dict:82 """Superficie habitable (JSON du sujet) et aire brute des étages (hypothèse : empreinte = aire de la dalle de sous-sol × 2 étages)."""83 hab = float(sujet["superficie_habitable_m2"])84 empreinte = float(devis.loc[devis["poste"] == "Dalle de sous-sol", "quantite"].iloc[0])85 brute = 2.0 * empreinte # cottage à deux étages hors sol (type « à étages »)86 return {"habitable_m2": hab, "habitable_pi2": hab * PI2_PAR_M2, "empreinte_m2": empreinte,87 "brute_etages_m2": brute, "brute_etages_pi2": brute * PI2_PAR_M2}888990# ----------------------------------------------------------------------------- IPCB (données réelles)9192def ipcb_montreal(types: tuple[str, ...] = ("Bâtiments résidentiels [621]", "Maison individuelle [621112]",93 "Immeuble à hauteur restreinte (moins de cinq étages) [62111]",94 "Tour d'habitations (cinq étages ou plus) [621111]",95 "Bâtiments non résidentiels [622]")) -> pd.DataFrame:96 """Séries trimestrielles IPCB (2023 = 100), Montréal, agrégat des divisions, lues dans le CSV brut de Statistique Canada."""97 rows = []98 with open(RAW, encoding="utf-8-sig") as f:99 for r in csv.DictReader(f, delimiter=";"):100 if r["GÉO"] == "Montréal, Québec" and r["Division"] == "Agrégat des divisions" and r["Type d'immeuble"] in types and r["VALEUR"]:101 rows.append({"periode": r["PÉRIODE DE RÉFÉRENCE"], "type": r["Type d'immeuble"], "indice": float(r["VALEUR"]), "vecteur": r["VECTEUR"]})102 df = pd.DataFrame(rows)103 df["date"] = pd.PeriodIndex(df["periode"].str.replace("-01", "Q1").str.replace("-04", "Q2").str.replace("-07", "Q3").str.replace("-10", "Q4"), freq="Q").to_timestamp()104 return df.sort_values(["type", "date"]).reset_index(drop=True)105106107def indice(df: pd.DataFrame, type_: str, periode: str) -> float:108 v = df[(df["type"] == type_) & (df["periode"] == periode)]["indice"]109 if v.empty:110 raise KeyError(f"{type_} {periode} absent")111 return float(v.iloc[0])112113114# ----------------------------------------------------------------------------- sensibilité115116def grille_sensibilite(direct: float, taux_ind: np.ndarray, taux_inc: np.ndarray) -> np.ndarray:117 """Coût neuf (assiette directs + indirects) pour chaque couple (taux d'indirects, taux d'incitatif)."""118 return np.array([[ct.cout_neuf(direct, ti, tc)["total"] for tc in taux_inc] for ti in taux_ind])119120121def tornade(direct: float, taux_ind0: float, taux_inc0: float, ratio_idx: tuple[float, float], elast: tuple[float, float],122 d_ind: float = 0.05, d_inc: float = 0.05) -> list[dict]:123 """Variation du coût neuf quand un seul paramètre bouge (bas / haut), les autres restant à leur valeur centrale."""124 base = ct.cout_neuf(direct, taux_ind0, taux_inc0)["total"]125 out = [126 {"param": "Taux d'indirects", "bas": ct.cout_neuf(direct, taux_ind0 - d_ind, taux_inc0)["total"], "haut": ct.cout_neuf(direct, taux_ind0 + d_ind, taux_inc0)["total"],127 "etiquette": f"{(taux_ind0-d_ind)*100:.1f} % → {(taux_ind0+d_ind)*100:.1f} %"},128 {"param": "Taux d'incitatif", "bas": ct.cout_neuf(direct, taux_ind0, taux_inc0 - d_inc)["total"], "haut": ct.cout_neuf(direct, taux_ind0, taux_inc0 + d_inc)["total"],129 "etiquette": f"{(taux_inc0-d_inc)*100:.0f} % → {(taux_inc0+d_inc)*100:.0f} %"},130 {"param": "Indice de coûts (date)", "bas": ct.indexer_cout(base, 1.0, ratio_idx[0]), "haut": ct.indexer_cout(base, 1.0, ratio_idx[1]),131 "etiquette": f"×{ratio_idx[0]:.3f} → ×{ratio_idx[1]:.3f}"},132 {"param": "Élasticité de taille", "bas": base * ct.cout_par_superficie_ajuste(1.0, 140.0, 165.0, elast[0]) / ct.cout_par_superficie_ajuste(1.0, 140.0, 165.0, -0.10),133 "haut": base * ct.cout_par_superficie_ajuste(1.0, 140.0, 165.0, elast[1]) / ct.cout_par_superficie_ajuste(1.0, 140.0, 165.0, -0.10),134 "etiquette": f"{elast[0]:.2f} → {elast[1]:.2f}"},135 ]136 for o in out:137 o["base"] = base138 return out139140141def main() -> dict:142 devis, sujet = charger()143 g = regrouper(devis)144 cn = cout_neuf_sujet(devis)145 sup = superficies(sujet, devis)146 f = lambda x: f"{x:,.2f}".replace(",", " ")147 print("== DONNÉES SYNTHÉTIQUES — sujet cas 1 :", sujet["type"], sujet["annee_construction"], sujet["superficie_habitable_m2"], "m² hab.,", sujet["secteur"])148 print(f"== Devis : {len(devis)} postes ; {int((~devis['indirect']).sum())} directs, {int(devis['indirect'].sum())} indirects")149 print("== Regroupement par corps de métier ($) :")150 for k, r in g.iterrows():151 print(f" {k:28s} {int(r['postes']):2d} postes {f(r['montant']):>14s} {r['part_direct_indirect']*100:5.1f} %")152 print(f"== Coûts directs D = {f(cn['direct'])} $")153 print(f"== Coûts indirects I = {f(cn['indirects'])} $ (I/D = {cn['taux_indirects']*100:.2f} % ; I/(D+I) = {cn['indirects']/(cn['direct']+cn['indirects'])*100:.2f} %)")154 print(f"== Sous-total D + I = {f(cn['direct']+cn['indirects'])} $")155 print(f"== Incitatif {TAUX_INCITATIF*100:.0f} % (D+I) = {f(cn['incitatif'])} $")156 print(f"== COÛT NEUF C_N = {f(cn['total'])} $ (arrondi {round(cn['total']/1000)*1000:,.0f} $)".replace(",", " "))157 print(f" part directs {cn['direct']/cn['total']*100:.1f} %, indirects {cn['indirects']/cn['total']*100:.1f} %, incitatif {cn['incitatif']/cn['total']*100:.1f} %")158 alt = cout_neuf_sujet(devis, TAUX_INCITATIF, "direct")159 print(f"== Variante assiette 'direct' : incitatif {f(alt['incitatif'])} $, C_N {f(alt['total'])} $ (écart {f(cn['total']-alt['total'])} $)")160 print(f"== Superficies : habitable {sup['habitable_m2']:.0f} m² = {sup['habitable_pi2']:.0f} pi² ; empreinte {sup['empreinte_m2']:.0f} m² ; aire brute des étages {sup['brute_etages_m2']:.0f} m² = {sup['brute_etages_pi2']:.0f} pi²")161 um_hab = cn["total"] / sup["habitable_m2"]; um_brut = cn["total"] / sup["brute_etages_m2"]162 print(f"== C_N / m² habitable = {um_hab:,.0f} $/m² = {um_hab/PI2_PAR_M2:,.1f} $/pi²".replace(",", " "))163 print(f"== C_N / m² brut = {um_brut:,.0f} $/m² = {um_brut/PI2_PAR_M2:,.1f} $/pi²".replace(",", " "))164 di = cn["direct"] + cn["indirects"]165 print(f"== (D+I) / m² habitable = {di/sup['habitable_m2']:,.0f} $/m² = {di/sup['habitable_m2']/PI2_PAR_M2:,.1f} $/pi² ; (D+I)/m² brut = {di/sup['brute_etages_m2']:,.0f} $/m² = {di/sup['brute_etages_m2']/PI2_PAR_M2:,.1f} $/pi²".replace(",", " "))166 # comparaison unitaire Altus167 alt_mid = 0.5 * (ALTUS_MIN + ALTUS_MAX)168 print(f"== Comparaison unitaire Altus 2026 ({ALTUS_MIN:.0f}-{ALTUS_MAX:.0f} $/pi², milieu {alt_mid:.0f}) :")169 for nom, s_pi2 in [("aire brute des étages", sup["brute_etages_pi2"]), ("superficie habitable", sup["habitable_pi2"])]:170 print(f" sur {nom} ({s_pi2:.0f} pi²) : {ALTUS_MIN*s_pi2:,.0f} – {ALTUS_MAX*s_pi2:,.0f} $, milieu {alt_mid*s_pi2:,.0f} $".replace(",", " "))171 print(f" milieu Altus en $/m² : {alt_mid*PI2_PAR_M2:,.0f} $/m²".replace(",", " "))172 # indexation IPCB173 ipcb = ipcb_montreal()174 res, mi = "Bâtiments résidentiels [621]", "Maison individuelle [621112]"175 print("== IPCB Montréal (2023 = 100), données réelles Statistique Canada 18-10-0289-01 :")176 for t in ipcb["type"].unique():177 s = ipcb[ipcb["type"] == t]178 print(f" {t:60s} {len(s)} trimestres, {s['periode'].iloc[0]} → {s['periode'].iloc[-1]} ; premier {s['indice'].iloc[0]}, dernier {s['indice'].iloc[-1]} ({s['vecteur'].iloc[0]})")179 pts = {p: indice(ipcb, mi, p) for p in ["2017-01", "2019-07", "2021-07", "2023-07", "2025-07", "2026-04", "2026-07"]}180 print(" Maison individuelle :", pts)181 ptr = {p: indice(ipcb, res, p) for p in ["2017-01", "2019-07", "2021-07", "2023-07", "2025-07", "2026-07"]}182 print(" Bâtiments résidentiels :", ptr)183 var_an = indice(ipcb, res, "2026-07") / indice(ipcb, res, "2025-07") - 1184 var_an_mi = indice(ipcb, mi, "2026-07") / indice(ipcb, mi, "2025-07") - 1185 print(f" Variation annuelle T3 2025 → T3 2026 : résidentiel {var_an*100:.2f} %, maison individuelle {var_an_mi*100:.2f} %")186 r_2017 = indice(ipcb, mi, "2026-07") / indice(ipcb, mi, "2017-01")187 r_2019 = indice(ipcb, mi, "2026-07") / indice(ipcb, mi, "2019-07")188 r_2021 = indice(ipcb, mi, "2026-07") / indice(ipcb, mi, "2021-07")189 print(f" Ratios maison individuelle : T3 2026 / T1 2017 = {r_2017:.4f} ; / T3 2019 = {r_2019:.4f} ; / T3 2021 = {r_2021:.4f}")190 # taux annuel composé 2017T1→2026T3 (9,5 ans)191 n_ans = (2026 + 6 / 12) - (2017 + 0 / 12)192 print(f" Croissance annuelle composée T1 2017 → T3 2026 ({n_ans:.1f} ans) : {(r_2017**(1/n_ans)-1)*100:.2f} %/an")193 # exemple d'indexation : coût du sujet exprimé en dollars du T3 2019 et du T3 2021 (rétropolation)194 c_2019 = ct.indexer_cout(cn["total"], indice(ipcb, mi, "2026-07"), indice(ipcb, mi, "2019-07"))195 c_2021 = ct.indexer_cout(cn["total"], indice(ipcb, mi, "2026-07"), indice(ipcb, mi, "2021-07"))196 print(f" C_N du sujet en dollars du T3 2019 : {c_2019:,.0f} $ ; du T3 2021 : {c_2021:,.0f} $".replace(",", " "))197 # exemple direct : un devis de 2019T3 de 290 000 $ porté au T3 2026198 c0 = 290_000.0199 print(f" Exemple indexation : {c0:,.0f} $ (T3 2019) × {indice(ipcb, mi, '2026-07')}/{indice(ipcb, mi, '2019-07')} = {ct.indexer_cout(c0, indice(ipcb, mi, '2019-07'), indice(ipcb, mi, '2026-07')):,.0f} $".replace(",", " "))200 # erreur de série : non résidentiel et tours comme mauvais substituts201 for t in ["Bâtiments non résidentiels [622]", "Tour d'habitations (cinq étages ou plus) [621111]", "Immeuble à hauteur restreinte (moins de cinq étages) [62111]"]:202 r = indice(ipcb, t, "2026-07") / indice(ipcb, t, "2019-07")203 print(f" Ratio T3 2026 / T3 2019 avec {t} : {r:.4f} (écart vs maison individuelle {(r/r_2019-1)*100:+.1f} %)")204 # ajustement de taille205 print("== Ajustement de taille (élasticité) :")206 for e in [-0.05, -0.10, -0.15, -0.20]:207 u = ct.cout_par_superficie_ajuste(um_hab, 165.0, 140.0, e) # coût unitaire d'une maison de 140 m² déduit du sujet208 u2 = ct.cout_par_superficie_ajuste(um_hab, 165.0, 200.0, e)209 print(f" e = {e:+.2f} : 140 m² → {u:,.0f} $/m² ({u*140:,.0f} $) ; 200 m² → {u2:,.0f} $/m² ({u2*200:,.0f} $)".replace(",", " "))210 # exemple du texte : coût unitaire de référence 2 300 $/m² observé sur 200 m², appliqué à 165 m²211 u_ref = 2300.0212 for e in [0.0, -0.10]:213 u = ct.cout_par_superficie_ajuste(u_ref, 200.0, 165.0, e)214 print(f" Référence {u_ref:.0f} $/m² (200 m²) → 165 m² avec e = {e:+.2f} : {u:,.1f} $/m², coût {u*165:,.0f} $".replace(",", " "))215 # sensibilité216 print("== Sensibilité (assiette directs + indirects) :")217 ti = np.array([0.12, 0.15, 0.18, 0.2095, 0.24, 0.27]); tc = np.array([0.05, 0.08, 0.10, 0.12, 0.15])218 G = grille_sensibilite(cn["direct"], ti, tc)219 print(" indirects \\ incitatif", [f"{x*100:.0f} %" for x in tc])220 for i, x in enumerate(ti):221 print(f" {x*100:5.1f} % ", [f"{v:,.0f}".replace(",", " ") for v in G[i]])222 print(f" Étendue de la grille : {G.min():,.0f} – {G.max():,.0f} $ ({(G.max()/G.min()-1)*100:.1f} %)".replace(",", " "))223 torn = tornade(cn["direct"], cn["taux_indirects"], TAUX_INCITATIF, (1 / (1 + var_an_mi), 1 + var_an_mi), (-0.05, -0.20))224 for o in torn:225 print(f" {o['param']:24s} {o['etiquette']:22s} bas {o['bas']:,.0f} haut {o['haut']:,.0f} (base {o['base']:,.0f}) ; étendue {(o['haut']-o['bas'])/o['base']*100:+.1f} %".replace(",", " "))226 return {"devis": devis, "sujet": sujet, "groupes": g, "cout_neuf": cn, "superficies": sup, "ipcb": ipcb, "grille": (ti, tc, G), "tornade": torn}227228229if __name__ == "__main__":230 main()231