"""Tests du chapitre 22 — métré et quantités du sujet du cas 1. Tolérances : 0,01 $ sur les montants recalculés depuis le devis ; 0,05 m² / m³ / m sur les quantités géométriques citées (arrondies à deux décimales dans le texte) ; 5e-4 sur les proportions ; identités à 1e-9. Les montants du chapitre 21 (D, I, C_N) doivent être strictement inchangés.""" 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 ch22_metre as cas from traite import cout as ct Q = 0.05 @pytest.fixture(scope="module") def data(): devis, sujet = c21.charger() return {"devis": devis, "sujet": sujet, "e": cas.empreinte(), "S": cas.superficies(), "q": cas.quantites_geometriques(), "V": cas.verification(devis), "eff": None, "cn": c21.cout_neuf_sujet(devis)} class TestCoherenceCh21: def test_montants_inchanges(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["total"] == pytest.approx(397567.33, abs=0.01) assert len(data["devis"]) == 38 def test_corps_identiques(self, data): g = c21.regrouper(data["devis"]) assert list(g.index) == c21.ORDRE_CORPS and int(g["postes"].sum()) == 38 def test_bordereau_quantites_fois_prix(self, data): d = data["devis"] assert np.allclose(d["quantite"] * d["prix_unitaire"], d["montant"], atol=0.011) detail = ct.cout_par_composantes(dict(zip(d["poste"], d["quantite"])), dict(zip(d["poste"], d["prix_unitaire"]))) assert detail["total"] == pytest.approx(361424.85, abs=0.2) class TestGeometrie: def test_empreinte_et_perimetre(self, data): e = data["e"] assert e["aire_au_sol"] == pytest.approx(95.04, abs=1e-9) assert e["perimetre"] == pytest.approx(39.84, abs=1e-9) assert e["garage"] == pytest.approx(24.95, abs=0.005) and e["aire_sous_sol"] == pytest.approx(70.09, abs=0.005) # invariance du périmètre d'une forme en L découpée dans un rectangle L, W, gx, gy = cas.PLAN["L"], cas.PLAN["W"], cas.PLAN["gx"], cas.PLAN["gy"] perim_L = (L - gx) + gy + gx + (W - gy) + L + W # parcours de la forme en L assert perim_L == pytest.approx(2 * (L + W), abs=1e-9) def test_superficies_conventions(self, data): S = data["S"] assert S["aire_etages_mefq"] == pytest.approx(190.08, abs=1e-9) assert S["perimetre_etages_mefq"] == pytest.approx(79.68, abs=1e-9) assert S["habitable"] == pytest.approx(165.13, abs=0.005) assert round(S["habitable"]) == data["sujet"]["superficie_habitable_m2"] assert S["nette_hors_sol"] == pytest.approx(135.37, abs=Q) assert S["nette_rez"] == pytest.approx(58.56, abs=Q) and S["nette_etage"] == pytest.approx(76.81, abs=Q) assert S["sous_sol_net"] == pytest.approx(58.65, abs=Q) and S["sous_sol_fini_net"] == pytest.approx(49.31, abs=Q) assert S["aire_etages_mefq"] * c21.PI2_PAR_M2 == pytest.approx(2046, abs=0.6) assert S["habitable"] * c21.PI2_PAR_M2 == pytest.approx(1777, abs=0.6) def test_pieces_couvrent_les_niveaux(self, data): pcs = data["S"]["pieces"] assert pcs.loc[pcs["niveau"] == "rez", "brute"].sum() == pytest.approx(95.04, abs=1e-6) assert pcs.loc[pcs["niveau"] == "etage", "brute"].sum() == pytest.approx(95.04, abs=1e-6) assert pcs.loc[pcs["niveau"] == "soussol", "brute"].sum() == pytest.approx(70.09, abs=0.01) assert (pcs["nette"] > 0).all() def test_cloisons(self): assert cas.cloisons("rez") == pytest.approx(21.52, abs=Q) assert cas.cloisons("etage") == pytest.approx(47.94, abs=Q) assert cas.cloisons("soussol") == pytest.approx(20.80, abs=Q) assert cas.separation_garage() == pytest.approx(10.26, abs=1e-9) def test_formules_elementaires(self): # aire décalée d'un rectangle = formule exacte assert cas.aire_decalee(12 * 7.92, 2 * (12 + 7.92), 0.9) == pytest.approx((12 + 1.8) * (7.92 + 1.8), abs=1e-9) # prismoïde d'un parallélépipède = A·h ; d'une pyramide tronquée : cohérent avec la formule exacte assert cas.volume_prismoidal(10, 10, 10, 3) == pytest.approx(30) a, b, h = 4.0, 6.0, 3.0 # tronc de pyramide à bases carrées exact = h / 3 * (a * a + b * b + a * b) assert cas.volume_prismoidal(a * a, ((a + b) / 2) ** 2, b * b, h) == pytest.approx(exact, rel=1e-12) assert math.sqrt(1 + 0.5 ** 2) == pytest.approx(1.1180, abs=5e-5) class TestQuantites: def test_mesures_citees(self, data): q = data["q"] attendu = {"Excavation et remblai": 408.94, "Fondations béton (semelles, murs)": 36.79, "Dalle de sous-sol": 95.04, "Drain français et imperméabilisation": 42.24, "Charpente murs et planchers": 324.65, "Fermes de toit": 111.62, "Couverture bardeaux d'asphalte": 124.79, "Revêtement extérieur (brique 40 % / fibrociment 60 %)": 238.79, "Isolation et pare-vapeur": 419.82, "Gypse et tirage de joints": 733.00, "Peinture intérieure": 733.00, "Planchers bois franc": 103.56, "Planchers céramique": 31.81, "Planchers flottants (sous-sol)": 70.09, "Gouttières et solins": 48.80, "Balcon et perron": 14.10, "Entrée asphaltée": 55.00} for k, v in attendu.items(): assert q[k]["q"] == pytest.approx(v, abs=Q), k def test_details_cites(self, data): q = data["q"] d = q["Charpente murs et planchers"]["detail"] assert d["planchers"] == pytest.approx(165.13, abs=Q) and d["murs_brut"] == pytest.approx(194.42, abs=Q) assert d["ouvertures"] == pytest.approx(34.90, abs=Q) and d["murs_net"] == pytest.approx(159.52, abs=Q) f = q["Fondations béton (semelles, murs)"]["detail"] assert f["L_axe"] == pytest.approx(38.84, abs=1e-9) and f["V_murs"] == pytest.approx(23.69, abs=Q) and f["V_semelles"] == pytest.approx(6.99, abs=Q) x = q["Excavation et remblai"]["detail"] assert x["A_bas"] == pytest.approx(109.19, abs=Q) and x["A_mi"] == pytest.approx(141.45, abs=Q) and x["A_haut"] == pytest.approx(177.10, abs=Q) assert x["V_sous_sol"] == pytest.approx(369.25, abs=Q) and x["V_garage"] == pytest.approx(15.39, abs=Q) and x["V_racc"] == pytest.approx(24.30, abs=Q) g = q["Gypse et tirage de joints"]["detail"] assert g["plafonds"] == pytest.approx(209.63, abs=Q) and g["murs"] == pytest.approx(582.53, abs=Q) assert g["sep_garage"] == pytest.approx(25.03, abs=Q) and g["ouvertures"] == pytest.approx(84.19, abs=Q) r = q["Revêtement extérieur (brique 40 % / fibrociment 60 %)"]["detail"] assert r["murs"] == pytest.approx(223.10, abs=Q) and r["pignons"] == pytest.approx(15.68, abs=Q) i = q["Isolation et pare-vapeur"]["detail"] assert i["fondation"] == pytest.approx(91.37, abs=Q) and i["rive"] == pytest.approx(23.90, abs=Q) and i["sep_murs"] == pytest.approx(25.03, abs=Q) def test_denombrements(self, data): q = data["q"] assert q["Fenêtres (PVC double vitrage)"]["q"] == 16 and q["Fenêtres (PVC double vitrage)"]["detail"] == {"rez": 6, "etage": 8, "soussol": 2} assert q["Portes intérieures et moulures"]["q"] == 14 and q["Plomberie brute et finition"]["q"] == 14 == len(cas.APPAREILS) assert q["Portes extérieures"]["q"] == 3 and q["Porte de garage"]["q"] == 1 and q["Escaliers"]["q"] == 2 assert q["Supervision de chantier (salaires)"]["q"] == 26 class TestVerification: def test_verdicts(self, data): V = data["V"] nb = V["verdict"].value_counts().to_dict() assert nb == {"conforme": 20, "ratio": 14, "à justifier": 2, "à préciser": 2} assert set(V.loc[V["verdict"] == "à justifier", "poste"]) == {"Gypse et tirage de joints", "Peinture intérieure"} assert set(V.loc[V["verdict"] == "à préciser", "poste"]) == {"Planchers bois franc", "Planchers céramique"} assert len(V) == 38 and int((V["classe"] == "mesuré").sum()) == 17 and int((V["classe"] == "dénombré").sum()) == 7 def test_ecarts_cites(self, data): V = data["V"].set_index("poste") assert V.loc["Gypse et tirage de joints", "ecart"] == pytest.approx(0.182, abs=5e-4) assert V.loc["Planchers bois franc", "ecart"] == pytest.approx(0.090, abs=5e-4) assert V.loc["Planchers céramique", "ecart"] == pytest.approx(-0.091, abs=5e-4) assert V.loc["Excavation et remblai", "ecart"] == pytest.approx(-0.026, abs=5e-4) assert V.loc["Fondations béton (semelles, murs)", "ecart"] == pytest.approx(-0.032, abs=5e-4) assert V.loc["Charpente murs et planchers", "ecart"] == pytest.approx(-0.016, abs=5e-4) assert V.loc["Gypse et tirage de joints", "delta_montant"] == pytest.approx(3104.20, abs=0.01) assert V.loc["Peinture intérieure", "delta_montant"] == pytest.approx(1143.59, abs=0.01) # planchers finis hors sol : total conforme même si la répartition diffère tot_calc = V.loc["Planchers bois franc", "q_calc"] + V.loc["Planchers céramique", "q_calc"] tot_devis = V.loc["Planchers bois franc", "q_devis"] + V.loc["Planchers céramique", "q_devis"] assert tot_calc == pytest.approx(135.37, abs=Q) and tot_devis == 130 and abs(tot_calc / tot_devis - 1) < 0.05 def test_effet_metre_controle(self, data): eff = cas.effet_metre_controle(data["devis"], data["V"]) assert eff["delta_D"] == pytest.approx(3410.22, abs=0.01) assert eff["D2"] == pytest.approx(302236.39, abs=0.01) assert eff["CN2"] == pytest.approx(401318.58, abs=0.01) assert eff["delta_CN"] == pytest.approx(3751.24, abs=0.01) assert eff["part_D"] == pytest.approx(0.0114, abs=5e-5) and eff["part_CN"] == pytest.approx(0.0094, abs=5e-5) assert eff["mult"] == pytest.approx(1.3304, abs=5e-5) # identité : ΔC_N = ΔD × (1 + τE) lorsque les indirects sont fixés en dollars assert eff["delta_CN"] == pytest.approx(eff["delta_D"] * 1.10, rel=1e-9) class TestSensibilitePropagation: def test_sensibilite(self, data): s = cas.sensibilite_quantites(data["devis"]).set_index("corps") assert s.loc["Enveloppe", "dCN_+0.10"] == pytest.approx(7739, abs=1) and s.loc["Enveloppe", "pct_+0.10"] == pytest.approx(0.0195, abs=5e-5) assert s.loc["Tous les postes directs", "dCN_+0.10"] == pytest.approx(32871, abs=1) and s.loc["Tous les postes directs", "pct_+0.10"] == pytest.approx(0.0827, abs=5e-5) assert s.loc["Aménagements extérieurs", "dCN_-0.10"] == pytest.approx(-1827, abs=1) # linéarité : ΔC_N = e × montant × (1 + τE) assert s.loc["Enveloppe", "dCN_+0.10"] == pytest.approx(0.10 * s.loc["Enveloppe", "montant"] * 1.10, rel=1e-9) assert s["part_D"].iloc[:-1].sum() == pytest.approx(1.0, abs=1e-9) def test_propagation(self, data): p = cas.propagation(data["devis"]) assert p["herfindahl"] == pytest.approx(0.0539, abs=5e-5) and p["equiv_uniforme"] == pytest.approx(18.6, abs=0.05) assert p["sigma_D_rel"] == pytest.approx(0.0116, abs=5e-5) and p["sigma_D"] == pytest.approx(3467.78, abs=0.01) assert p["poste_max"] == "Charpente murs et planchers" and p["max_part"] == pytest.approx(0.132, abs=5e-4) # bornes : 1/n ≤ H ≤ 1 et σ indépendants ≤ σ corrélés assert 1 / p["n"] <= p["herfindahl"] <= 1 and p["sigma_D_rel"] <= p["sigma_si_parfaitement_correle"] def test_pertes(self, data): per = cas.pertes(data["devis"]).set_index("poste") assert per.loc["Gypse et tirage de joints", "q_commander"] == pytest.approx(694.4, abs=Q) assert per.loc["Couverture bardeaux d'asphalte", "q_commander"] == pytest.approx(137.5, abs=Q) assert per["cout_pertes_si_prix_pose"].sum() == pytest.approx(11283.78, abs=0.01) def test_repartition_types(self, data): rep = cas.repartition_par_type(data["devis"]) tot = rep.sum() assert tot["mesuré"] == pytest.approx(174351.56, abs=0.01) and tot["dénombré"] == pytest.approx(89850.00, abs=0.01) and tot["forfait"] == pytest.approx(97223.29, abs=0.01) assert tot.sum() == pytest.approx(361424.85, abs=0.01) assert rep.loc["Enveloppe", "mesuré"] == pytest.approx(47580.84, abs=0.01) and rep.loc["Coûts indirects", "dénombré"] == pytest.approx(35708.66, abs=0.01) class TestExercices: """Chiffres des solutions (solutions/ch22.tex).""" def test_ex1_conventions(self): L, W, gx, gy = 10.40, 9.15, 3.66, 6.10 aire_sol = L * W; etages = 2 * aire_sol; hab = etages - gx * gy assert aire_sol == pytest.approx(95.16, abs=0.005) and etages == pytest.approx(190.32, abs=0.005) assert gx * gy == pytest.approx(22.33, abs=0.005) and hab == pytest.approx(167.99, abs=0.005) assert 2 * (L + W) == pytest.approx(39.10, abs=1e-9) net = 2 * (L - 0.6) * (W - 0.6) - (gx - 0.3 - 0.075) * (gy - 0.3 - 0.075) assert net == pytest.approx(148.77, abs=0.01) assert etages * 10.7639 == pytest.approx(2048.6, abs=0.1) and hab * 10.7639 == pytest.approx(1808.2, abs=0.1) def test_ex2_murs_nets(self): P = 2 * (10.40 + 9.15); brut = P * 2.44 * 2 ouv = 12 * 1.68 + 2 * 1.85 + 2.74 * 2.13 assert brut == pytest.approx(190.81, abs=0.01) and ouv == pytest.approx(29.70, abs=0.01) assert brut - ouv == pytest.approx(161.11, abs=0.01) and ouv / brut == pytest.approx(0.156, abs=5e-4) def test_ex3_toiture(self): A = (10.40 + 0.8) * (9.15 + 0.8); f = math.sqrt(1 + (8 / 12) ** 2) assert A == pytest.approx(111.44, abs=0.01) and f == pytest.approx(1.2019, abs=5e-5) assert A * f == pytest.approx(133.94, abs=0.01) and A * f * 1.10 == pytest.approx(147.33, abs=0.01) f6 = math.sqrt(1.25) assert A * f / (A * f6) - 1 == pytest.approx(0.0750, abs=5e-5) def test_ex4_excavation(self): A, P, h, d = 95.16, 39.10, 2.50, 0.90 rect = cas.aire_decalee(A, P, d) * h Ab = cas.aire_decalee(A, P, d); Am = cas.aire_decalee(A, P, d + 0.5 * h / 2); At = cas.aire_decalee(A, P, d + 0.5 * h) V = cas.volume_prismoidal(Ab, Am, At, h) Am1 = cas.aire_decalee(A, P, d + 1.0 * h / 2); At1 = cas.aire_decalee(A, P, d + 1.0 * h) V1 = cas.volume_prismoidal(Ab, Am1, At1, h) assert Ab == pytest.approx(133.59, abs=0.01) and rect == pytest.approx(333.98, abs=0.01) assert Am == pytest.approx(164.09, abs=0.01) and At == pytest.approx(197.72, abs=0.01) assert V == pytest.approx(411.53, abs=0.01) and V1 == pytest.approx(499.50, abs=0.01) assert V / rect - 1 == pytest.approx(0.2322, abs=5e-4) and V1 / rect - 1 == pytest.approx(0.4956, abs=5e-4) def test_ex5_prix_unitaire(self): m, w, hpu, s, e = 11.50, 0.12, 0.35, 62.0, 0.80 p = m * (1 + w) + hpu * s + e assert m * (1 + w) == pytest.approx(12.88, abs=0.005) and hpu * s == pytest.approx(21.70, abs=0.005) and p == pytest.approx(35.38, abs=0.005) assert hpu * s / p == pytest.approx(0.613, abs=5e-4) assert p * 1.15 == pytest.approx(40.69, abs=0.005) assert (m * (1 + w) + hpu * s * 1.05 + e) / p - 1 == pytest.approx(0.0307, abs=5e-5) assert (m * (1 + w) + hpu * 0.9 * s + e) / p - 1 == pytest.approx(-0.0613, abs=5e-5) def test_ex6_propagation(self, data): p = cas.propagation(data["devis"], 0.05); p10 = cas.propagation(data["devis"], 0.10) assert p10["sigma_D_rel"] == pytest.approx(2 * p["sigma_D_rel"], rel=1e-12) # 33 postes de parts égales assert 0.05 * math.sqrt(1 / 33) == pytest.approx(0.0087, abs=5e-5) # 2 postes concentrés : parts 0,5 / 0,5 → H = 0,5 assert 0.05 * math.sqrt(0.5) == pytest.approx(0.0354, abs=5e-5) assert p["sigma_D"] * 1.3304 == pytest.approx(4613, abs=2) def test_ex7_plain_pied(self): L, W = 14.00, 9.00; P = 2 * (L + W); A = L * W assert A == 126 and P == 46 murs = P * 2.44 - (11 * 1.68 + 2 * 1.85); assert murs == pytest.approx(90.06, abs=0.01) plafond = (L - 0.6) * (W - 0.6); assert plafond == pytest.approx(112.56, abs=0.01) cloisons = 38.0 * 2.44 * 2; assert cloisons == pytest.approx(185.44, abs=0.01) gypse = plafond + murs + cloisons - 2 * 9 * 1.85 assert gypse == pytest.approx(354.76, abs=0.01) ecart = gypse / 300 - 1; assert ecart == pytest.approx(0.1825, abs=5e-4) delta = (gypse - 300) * 27.47 + (gypse - 300) * 10.12 assert delta == pytest.approx(2058.3, abs=0.5) toit = (L + 0.8) * (W + 0.8) * math.sqrt(1.25); assert toit == pytest.approx(162.16, abs=0.01) assert toit / 150 - 1 == pytest.approx(0.0811, abs=5e-4) total = delta + (toit - 150) * 61.44 assert total == pytest.approx(2805.52, abs=0.01) and total * 1.3304 == pytest.approx(3732.46, abs=0.5)