"""Tests du chapitre 23 — coûts directs et indirects : structure du devis du cas 1, financement intérimaire (traite.finance), taxes de vente, incitatif, IPCB par région/type/division (données réelles), multilogement (cas 3) et exercices. Tolérances : 0,01 $ sur les montants recalculés depuis le devis ; 0,5 $ (ou 1 $ sur les milliers arrondis) sur les montants cités ; 1e-9 sur les identités ; valeurs IPCB exactes (lecture du CSV brut de Statistique Canada).""" import sys, pathlib, math import numpy as np, pytest sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) import ch21_cout_neuf as c21 import ch23_structure_couts as cas from traite import cout as ct, finance as fin @pytest.fixture(scope="module") def data(): devis, sujet = c21.charger() cn = c21.cout_neuf_sujet(devis) sup = c21.superficies(sujet, devis) return {"devis": devis, "sujet": sujet, "cn": cn, "sup": sup, "st": cas.structure(devis, cn), "ri": cas.ratios_indirects(devis, cn), "i": cas.taux_affiche_5ans(), "ipcb": cas.ipcb_long(), "ipcb21": c21.ipcb_montreal()} class TestStructure: def test_coherence_ch21(self, data): cn = data["cn"] assert cn["direct"] == pytest.approx(298826.17, abs=0.01) and cn["indirects"] == pytest.approx(62598.68, abs=0.01) assert cn["incitatif"] == pytest.approx(36142.49, abs=0.01) and cn["total"] == pytest.approx(397567.33, abs=0.01) assert cn["taux_indirects"] == pytest.approx(0.2095, abs=5e-5) def test_structure_complete(self, data): st, cn = data["st"], data["cn"] assert len(st) == 38 and st["nature"].notna().all() assert st.loc[~st["indirect"], "part_bloc"].sum() == pytest.approx(1.0, abs=1e-9) assert st.loc[st["indirect"], "part_bloc"].sum() == pytest.approx(1.0, abs=1e-9) assert st["part_CN"].sum() == pytest.approx((cn["direct"] + cn["indirects"]) / cn["total"], abs=1e-9) assert st["part_CN"].sum() + cn["incitatif"] / cn["total"] == pytest.approx(1.0, abs=1e-9) sup_ = st[st["poste"] == "Supervision de chantier (salaires)"].iloc[0] assert sup_["part_bloc"] == pytest.approx(0.5704, abs=5e-4) and sup_["part_CN"] == pytest.approx(0.0898, abs=5e-4) def test_ratios_indirects(self, data): ri = data["ri"] attendu = {"Supervision de chantier (salaires)": (35708.66, 11.95, 9.88, 8.98), "Plans et honoraires professionnels": (10119.90, 3.39, 2.80, 2.55), "Financement intérimaire": (8529.63, 2.85, 2.36, 2.15), "Permis et études (arpentage, sol)": (4481.67, 1.50, 1.24, 1.13), "Assurances chantier et cautionnement": (3758.82, 1.26, 1.04, 0.95)} for _, r in ri.iterrows(): m, a, b, c = attendu[r["poste"]] assert r["montant"] == pytest.approx(m, abs=0.01) assert r["pct_D"] * 100 == pytest.approx(a, abs=0.005) and r["pct_DI"] * 100 == pytest.approx(b, abs=0.005) and r["pct_CN"] * 100 == pytest.approx(c, abs=0.005) assert ri["pct_D"].sum() == pytest.approx(0.2095, abs=5e-5) and ri["pct_DI"].sum() == pytest.approx(0.1732, abs=5e-5) and ri["pct_CN"].sum() == pytest.approx(0.1575, abs=5e-5) assert (ri["pct_D"].sum() - 0.1195) * 100 == pytest.approx(9.00, abs=0.01) def test_parts_nature(self, data): pn = cas.parts_nature(data["st"]) assert pn.sum() == pytest.approx(data["cn"]["direct"], abs=0.01) assert pn["mixte"] / data["cn"]["direct"] == pytest.approx(0.645, abs=5e-4) assert pn["matériaux"] / data["cn"]["direct"] == pytest.approx(0.217, abs=5e-4) assert pn["main-d'œuvre"] / data["cn"]["direct"] == pytest.approx(0.078, abs=5e-4) class TestFinancement: def test_taux_documentes(self, data): assert data["i"] == pytest.approx(0.0619, abs=1e-9) fa = cas.taux_fonds_avances() assert fa["non_assure_fixe_5ans_plus"] == pytest.approx(0.0458, abs=1e-9) and fa["non_assure_total"] == pytest.approx(0.0434, abs=1e-9) assert fa["assure_total"] == pytest.approx(0.0419, abs=1e-9) and fa["non_assure_variable"] == pytest.approx(0.0390, abs=1e-9) def test_formules_interets(self): # continu : L i T / 2 ; mensuel sans capitalisation : L i T / 2 × (n + 1) / n ; capitalisation > simple ; mise de fonds d'abord L, i, T = 100000.0, 0.06, 1.0 c = fin.interets_intermediaires(L, i, T)["interets"] assert c == pytest.approx(3000.0, abs=1e-9) m = fin.interets_intermediaires(L, i, T, "mensuel")["interets"] assert m == pytest.approx(3000.0 * 13 / 12, abs=1e-9) k = fin.interets_intermediaires(L, i, T, "mensuel", capitaliser=True)["interets"] assert k > m e = fin.interets_intermediaires(0.75 * L, i, T, "continu", mise_de_fonds=0.25 * L, cout_total=L) assert e["t0"] == pytest.approx(0.25, abs=1e-12) and e["interets"] == pytest.approx(0.75 * L * i * 0.75 / 2, abs=1e-9) assert fin.taux_duree_implicites(3000.0, L) == pytest.approx(0.06, abs=1e-12) with pytest.raises(ValueError): fin.interets_intermediaires(L, i, 0.0) def test_financement_cas1(self, data): fc = cas.financement_cas1(data["devis"], data["cn"], data["i"]) assert fc["forfait"] == pytest.approx(8529.63, abs=0.01) and fc["base_hors_financement"] == pytest.approx(352895.22, abs=0.01) assert fc["interets_continu"] == pytest.approx(5461.05, abs=0.01) and fc["interets_mensuel"] == pytest.approx(6371.23, abs=0.01) assert fc["interets_capitalise"] == pytest.approx(6426.29, abs=0.01) assert fc["iT_implicite"] == pytest.approx(0.04834, abs=5e-6) and fc["duree_implicite_mois"] == pytest.approx(9.4, abs=0.05) assert fc["taux_implicite_6mois"] == pytest.approx(0.0967, abs=5e-5) cn6 = cas.cout_neuf_avec_financement(data["devis"], data["cn"], fc["interets_continu"]) assert cn6["total"] == pytest.approx(394191.90, abs=0.01) and cn6["total"] - data["cn"]["total"] == pytest.approx(-3375.43, abs=0.01) # identité : remplacer le forfait par lui-même redonne le coût neuf du ch. 21 assert cas.cout_neuf_avec_financement(data["devis"], data["cn"], fc["forfait"])["total"] == pytest.approx(data["cn"]["total"], rel=1e-12) def test_grille(self, data): fc = cas.financement_cas1(data["devis"], data["cn"], data["i"]) G = cas.grille_financement(fc["base_hors_financement"], [6, 9, 12, 18], [0.04, 0.05, 0.0619, 0.07, 0.09]) assert G[0, 0] == pytest.approx(3529, abs=1) and G[0, 2] == pytest.approx(5461, abs=1) and G[3, 4] == pytest.approx(23820, abs=1) cn18 = cas.cout_neuf_avec_financement(data["devis"], data["cn"], G[3, 4])["total"] assert cn18 == pytest.approx(414387, abs=1) # linéarité en durée et en taux assert G[2, 0] == pytest.approx(2 * G[0, 0], rel=1e-12) and G[0, 4] == pytest.approx(G[0, 0] * 9 / 4, rel=1e-12) class TestTaxes: def test_profils(self, data): tp = cas.taxes_profils(data["cn"]["total"]) occ, prem, inv = (tp.iloc[k] for k in range(3)) assert occ["prix_avant_taxes"] == pytest.approx(577567.33, abs=0.01) assert occ["tps"] == pytest.approx(28878.37, abs=0.01) and occ["tvq"] == pytest.approx(57612.34, abs=0.01) assert occ["remb_tps"] == 0.0 and occ["remb_tvq"] == 0.0 and occ["taxes_nettes"] == pytest.approx(86490.71, abs=0.01) assert occ["cout_ttc"] == pytest.approx(664058, abs=1) and occ["taux_effectif"] == pytest.approx(0.14975, abs=1e-9) assert prem["remb_tps"] == pytest.approx(prem["tps"], rel=1e-12) and prem["taxes_nettes"] == pytest.approx(57612.34, abs=0.01) assert prem["cout_ttc"] == pytest.approx(635180, abs=1) assert inv["taxes_nettes"] == 0.0 and inv["cout_ttc"] == pytest.approx(577567.33, abs=0.01) assert data["cn"]["total"] * (cas.TPS + cas.TVQ) == pytest.approx(59536, abs=1) def test_remboursements(self): r = cas.remboursement_tps(340000.0) assert r["remboursement_general"] == pytest.approx(6120.0, abs=1e-9) and not r["interpolation_appliquee"] assert cas.remboursement_tps(350000.0)["remboursement_general"] == pytest.approx(6300.0, abs=1e-9) r4 = cas.remboursement_tps(400000.0) assert r4["remboursement_general"] == pytest.approx(3150.0, abs=1e-9) and r4["interpolation_appliquee"] assert cas.remboursement_tps(450000.0)["remboursement_general"] == 0.0 and cas.remboursement_tps(600000.0)["remboursement_general"] == 0.0 assert cas.remboursement_tps(900000.0, premier_acheteur=True)["remboursement"] == pytest.approx(45000.0, abs=1e-9) assert cas.remboursement_tps(1250000.0, premier_acheteur=True)["remboursement"] == pytest.approx(0.5 * 62500.0, abs=1e-9) assert cas.remboursement_tps(1500000.0, premier_acheteur=True)["remboursement"] == 0.0 assert cas.remboursement_tps(900000.0, premier_acheteur=True, travaux_apres_20mars2025=False)["remboursement"] == 0.0 assert cas.remboursement_tvq(250000.0)["remboursement"] == pytest.approx(4987.5, abs=1e-9) assert cas.remboursement_tvq(150000.0)["remboursement"] == pytest.approx(0.5 * 0.09975 * 150000.0, abs=1e-9) assert cas.remboursement_tvq(300000.0)["remboursement"] == 0.0 class TestIncitatif: def test_marges(self): assert fin.marge_sur_prix(0.10) == pytest.approx(1 / 11, abs=1e-12) assert fin.marge_sur_cout(fin.marge_sur_prix(0.137)) == pytest.approx(0.137, abs=1e-12) with pytest.raises(ValueError): fin.marge_sur_cout(1.0) def test_rendement_implicite(self, data): cn = data["cn"] rho = cas.incitatif_rendement_implicite(cn["incitatif"], 180000.0, cn["direct"] + cn["indirects"], 0.75) assert rho == pytest.approx(0.134, abs=5e-4) assert cas.incitatif_rendement_implicite(cn["incitatif"], 180000.0, cn["direct"] + cn["indirects"], 0.5) == pytest.approx(0.200, abs=5e-4) def test_hausse_salariale(self, data): D = data["cn"]["direct"] assert cas.effet_hausse_salariale(D, 0.40, 0.08) == pytest.approx(9562, abs=1) assert cas.effet_hausse_salariale(D, 0.35, 0.08) == pytest.approx(8367, abs=1) and cas.effet_hausse_salariale(D, 0.50, 0.08) == pytest.approx(11953, abs=1) assert cas.effet_hausse_salariale(D, 0.40, 0.08) * (1 + data["cn"]["taux_indirects"]) * 1.10 == pytest.approx(12722, abs=1) class TestIPCB: def test_couverture(self, data): df = data["ipcb"] q = df[(df["geo"] == "Québec, Québec") & (df["type"] == "Bâtiments résidentiels [621]")] assert q["periode"].iloc[0] == "2023-01" and len(q) == 15 m = df[(df["geo"] == "Montréal, Québec") & (df["type"] == "Maison individuelle [621112]")] assert m["periode"].iloc[0] == "2017-01" and m["periode"].iloc[-1] == "2026-07" def test_valeurs_tableau(self, data): df = data["ipcb"]; v = cas.val assert v(df, "Montréal, Québec", "Bâtiments résidentiels [621]", "2026-07") == 117.8 and v(df, "Montréal, Québec", "Bâtiments résidentiels [621]", "2025-07") == 108.5 assert v(df, "Montréal, Québec", "Maison individuelle [621112]", "2026-07") == 116.5 and v(df, "Montréal, Québec", "Maison en rangée [621112]", "2026-07") == 116.2 assert v(df, "Montréal, Québec", "Immeuble à hauteur restreinte (moins de cinq étages) [62111]", "2026-07") == 118.5 assert v(df, "Montréal, Québec", "Tour d'habitations (cinq étages ou plus) [621111]", "2026-07") == 119.5 assert v(df, "Montréal, Québec", "Immeubles d'appartements", "2026-07") == 118.2 and v(df, "Montréal, Québec", "Bâtiments non résidentiels [622]", "2026-07") == 114.4 assert v(df, "Québec, Québec", "Bâtiments résidentiels [621]", "2026-07") == 124.8 and v(df, "Québec, Québec", "Bâtiments résidentiels [621]", "2023-01") == 98.8 assert v(df, "Ottawa–Gatineau, partie ontarienne, Ontario/Québec", "Bâtiments résidentiels [621]", "2026-07") == 110.8 assert v(df, "Composite de quinze régions métropolitaines de recensement", "Bâtiments résidentiels [621]", "2026-07") == 111.4 def test_variations(self, data): tt = cas.tableau_types_regions(data["ipcb"]).set_index(["geo", "type"]) r = tt.loc[("Montréal, Québec", "Bâtiments résidentiels [621]")] assert r["var_aa"] == pytest.approx(0.0857, abs=5e-4) and r["var_2020"] == pytest.approx(0.620, abs=5e-4) and r["depuis_debut"] == pytest.approx(0.798, abs=5e-4) assert tt.loc[("Montréal, Québec", "Immeuble à hauteur restreinte (moins de cinq étages) [62111]"), "var_aa"] == pytest.approx(0.092, abs=5e-4) assert tt.loc[("Québec, Québec", "Bâtiments résidentiels [621]"), "var_aa"] == pytest.approx(0.092, abs=5e-4) assert tt.loc[("Québec, Québec", "Bâtiments résidentiels [621]"), "depuis_debut"] == pytest.approx(0.263, abs=5e-4) assert tt.loc[("Ottawa–Gatineau, partie ontarienne, Ontario/Québec", "Bâtiments résidentiels [621]"), "var_aa"] == pytest.approx(0.046, abs=5e-4) assert tt.loc[("Composite de quinze régions métropolitaines de recensement", "Bâtiments résidentiels [621]"), "var_aa"] == pytest.approx(0.027, abs=5e-4) va = cas.variations_annuelles(data["ipcb"]) mi = va[(va["geo"] == "Montréal, Québec") & (va["type"] == "Maison individuelle [621112]")] assert mi.loc[mi["var_aa"].idxmax(), "periode"] == "2022-01" and mi["var_aa"].max() == pytest.approx(0.189, abs=5e-4) assert mi.loc[mi["var_aa"].idxmin(), "periode"] == "2024-01" and mi["var_aa"].min() == pytest.approx(0.009, abs=5e-4) def test_divisions(self): df_div = cas.ipcb_long(("Montréal, Québec",), ("Maison individuelle [621112]",), None) dv = cas.divisions_maison(df_div).set_index("division") assert dv.loc["Bois, plastiques et composites", "var_2020_2022"] == pytest.approx(0.851, abs=5e-4) and dv.loc["Bois, plastiques et composites", "var_aa"] == pytest.approx(0.087, abs=5e-4) assert dv.loc["Béton", "var_2020_2022"] == pytest.approx(0.290, abs=5e-4) and dv.loc["Béton", "var_aa"] == pytest.approx(0.154, abs=5e-4) assert dv.loc["Agrégat des divisions", "var_2020_2022"] == pytest.approx(0.360, abs=5e-4) and dv.loc["Agrégat des divisions", "var_aa"] == pytest.approx(0.086, abs=5e-4) assert dv.loc["Électricité", "var_2020_2022"] == pytest.approx(0.048, abs=5e-4) and dv.loc["Terrassements", "var_aa"] == pytest.approx(0.017, abs=5e-4) assert dv.loc["Exigences générales", "var_aa"] == pytest.approx(0.062, abs=5e-4) and dv.loc["Maçonnerie", "var_aa"] == pytest.approx(0.116, abs=5e-4) assert len(dv) == 18 and dv.index[0] == "Bois, plastiques et composites" class TestMultilogement: def test_cas3(self, data): ml = cas.multilogement_cas3(data["devis"], data["cn"], data["sup"], data["ipcb21"], data["i"]) assert ml["n_logements"] == 12 and ml["s_locative"] == 825 and ml["s_brute"] == pytest.approx(970.6, abs=0.05) assert ml["ratio_ipcb"] == pytest.approx(118.5 / 116.5, rel=1e-12) and ml["cu_direct_cas1"] == pytest.approx(1572.77, abs=0.01) assert ml["cu_direct"] == pytest.approx(1599.77, abs=0.01) and ml["direct"] == pytest.approx(1552717.60, abs=0.01) assert ml["indirects_detail"]["Supervision de chantier (salaires)"] == pytest.approx(185544.21, abs=0.01) assert ml["financement"]["t0"] * 12 == pytest.approx(3.0, abs=1e-9) and ml["financement"]["interets"] == pytest.approx(31922.93, abs=0.01) assert ml["indirects"] == pytest.approx(312868.76, abs=0.01) and ml["taux_indirects"] == pytest.approx(0.2015, abs=5e-5) assert ml["incitatif"] == pytest.approx(186558.64, abs=0.01) and ml["total"] == pytest.approx(2052145.00, abs=0.01) assert ml["par_logement"] == pytest.approx(171012, abs=1) and ml["par_m2_locatif"] == pytest.approx(2487, abs=1) and ml["par_m2_brut"] == pytest.approx(2114, abs=1) assert ml["total"] == pytest.approx(ml["direct"] * (1 + ml["taux_indirects"]) * 1.10, rel=1e-12) ml18 = cas.multilogement_cas3(data["devis"], data["cn"], data["sup"], data["ipcb21"], 0.07, duree_ans=1.5) assert ml18["financement"]["interets"] == pytest.approx(54150.37, abs=0.01) and ml18["total"] == pytest.approx(2076595.18, abs=0.01) assert ml18["par_logement"] == pytest.approx(173050, abs=1) class TestSensibilite: def test_tornade(self, data): t = {o["param"][:10]: o for o in cas.tornade(data["devis"], data["cn"], data["i"])} assert t["Durée du c"]["bas"] == pytest.approx(394192, abs=1) and t["Durée du c"]["haut"] == pytest.approx(406206, abs=1) assert t["Taux intér"]["bas"] == pytest.approx(395948, abs=1) and t["Taux intér"]["haut"] == pytest.approx(405653, abs=1) assert t["Indirects "]["bas"] == pytest.approx(381132, abs=1) and t["Indirects "]["haut"] == pytest.approx(414003, abs=1) assert t["Hausse sal"]["bas"] == pytest.approx(397567, abs=1) and t["Hausse sal"]["haut"] == pytest.approx(408086, abs=1) assert t["Taux d'inc"]["bas"] == pytest.approx(379496, abs=1) and t["Taux d'inc"]["haut"] == pytest.approx(415639, abs=1) class TestExercices: """Chiffres des solutions (solutions/ch23.tex).""" def test_ex1_classement(self): D = 12000 + 48000 + 16000 + 2600; I = 9500 + 2800 + 3200 + 7400 assert D == 78600 and I == 22900 assert I / D == pytest.approx(0.2913, abs=5e-5) and I / (D + I) == pytest.approx(0.2256, abs=5e-5) cn = ct.cout_neuf(D, I / D, 0.10) assert cn["total"] == pytest.approx(111650.0, abs=1e-6) and cn["incitatif"] == pytest.approx(10150.0, abs=1e-6) def test_ex2_interets(self): c = fin.interets_intermediaires(300000.0, 0.06, 0.75)["interets"] m = fin.interets_intermediaires(300000.0, 0.06, 0.75, "mensuel")["interets"] k = fin.interets_intermediaires(300000.0, 0.06, 0.75, "mensuel", capitaliser=True)["interets"] assert c == pytest.approx(6750.0, abs=1e-9) and m == pytest.approx(7500.0, abs=1e-9) and k == pytest.approx(7600.88, abs=0.01) assert fin.taux_duree_implicites(6750.0, 300000.0) == pytest.approx(0.045, abs=1e-12) def test_ex3_taxes(self): P = 520000.0 assert P * cas.TPS == pytest.approx(26000.0) and P * cas.TVQ == pytest.approx(51870.0) assert cas.remboursement_tps(P)["remboursement_general"] == 0.0 and cas.remboursement_tvq(P)["remboursement"] == 0.0 assert cas.remboursement_tps(P, premier_acheteur=True)["remboursement"] == pytest.approx(26000.0) assert P + 51870.0 == pytest.approx(571870.0) and P + 26000.0 + 51870.0 == pytest.approx(597870.0) assert cas.remboursement_tps(400000.0)["remboursement_general"] == pytest.approx(3150.0) def test_ex4_incitatif(self): P, VL, DI = 640000.0, 170000.0, 410000.0 E = P - VL - DI assert E == 60000.0 mc = E / (VL + DI); mp = E / P assert mc == pytest.approx(0.10345, abs=5e-6) and mp == pytest.approx(0.09375, abs=1e-9) assert fin.marge_sur_prix(mc) == pytest.approx(mp, abs=1e-12) assert cas.incitatif_rendement_implicite(E, VL, DI, 10 / 12) == pytest.approx(0.192, abs=5e-4) def test_ex5_salaires(self, data): D = data["cn"]["direct"]; cum = 1.08 * 1.055 - 1 assert cum == pytest.approx(0.1394, abs=5e-5) dD = cas.effet_hausse_salariale(D, 0.40, cum) assert dD == pytest.approx(16663, abs=1) and dD / D == pytest.approx(0.0558, abs=5e-5) assert D * 0.0857 == pytest.approx(25609, abs=1) assert dD * (1 + data["cn"]["taux_indirects"]) * 1.10 == pytest.approx(22168, abs=1) def test_ex6_mise_de_fonds(self, data): fc = cas.financement_cas1(data["devis"], data["cn"], data["i"]); C = fc["base_hors_financement"] e = fin.interets_intermediaires(0.75 * C, 0.0619, 1.0, "continu", mise_de_fonds=0.25 * C, cout_total=C) assert e["t0"] * 12 == pytest.approx(3.0, abs=1e-9) and e["interets"] == pytest.approx(6143.69, abs=0.01) assert fin.interets_intermediaires(C, 0.0619, 1.0)["interets"] == pytest.approx(10922.11, abs=0.01) assert fin.interets_intermediaires(0.75 * C, 0.0619, 1.0)["interets"] == pytest.approx(8191.58, abs=0.01) k = fin.interets_intermediaires(0.75 * C, 0.0619, 1.0, "mensuel", capitaliser=True, mise_de_fonds=0.25 * C, cout_total=C) assert k["n_mois"] == 9 and k["interets"] == pytest.approx(6921.07, abs=0.01) assert cas.cout_neuf_avec_financement(data["devis"], data["cn"], e["interets"])["total"] == pytest.approx(394942.80, abs=0.01) def test_ex7_multilogement(self, data): ml18 = cas.multilogement_cas3(data["devis"], data["cn"], data["sup"], data["ipcb21"], 0.07, duree_ans=1.5) ml = cas.multilogement_cas3(data["devis"], data["cn"], data["sup"], data["ipcb21"], data["i"]) assert ml18["total"] - ml["total"] == pytest.approx(24450.18, abs=0.01) assert ml18["par_m2_locatif"] == pytest.approx(2517, abs=1) and ml18["taux_indirects"] == pytest.approx(0.2158, abs=5e-5) assert ml18["financement"]["interets"] / ml18["indirects"] == pytest.approx(0.162, abs=5e-4)