"""Tests du chapitre 28 — dépenses, réserves, CAPEX et RNE (cas 3 et cas 2, données synthétiques). Identités + valeurs citées dans manuscript/chapters/ch28.tex et solutions/ch28.tex (tolérances déclarées : 0,01 $ sur les montants au cent, 1 $ sur les valeurs capitalisées, 1e-9 sur les identités).""" import sys, pathlib sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) import numpy as np import pytest from traite import cas3, cas2, finance as fin from ch28_depenses import calculer, exercices, reserve_par_composantes, demonstration_reserve, ratio_iso_depenses, indexation_ipc ROOT = pathlib.Path(__file__).resolve().parents[3] @pytest.fixture(scope="module") def res(): return calculer() @pytest.fixture(scope="module") def ex(res): return exercices(res) # ----------------------------------------------------------------------------- identités def test_identite_rne(res): b = res["base"] assert b["rne"] == pytest.approx(b["rbe"] - b["depenses_hors_reserve"] - b["reserve"], abs=1e-9) assert b["ratio_depenses"] == pytest.approx(b["depenses_totales"] / b["rbe"], abs=1e-12) assert b["rne"] / b["rbe"] + b["ratio_depenses"] == pytest.approx(1.0, abs=1e-12) def test_coherence_avec_cas3_partage(res): """Les chiffres du chapitre 28 coïncident avec ceux que les chapitres 29, 30 et 47 tirent de traite.cas3.""" t = cas3.tout() assert res["base"]["rne"] == pytest.approx(t["base"]["rne"], rel=1e-12) assert res["base"]["depenses_totales"] == pytest.approx(t["base"]["depenses_totales"], rel=1e-12) assert res["R"] == pytest.approx(t["taux"]["mediane"], rel=1e-12) assert res["V"] == pytest.approx(t["valeur_capitalisation"], rel=1e-12) def test_classement_somme(res): cl = res["classement"]; b = res["base"] assert cl["fixes"] + cl["variables"] + cl["reserve"] == pytest.approx(b["depenses_totales"], abs=1e-9) assert cl["variables_hors_gestion"] + cl["gestion"] == pytest.approx(cl["variables"], abs=1e-9) assert float(cl["groupes"].sum()) == pytest.approx(b["depenses_totales"], abs=1e-9) assert cl["part_fixes"] + cl["part_variables"] + cl["part_reserve"] == pytest.approx(1.0, abs=1e-12) def test_detail_somme_et_regles(res): d = res["detail"]; hyp = res["hyp"] assert float(d["normalise"].sum()) == pytest.approx(res["base"]["depenses_totales"], abs=1e-9) taxes = float(d.loc[d["poste"] == "Taxes municipales", "normalise"].iloc[0]) assert taxes == pytest.approx(hyp["valeur_role_2026"] * hyp["taux_taxe_fonciere"], abs=1e-9) ent = d.loc[d["poste"] == "Entretien et réparations"].iloc[0] assert ent["normalise"] == pytest.approx(ent["historique_2025"] * 1.03, abs=1e-9) gestion = float(d.loc[d["poste"].str.startswith("Gestion"), "normalise"].iloc[0]) assert gestion == pytest.approx(0.04 * res["base"]["rbe"], abs=1e-9) def test_historique(res): h = res["historique"] assert h.loc["Total", 2023] == 66992 and h.loc["Total", 2024] == 70438 and h.loc["Total", 2025] == 70693 assert h.loc["Total", "var_2025_2023"] == pytest.approx(0.0552, abs=5e-4) assert h.loc["Total", "tcac"] == pytest.approx(0.0273, abs=5e-4) assert res["depenses_2025_declarees"] == 70693 assert res["ecart_normalisation"] == pytest.approx(7795.19, abs=0.01) assert res["rne_historique_2025"] == pytest.approx(97750.94, abs=0.01) assert res["V_historique"] - res["V"] == pytest.approx(176561, abs=1) def test_normalisation_alternative(res): alt = res["alternative"] a = alt.loc["Assurances"] attendu = np.mean([11474 * 1.03 ** 3, 12614 * 1.03 ** 2, 11060 * 1.03]) assert a["moyenne_indexee"] == pytest.approx(attendu, abs=1e-9) assert a["derniere_indexee"] == pytest.approx(11391.80, abs=0.01) assert a["ecart"] == pytest.approx(1045.51, abs=0.01) assert alt.loc["Entretien et réparations", "ecart"] == pytest.approx(-130.75, abs=0.01) # ----------------------------------------------------------------------------- réserve / taux def test_demonstration_reserve(res): d = res["demo_reserve"]; b = res["base"] # R' = R + RR/V ; V − V' = RR/R' ; erreur relative = −RR/(RNE + RR) assert d["R_avant"] == pytest.approx(d["R_apres"] + b["reserve"] / d["V"], abs=1e-12) assert d["delta_V"] == pytest.approx(b["reserve"] / d["R_avant"], abs=1e-6) assert d["erreur_relative"] == pytest.approx(-b["reserve"] / (b["rne"] + b["reserve"]), abs=1e-12) assert d["V_err_inverse"] - d["V"] == pytest.approx(b["reserve"] / d["R_apres"], abs=1e-6) # valeurs citées assert d["R_avant"] == pytest.approx(0.048127, abs=5e-7) assert d["ecart_taux_pb"] == pytest.approx(39.77, abs=0.01) assert d["V_err"] == pytest.approx(1869136, abs=1) assert d["delta_V"] == pytest.approx(168367, abs=1) assert d["erreur_relative"] == pytest.approx(-0.0826, abs=5e-5) assert d["erreur_relative_inverse"] == pytest.approx(0.0901, abs=5e-5) def test_reserve_composantes(): rc = reserve_par_composantes(taux=0.03) assert rc["lineaire_par_logement"] == pytest.approx(687.62, abs=0.01) assert rc["fonds_par_logement"] == pytest.approx(580.71, abs=0.01) assert rc["lineaire_immeuble"] == pytest.approx(8251.43, abs=0.01) assert rc["fonds_immeuble"] == pytest.approx(6968.47, abs=0.01) # fonds d'amortissement → linéaire quand le taux → 0 rc0 = reserve_par_composantes(taux=1e-9) assert rc0["fonds_par_logement"] == pytest.approx(rc0["lineaire_par_logement"], rel=1e-6) assert reserve_par_composantes(taux=0.05)["fonds_par_logement"] == pytest.approx(518.77, abs=0.01) def test_equivalence_reserve_capex(): """VA des remplacements perpétuels tous les N ans = VA d'une réserve perpétuelle au fonds d'amortissement.""" C, N, i = 2200.0 * 12, 12, 0.03 va_capex = C / ((1 + i) ** N - 1) rr = C * fin.facteur_fonds_amortissement(i, N) assert rr / i == pytest.approx(va_capex, rel=1e-12) # vérification par sommation tronquée (2 000 cycles) somme = sum(C / (1 + i) ** (j * N) for j in range(1, 2001)) assert somme == pytest.approx(va_capex, rel=1e-9) assert va_capex == pytest.approx(62006.64, abs=0.01) assert rr == pytest.approx(1860.20, abs=0.01) def test_equivalence_capex_dossier(res): eq = res["equivalence_capex"] assert eq["total"] == 245000 va = 85000 / 1.0725 ** 2 + 40000 / 1.0725 ** 4 + 120000 / 1.0725 ** 7 assert eq["va"] == pytest.approx(va, abs=1e-6) assert eq["annuite_equivalente"] == pytest.approx(va * fin.facteur_recouvrement(0.0725, 10), abs=1e-6) assert eq["va"] == pytest.approx(177647.94, abs=0.01) assert eq["annuite_equivalente"] == pytest.approx(25586.15, abs=0.01) # ----------------------------------------------------------------------------- valeurs citées def test_valeurs_citees_cas3(res): b = res["base"]; cl = res["classement"] assert b["rbe"] == pytest.approx(168443.94, abs=0.01) assert b["depenses_hors_reserve"] == pytest.approx(70385.19, abs=0.01) assert b["depenses_totales"] == pytest.approx(78488.19, abs=0.01) assert b["rne"] == pytest.approx(89955.75, abs=0.01) assert b["ratio_depenses"] == pytest.approx(0.466, abs=5e-4) assert res["rne_par_logement"] == pytest.approx(7496.31, abs=0.01) assert res["rne_par_m2"] == pytest.approx(109.04, abs=0.01) assert cl["fixes"] == pytest.approx(23317.78, abs=0.01) assert cl["variables"] == pytest.approx(47067.41, abs=0.01) assert cl["part_fixes"] == pytest.approx(0.297, abs=5e-4) assert cl["part_variables"] == pytest.approx(0.600, abs=5e-4) assert cl["part_reserve"] == pytest.approx(0.103, abs=5e-4) assert cl["fixes_pct_rbe"] == pytest.approx(0.138, abs=5e-4) assert cl["total_par_logement"] == pytest.approx(6540.68, abs=0.01) assert cl["total_par_m2"] == pytest.approx(95.14, abs=0.01) g = cl["groupes_pct_rbe"] assert g["Conciergerie et services"] == pytest.approx(0.1011, abs=5e-4) assert g["Taxes foncières"] == pytest.approx(0.0708, abs=5e-4) assert g["Réserve de remplacement"] == pytest.approx(0.0481, abs=5e-4) def test_valeurs_citees_marche_et_comparables(res): m = res["marche"]; cs = res["comps_stats"] assert m["rbe"] == pytest.approx(188140.60, abs=0.01) assert m["rne"] == pytest.approx(108864.54, abs=0.01) assert m["ratio"] == pytest.approx(0.421, abs=5e-4) assert m["loyer_moyen_contrat"] == pytest.approx(1149.58, abs=0.01) assert m["loyer_moyen"] == pytest.approx(1291.92, abs=0.01) assert cs["n"] == 40 and cs["ratio_median"] == pytest.approx(0.3985, abs=5e-4) assert cs["ratio_q1"] == pytest.approx(0.379, abs=1e-3) and cs["ratio_q3"] == pytest.approx(0.4185, abs=1e-3) # courbe iso-dépenses : passe par les deux points du sujet assert float(ratio_iso_depenses(m["loyer_moyen_contrat"], res["base"], res["hyp"])) == pytest.approx(res["base"]["ratio_depenses"], abs=1e-9) assert float(ratio_iso_depenses(m["loyer_moyen"], res["base"], res["hyp"])) == pytest.approx(m["ratio"], abs=1e-9) def test_sensibilites(res): s = res["sensibilites"]; R = res["R"]; noi0 = res["base"]["rne"] r = s["reserve"].set_index("reserve_par_logement") assert r.loc[0, "rne"] == pytest.approx(98058.75, abs=0.01) and r.loc[0, "dV_pct"] == pytest.approx(0.090, abs=5e-4) assert r.loc[100, "dV_pct"] == pytest.approx(0.077, abs=5e-4) and r.loc[1200, "dV_pct"] == pytest.approx(-0.070, abs=5e-4) assert (np.diff(r["rne"].values) < 0).all() t = s["taxe"].set_index("scenario") assert t.iloc[1]["taxe"] == pytest.approx(11194.44, abs=0.01) and t.iloc[1]["dV_pct"] == pytest.approx(-0.011, abs=5e-4) assert t.iloc[2]["dV_pct"] == pytest.approx(-0.057, abs=5e-4) and t.iloc[3]["dV_pct"] == pytest.approx(-0.075, abs=5e-4) g = s["gestion"].set_index("gestion_pct") assert g.loc[0.0, "dV_pct"] == pytest.approx(0.075, abs=5e-4) and g.loc[0.10, "dV_pct"] == pytest.approx(-0.112, abs=5e-4) assert s["dV_dDE"] == pytest.approx(-1 / R, rel=1e-12) and abs(s["dV_dDE"]) == pytest.approx(22.65, abs=0.01) assert s["elasticite_rne_depenses"] == pytest.approx(-0.873, abs=5e-4) assert s["elasticite_rne_rbe"] == pytest.approx(1.80, abs=5e-3) assert s["depenses_equiv_25pb"] * R == pytest.approx(4820.78, abs=0.01) # pentes citées dans la légende de la figure : 12/R par dollar de réserve/logement ; 2 200/R par 10 pb ; RBE/100/R par point de gestion assert 12 / R == pytest.approx(272, abs=1) assert 2200 / R == pytest.approx(49830, abs=10) assert res["base"]["rbe"] / 100 / R == pytest.approx(38153, abs=1) def test_indexation_ipc(res): ipc = res["ipc"] assert ipc["facteur"] == pytest.approx(167.7 / 124.7, rel=1e-12) assert ipc["variation"] == pytest.approx(0.345, abs=5e-4) assert ipc["entretien_500"] == pytest.approx(672.4, abs=0.1) assert ipc["salaires_300"] == pytest.approx(403.4, abs=0.1) assert ipc["reserve_100"] == pytest.approx(134.5, abs=0.1) def test_cas2(res): c = res["cas2"]; b = c["base"] t2 = cas2.tout() assert b["rne"] == pytest.approx(t2["base"]["rne"], rel=1e-12) assert b["rbe"] == pytest.approx(40577.04, abs=0.01) assert c["gestion_imputee"] == pytest.approx(1623.08, abs=0.01) assert b["depenses_hors_reserve"] == pytest.approx(16743.08, abs=0.01) assert b["depenses_totales"] == pytest.approx(18543.08, abs=0.01) assert b["rne"] == pytest.approx(22033.96, abs=0.01) assert b["ratio_depenses"] == pytest.approx(0.457, abs=5e-4) assert c["rne_par_logement"] == pytest.approx(7344.65, abs=0.01) assert c["rne_par_m2"] == pytest.approx(84.10, abs=0.01) assert c["classement"]["part_fixes"] == pytest.approx(0.527, abs=5e-4) assert c["classement"]["total_par_logement"] == pytest.approx(6181.03, abs=0.01) assert c["classement"]["total_par_m2"] == pytest.approx(70.78, abs=0.01) tq = c["taxes_quebec_recalc"] assert tq["taxe_generale"] == pytest.approx(495000 * 0.007464, abs=1e-9) assert tq["total"] == pytest.approx(5437.68, abs=0.01) and tq["ecart"] == pytest.approx(462.32, abs=0.01) assert tq["scolaire_recalc"] == pytest.approx(371.25, abs=0.01) # ----------------------------------------------------------------------------- exercices def test_exercices(ex, res): e1 = ex["ex1"] assert e1["rbe"] == pytest.approx(118800, abs=1e-9) assert e1["fixes"] == pytest.approx(15260.31, abs=0.01) and e1["variables"] == pytest.approx(18740, abs=1e-9) assert e1["rne"] == pytest.approx(79999.70, abs=0.01) and e1["ratio"] == pytest.approx(0.3266, abs=5e-5) assert e1["part_fixes"] == pytest.approx(0.393, abs=5e-4) e2 = ex["ex2"] assert e2["ecart_total"] == pytest.approx(1046.00, abs=0.01) and e2["dV"] == pytest.approx(23692, abs=1) e3 = ex["ex3"] assert e3["ecart"] == pytest.approx(1006.24, abs=0.01) and e3["dV"] == pytest.approx(-22791, abs=1) assert e3["ecart_exemption"] == pytest.approx(19.75, abs=0.01) e4 = ex["ex4"] assert e4["lineaire"] == pytest.approx(687.62, abs=0.01) and e4["fonds_5"] == pytest.approx(518.77, abs=0.01) e5 = ex["ex5"] assert e5["R_apres"] == pytest.approx(182000 / 4100000, rel=1e-12) assert e5["ecart_pb"] == pytest.approx(32.93, abs=0.01) assert e5["V_err"] == pytest.approx(1886540, abs=1) and e5["V_coherent"] == pytest.approx(2026476, abs=1) assert e5["erreur_rel"] == pytest.approx(-0.0691, abs=5e-5) assert e5["delta_V"] == pytest.approx(139936, abs=1) and e5["delta_formule_sujet"] == pytest.approx(169935, abs=1) # l'écart exact n'égale RR/R' que si les parts de réserve coïncident assert e5["ratio_reserve_comparable"] == pytest.approx(0.0691, abs=5e-5) and e5["ratio_reserve_sujet"] == pytest.approx(0.0826, abs=5e-5) e6 = ex["ex6"] assert e6["rne_sans"] == pytest.approx(23657.04, abs=0.01) and e6["ratio_sans"] == pytest.approx(0.417, abs=5e-4) assert e6["rne_5"] == pytest.approx(21628.19, abs=0.01) assert e6["V_avec"] == pytest.approx(695078, abs=1) and e6["V_sans"] == pytest.approx(746279, abs=1) assert e6["surevaluation"] == pytest.approx(0.0737, abs=5e-5) e7 = ex["ex7"] assert e7["rne_double"] == pytest.approx(64369.60, abs=0.01) and e7["V_double"] == pytest.approx(1457975, abs=1) assert e7["erreur_rel_double"] == pytest.approx(-0.284, abs=5e-4) assert e7["va_capex_perpetuel"] == pytest.approx(e7["va_reserve_perpetuelle"], rel=1e-12) # ----------------------------------------------------------------------------- figures def test_figures_presentes(): for slug in ["cascade", "structure", "evolution", "ratio-loyer", "sensibilite"]: p = ROOT / "assets/figures/ch28" / f"ch28-fig-{slug}.pdf" assert p.exists() and p.stat().st_size > 1000, p