"""Tests du chapitre 21 — coût neuf du sujet du cas 1, IPCB Montréal, sensibilité et exercices. Tolérances : 0,01 $ sur les montants recalculés depuis le devis ; 0,5 $ sur les montants arrondis cités dans le texte ; 1e-6 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 cas from traite import cout as ct TOL = 0.5 @pytest.fixture(scope="module") def data(): devis, sujet = cas.charger() return {"devis": devis, "sujet": sujet, "g": cas.regrouper(devis), "cn": cas.cout_neuf_sujet(devis), "sup": cas.superficies(sujet, devis), "ipcb": cas.ipcb_montreal()} class TestDevis: def test_structure(self, data): d = data["devis"] assert len(d) == 38 and int((~d["indirect"]).sum()) == 33 and int(d["indirect"].sum()) == 5 assert np.allclose(d["quantite"] * d["prix_unitaire"], d["montant"], atol=0.011) def test_totaux_cites(self, data): cn = data["cn"] assert cn["direct"] == pytest.approx(298826.17, abs=0.01) assert cn["indirects"] == pytest.approx(62598.68, abs=0.01) assert cn["direct"] + cn["indirects"] == pytest.approx(361424.85, abs=0.01) assert cn["taux_indirects"] == pytest.approx(0.2095, abs=5e-5) assert cn["incitatif"] == pytest.approx(36142.49, abs=0.01) assert cn["total"] == pytest.approx(397567.33, abs=0.01) def test_regroupement(self, data): g = data["g"] attendu = {"Fondations et site": 39818.04, "Structure et charpente": 53852.16, "Enveloppe": 70350.56, "Finition intérieure": 67092.26, "Mécanique et électricité": 51105.45, "Aménagements extérieurs": 16607.70, "Coûts indirects": 62598.68} for k, v in attendu.items(): assert g.loc[k, "montant"] == pytest.approx(v, abs=0.01) assert int(g["postes"].sum()) == 38 assert g["montant"].sum() == pytest.approx(data["cn"]["direct"] + data["cn"]["indirects"], abs=0.01) assert g.loc["Enveloppe", "part_direct_indirect"] == pytest.approx(0.195, abs=5e-4) def test_identite_multiplicative(self, data): cn = data["cn"] assert cn["total"] == pytest.approx(cn["direct"] * (1 + cn["taux_indirects"]) * 1.10, rel=1e-9) parts = (cn["direct"] / cn["total"], cn["indirects"] / cn["total"], cn["incitatif"] / cn["total"]) assert parts[0] == pytest.approx(0.752, abs=5e-4) and parts[1] == pytest.approx(0.157, abs=5e-4) and parts[2] == pytest.approx(0.0909, abs=5e-4) assert sum(parts) == pytest.approx(1.0, abs=1e-9) def test_variante_assiette_direct(self, data): cn = data["cn"]; alt = cas.cout_neuf_sujet(data["devis"], 0.10, "direct") assert alt["incitatif"] == pytest.approx(29882.62, abs=0.01) and alt["total"] == pytest.approx(391307.47, abs=0.01) assert cn["total"] - alt["total"] == pytest.approx(cn["direct"] * cn["taux_indirects"] * 0.10, rel=1e-9) assert cn["total"] - alt["total"] == pytest.approx(6259.87, abs=0.01) def test_unitaires(self, data): cn, sup = data["cn"], data["sup"] assert sup["habitable_m2"] == 165 and sup["brute_etages_m2"] == 190 assert sup["habitable_pi2"] == pytest.approx(1776, abs=0.6) and sup["brute_etages_pi2"] == pytest.approx(2045, abs=0.6) assert cn["total"] / 165 == pytest.approx(2409, abs=TOL) and cn["total"] / 165 / cas.PI2_PAR_M2 == pytest.approx(223.8, abs=0.05) assert cn["total"] / 190 == pytest.approx(2092, abs=TOL) and cn["total"] / 190 / cas.PI2_PAR_M2 == pytest.approx(194.4, abs=0.05) di = cn["direct"] + cn["indirects"] assert di / 165 == pytest.approx(2190, abs=TOL) and di / 190 / cas.PI2_PAR_M2 == pytest.approx(176.7, abs=0.05) def test_comparaison_altus(self, data): sup = data["sup"]; cn = data["cn"] lo, hi, mid = 155 * sup["brute_etages_pi2"], 215 * sup["brute_etages_pi2"], 185 * sup["brute_etages_pi2"] assert lo == pytest.approx(316997, abs=1) and hi == pytest.approx(439705, abs=1) and mid == pytest.approx(378351, abs=1) assert lo < cn["total"] < hi and cn["total"] / mid - 1 == pytest.approx(0.051, abs=2e-3) assert 185 * cas.PI2_PAR_M2 == pytest.approx(1991.3, abs=0.05) assert 155 * sup["habitable_pi2"] == pytest.approx(275287, abs=1) and 215 * sup["habitable_pi2"] == pytest.approx(381849, abs=1) class TestIPCB: def test_series(self, data): ip = data["ipcb"] mi = ip[ip["type"] == "Maison individuelle [621112]"] assert len(mi) == 39 and mi["periode"].iloc[0] == "2017-01" and mi["periode"].iloc[-1] == "2026-07" assert mi["indice"].iloc[0] == 66.1 and mi["indice"].iloc[-1] == 116.5 res = ip[ip["type"] == "Bâtiments résidentiels [621]"] assert res["indice"].iloc[0] == 65.5 and res["indice"].iloc[-1] == 117.8 def test_valeurs_tableau(self, data): ip = data["ipcb"] attendu = {"Bâtiments résidentiels [621]": [65.5, 71.6, 86.0, 99.9, 108.5, 117.8], "Maison individuelle [621112]": [66.1, 71.9, 86.3, 100.0, 107.3, 116.5], "Immeuble à hauteur restreinte (moins de cinq étages) [62111]": [64.8, 70.9, 85.9, 99.7, 108.5, 118.5], "Tour d'habitations (cinq étages ou plus) [621111]": [69.9, 76.7, 85.6, 100.6, 110.8, 119.5], "Bâtiments non résidentiels [622]": [67.3, 75.0, 85.3, 100.4, 108.5, 114.4]} for t, vals in attendu.items(): for p, v in zip(["2017-01", "2019-07", "2021-07", "2023-07", "2025-07", "2026-07"], vals): assert cas.indice(ip, t, p) == v, (t, p) def test_ratios_et_variations(self, data): ip = data["ipcb"]; mi = "Maison individuelle [621112]" assert cas.indice(ip, mi, "2026-07") / cas.indice(ip, mi, "2025-07") - 1 == pytest.approx(0.0857, abs=5e-4) assert cas.indice(ip, mi, "2026-07") / cas.indice(ip, mi, "2017-01") == pytest.approx(1.7625, abs=5e-4) assert cas.indice(ip, mi, "2026-07") / cas.indice(ip, mi, "2019-07") == pytest.approx(1.620, abs=5e-4) assert (1.7625 ** (1 / 9.5) - 1) == pytest.approx(0.0615, abs=5e-4) assert cas.indice(ip, mi, "2022-10") / cas.indice(ip, mi, "2020-01") - 1 == pytest.approx(0.36, abs=5e-3) nr = "Bâtiments non résidentiels [622]" r_nr = cas.indice(ip, nr, "2026-07") / cas.indice(ip, nr, "2019-07") assert r_nr == pytest.approx(1.525, abs=5e-4) and r_nr / 1.6203 - 1 == pytest.approx(-0.059, abs=1e-3) def test_indexation_exemples(self, data): ip = data["ipcb"]; mi = "Maison individuelle [621112]"; cn = data["cn"]["total"] assert ct.indexer_cout(cn, cas.indice(ip, mi, "2026-07"), cas.indice(ip, mi, "2021-07")) == pytest.approx(294507, abs=1) assert ct.indexer_cout(cn, cas.indice(ip, mi, "2026-07"), cas.indice(ip, mi, "2019-07")) == pytest.approx(245366, abs=1) assert ct.indexer_cout(290000, cas.indice(ip, mi, "2019-07"), cas.indice(ip, mi, "2026-07")) == pytest.approx(469889, abs=1) class TestSensibilite: def test_grille(self, data): cn = data["cn"] ti = np.array([0.12, 0.15, 0.18, cn["taux_indirects"], 0.24, 0.27]); tc = np.array([0.05, 0.08, 0.10, 0.12, 0.15]) G = cas.grille_sensibilite(cn["direct"], ti, tc) assert G.min() == pytest.approx(351420, abs=1) and G.max() == pytest.approx(436436, abs=1) assert G.max() / G.min() - 1 == pytest.approx(0.242, abs=1e-3) assert G[3, 2] == pytest.approx(cn["total"], abs=0.01) # effet marginal d'un point de taux assert (G[4, 2] - G[1, 2]) / 9 == pytest.approx(3290, abs=5) and (G[3, 4] - G[3, 0]) / 10 == pytest.approx(3614, abs=5) def test_tornade(self, data): cn = data["cn"]; ip = data["ipcb"]; mi = "Maison individuelle [621112]" var = cas.indice(ip, mi, "2026-07") / cas.indice(ip, mi, "2025-07") - 1 t = cas.tornade(cn["direct"], cn["taux_indirects"], 0.10, (1 / (1 + var), 1 + var), (-0.05, -0.20)) d = {o["param"]: o for o in t} assert d["Taux d'indirects"]["bas"] == pytest.approx(381132, abs=1) and d["Taux d'indirects"]["haut"] == pytest.approx(414003, abs=1) assert d["Taux d'incitatif"]["bas"] == pytest.approx(379496, abs=1) and d["Taux d'incitatif"]["haut"] == pytest.approx(415639, abs=1) assert d["Indice de coûts (date)"]["bas"] == pytest.approx(366171, abs=1) and d["Indice de coûts (date)"]["haut"] == pytest.approx(431655, abs=1) assert (431655 - 366171) / cn["total"] == pytest.approx(0.165, abs=1e-3) def test_elasticite(self, data): um = data["cn"]["total"] / 165 assert ct.cout_par_superficie_ajuste(um, 165, 200, -0.05) * 200 == pytest.approx(477287, abs=1) assert ct.cout_par_superficie_ajuste(um, 165, 200, -0.20) * 200 == pytest.approx(463711, abs=1) assert ct.cout_par_superficie_ajuste(um, 165, 140, -0.05) * 140 == pytest.approx(340112, abs=1) assert ct.cout_par_superficie_ajuste(um, 165, 140, -0.20) * 140 == pytest.approx(348599, abs=1) assert ct.cout_par_superficie_ajuste(2300, 200, 165, -0.10) * 165 == pytest.approx(386871, abs=1) assert 2300 * 165 == 379500 # élasticité du coût total = 1 + ε assert (ct.cout_par_superficie_ajuste(1, 100, 120, -0.1) * 120) / (1 * 100) == pytest.approx(1.2 ** 0.9, rel=1e-12) class TestExercices: def test_ex1(self): a = ct.cout_neuf(250000, 0.15, 0.10, "direct+indirect"); b = ct.cout_neuf(250000, 0.15, 0.10, "direct") assert a["total"] == 316250 and b["total"] == 312500 and a["total"] - b["total"] == 3750 assert a["direct"] / a["total"] == pytest.approx(0.791, abs=5e-4) and a["indirects"] / a["total"] == pytest.approx(0.119, abs=5e-4) def test_ex2(self, data): ip = data["ipcb"] assert ct.indexer_cout(310000, 86.3, 116.5) == pytest.approx(418482, abs=1) assert ct.indexer_cout(310000, cas.indice(ip, "Bâtiments non résidentiels [622]", "2021-07"), cas.indice(ip, "Bâtiments non résidentiels [622]", "2026-07")) == pytest.approx(415756, abs=1) def test_ex3(self): repro = 0.7 * 520000 - (0.7 * 40000 + 12000); rempl = 0.7 * 480000 - 12000 assert repro == rempl == 324000 and rempl - 40000 == 284000 def test_ex4(self): k = cas.PI2_PAR_M2 assert 155 * k == pytest.approx(1668.4, abs=0.05) and 215 * k == pytest.approx(2314.2, abs=0.05) u = ct.cout_par_superficie_ajuste(185 * k, 190, 210, -0.10) assert u == pytest.approx(1971.5, abs=0.05) and u * 210 == pytest.approx(414013, abs=1) and 185 * k * 210 == pytest.approx(418178, abs=1) def test_ex5(self, data): d = data["devis"]; sup = float(d.loc[d["poste"] == "Supervision de chantier (salaires)", "montant"].iloc[0]) assert sup == pytest.approx(35708.66, abs=0.01) D2 = data["cn"]["direct"] + sup; I2 = data["cn"]["indirects"] - sup assert I2 / D2 == pytest.approx(0.0804, abs=5e-5) and D2 + I2 == pytest.approx(361424.85, abs=0.01) assert ct.cout_neuf(D2, I2 / D2, 0.10)["total"] == pytest.approx(397567.33, abs=0.01) assert ct.cout_neuf(D2, I2 / D2, 0.10, "direct")["total"] == pytest.approx(394878.33, abs=0.01) def test_ex6(self): a, b, A = 1500, 900, 95 for kappa, part, eps, c in [(4.0, 0.1976, -0.0988, 1869.4), (3 * math.sqrt(2), 0.2071, -0.1035, 1891.8)]: P = kappa * math.sqrt(A); w = b * P / (a * A + b * P) assert w == pytest.approx(part, abs=5e-4) and -0.5 * w == pytest.approx(eps, abs=5e-4) and (a * A + b * P) / A == pytest.approx(c, abs=0.05) assert 1500 + 900 * 4 / math.sqrt(190) == pytest.approx(1761.2, abs=0.05) assert 1869.4 * 2 ** -0.0988 == pytest.approx(1745.7, abs=0.1) def test_ex7(self, data): g = data["g"]; cn = data["cn"] Dass = cn["direct"] - g.loc["Fondations et site", "montant"] - g.loc["Aménagements extérieurs", "montant"] r = ct.cout_neuf(Dass, cn["taux_indirects"], 0.10) assert Dass == pytest.approx(242400.43, abs=0.01) and r["indirects"] == pytest.approx(50778.51, abs=0.01) and r["total"] == pytest.approx(322497, abs=1) assert 180000 + 0.65 * cn["total"] == pytest.approx(438419, abs=1)