SPB Git
16.7 KB · 231 lines python
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
Files · main
Menu

Theme