"""Tests du chapitre 5 — modèle monocentrique simulé, gradient prix/distance du cas 1, décomposition du cas 4, rente résiduelle, capitalisation fiscale et exercices. Tolérances : identités à 1e-9 ; valeurs citées dans le texte à l'arrondi affiché (0,5 $ sur les montants au dollar, 0,005 point sur les pourcentages à deux décimales, 0,0005 sur les coefficients en log à trois décimales).""" import sys, pathlib, math import numpy as np, pytest sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) import ch05_rente as c5 from traite import cas1, cas4 from traite import hedonique as h P = c5.PARAMS @pytest.fixture(scope="module") def data(): d1 = cas1.charger(); v = d1["ventes"]; m = cas1.hedonique(v) d4 = cas4.charger() return {"v": v, "sujet": d1["sujet"], "modele": m, "tranches": c5.tranches_distance(v), "gradients": c5.gradients_cas1(v, m), "pf": d4["portefeuille"], "dec": c5.decomposition_rmr(d4["portefeuille"])} class TestMonocentrique: def test_gradient_et_frontiere(self): g = c5.gradient_exponentiel(P) assert g == pytest.approx(0.029167, abs=5e-6) and c5.demi_distance(g) == pytest.approx(23.8, abs=0.05) dbar = c5.frontiere_urbaine(P) assert dbar == pytest.approx(32.29, abs=0.005) # identité : r(d_bar) = ra assert c5.rente_fonciere(dbar, P) == pytest.approx(P["ra"], rel=1e-9) assert c5.prix_logement(dbar, P) == pytest.approx(85.97, abs=0.005) def test_condition_de_muth_numerique(self): # p'(d) q(d) = -t, vérifié par différences finies for d in (0.0, 5.0, 12.0, 25.0): eps = 1e-5 dp = (c5.prix_logement(d + eps, P) - c5.prix_logement(d - eps, P)) / (2 * eps) assert dp * c5.consommation_logement(d, P) == pytest.approx(-P["t"], rel=1e-6) def test_valeurs_tableau(self): tab = c5.gradient_exact_vs_exponentiel(P).set_index("d_km") assert tab.loc[10.0, "p_exact"] == pytest.approx(191.61, abs=0.005) and tab.loc[10.0, "p_exponentiel"] == pytest.approx(194.22, abs=0.005) assert tab.loc[10.0, "ecart_pct"] == pytest.approx(0.014, abs=5e-4) assert tab.loc[25.0, "p_exact"] == pytest.approx(114.18, abs=0.005) and tab.loc[25.0, "p_exponentiel"] == pytest.approx(125.40, abs=0.005) assert tab.loc[25.0, "ecart_pct"] == pytest.approx(0.098, abs=5e-4) assert tab.loc[0.0, "q_m2"] == pytest.approx(92.3, abs=0.05) and tab.loc[10.0, "q_m2"] == pytest.approx(114.3, abs=0.05) assert c5.consommation_logement(c5.frontiere_urbaine(P), P) == pytest.approx(200.3, abs=0.05) assert tab.loc[10.0, "rente_fonciere"] == pytest.approx(28.92, abs=0.005) and tab.loc[10.0, "densite_relative"] == pytest.approx(0.396, abs=5e-4) assert c5.valeur_terrain(P["r0"], P["i"]) == 1600 and c5.valeur_terrain(P["ra"], P["i"]) == 40 assert c5.elasticite_rente_prix(P["alpha"]) == pytest.approx(3.333, abs=5e-4) def test_identites_production(self): d = np.array([3.0, 8.0, 20.0]) ratio_p = c5.prix_logement(d, P) / P["p0"] assert np.allclose(c5.rente_fonciere(d, P) / P["r0"], ratio_p ** (1 / P["alpha"])) # densité = (p/p0)^(1/alpha) * y/(y - t d) assert np.allclose(c5.densite(d, P), ratio_p ** (1 / P["alpha"]) * P["y"] / (P["y"] - P["t"] * d)) # l'approximation exponentielle surestime le prix pour d > 0 assert np.all(c5.prix_exponentiel(d, P) > c5.prix_logement(d, P)) def test_alonso(self): (R1, t1), (R2, t2), (R3, t3) = c5.ALONSO.values() assert c5.frontiere_usages(R1, t1, R2, t2) == pytest.approx(9.47, abs=0.005) assert c5.frontiere_usages(R2, t2, R3, t3) == pytest.approx(14.29, abs=0.005) assert (R3 - P["ra"]) / t3 == pytest.approx(32.22, abs=0.005) d = np.array([5.0, 12.0, 20.0, 35.0]); env, courbes = c5.enveloppe_alonso(d) assert np.all(env >= np.array([max(c[i] for c in courbes.values()) for i in range(len(d))])) and env[-1] == P["ra"] class TestCas1: def test_tranches(self, data): tr = data["tranches"] assert int(tr["n"].sum()) == 600 and list(tr["n"]) == [116, 67, 37, 97, 100, 64, 30, 89] assert tr.loc["0-2", "prix_m2_median"] == pytest.approx(3600, abs=0.5) and tr.loc["20 et plus", "prix_m2_median"] == pytest.approx(2684, abs=0.5) assert tr.loc["10-12", "prix_m2_median"] == pytest.approx(3714, abs=0.5) and tr.loc["10-12", "prix_median"] == 548000 assert tr.loc["20 et plus", "terrain_median"] == 1184 and tr.loc["0-2", "terrain_median"] == pytest.approx(535.5, abs=0.01) assert tr.loc["10-12", "secteur_dominant"] == "Aylmer" and tr.loc["20 et plus", "secteur_dominant"] == "Masson-Angers" def test_gradients(self, data): g = data["gradients"] assert g["brut_prix_m2"]["gamma"] == pytest.approx(0.01020, abs=5e-6) and g["brut_prix_m2"]["se"] == pytest.approx(0.00089, abs=5e-6) assert g["brut_prix_m2"]["r2"] == pytest.approx(0.185, abs=5e-4) and c5.demi_distance(g["brut_prix_m2"]["gamma"]) == pytest.approx(68, abs=0.5) assert g["controle_lineaire"]["gamma"] == pytest.approx(0.01083, abs=5e-6) and g["controle_lineaire"]["se"] == pytest.approx(0.00067, abs=5e-6) assert g["controle_lineaire"]["r2"] == pytest.approx(0.759, abs=5e-4) u = g["controle_urbain_16km"] assert u["n"] == 511 and u["gamma"] == pytest.approx(-0.00266, abs=5e-6) and u["se"] == pytest.approx(0.00103, abs=5e-6) and u["r2"] == pytest.approx(0.771, abs=5e-4) q = g["controle_quadratique"] assert q["b1"] == pytest.approx(0.00900, abs=5e-6) and q["se1"] == pytest.approx(0.00169, abs=5e-6) assert q["b2_100"] == pytest.approx(-0.06966, abs=5e-5) and q["se2"] == pytest.approx(0.0056, abs=5e-5) and q["r2"] == pytest.approx(0.808, abs=5e-4) sommet = -q["b1"] / (2 * q["b2_100"] / 100) assert sommet == pytest.approx(6.5, abs=0.05) assert q["gamma_10"] == pytest.approx(0.00493, abs=5e-6) pente20 = -(q["b1"] + 2 * q["b2_100"] * 20 / 100) assert pente20 == pytest.approx(0.0189, abs=5e-5) def test_modele_reference(self, data): r = data["gradients"]["reference"] assert r["gamma_au_dela_3km"] == pytest.approx(0.00357, abs=5e-6) and r["se"] == pytest.approx(0.00265, abs=5e-6) and r["t"] == pytest.approx(-1.35, abs=0.005) assert r["r2"] == pytest.approx(0.853, abs=5e-4) pct = r["secteurs_pct"] assert pct["Gatineau"] == pytest.approx(-0.022, abs=5e-4) and pct["Aylmer"] == pytest.approx(0.089, abs=5e-4) assert pct["Buckingham"] == pytest.approx(-0.221, abs=5e-4) and pct["Masson-Angers"] == pytest.approx(-0.231, abs=5e-4) gs = data["gradients"]["secteurs_medianes"] assert gs.loc["Hull", "dist"] == pytest.approx(1.6, abs=0.05) and gs.loc["Buckingham", "dist"] == pytest.approx(29.2, abs=0.05) p_hg = (math.log(gs.loc["Gatineau", "prix"]) - math.log(gs.loc["Hull", "prix"])) / (gs.loc["Gatineau", "dist"] - gs.loc["Hull", "dist"]) p_hb = (math.log(gs.loc["Buckingham", "prix"]) - math.log(gs.loc["Hull", "prix"])) / (gs.loc["Buckingham", "dist"] - gs.loc["Hull", "dist"]) assert p_hg == pytest.approx(-0.00345, abs=5e-6) and p_hb == pytest.approx(-0.00973, abs=5e-6) # effet distance nul pour le sujet (2,1 km < 3 km) assert max(data["sujet"]["dist_centre_km"] - 3, 0) == 0 g = data["gradients"]["controle_lineaire"]["gamma"] assert 551029 * g == pytest.approx(5969, abs=0.5) and 551029 * (1 - math.exp(-5 * g)) == pytest.approx(29050, abs=0.5) class TestCas4: def test_decomposition(self, data): dec = data["dec"] attendu = {"Montréal": (206237, 9270, 0.0449), "Gatineau": (184173, 10011, 0.0544), "Sherbrooke": (137364, 7884, 0.0574), "Québec": (125455, 7237, 0.0577), "Trois-Rivières": (116806, 7345, 0.0629), "Saguenay": (101969, 6520, 0.0639)} for k, (vl, rl, tx) in attendu.items(): assert dec.loc[k, "valeur_par_logement"] == pytest.approx(vl, abs=0.5) and dec.loc[k, "rne_par_logement"] == pytest.approx(rl, abs=0.5) assert dec.loc[k, "taux_pondere"] == pytest.approx(tx, abs=5e-5) # identité : écart total = contribution RNE + contribution taux assert np.allclose(dec["ecart_ln_V_n"], dec["contrib_rne"] + dec["contrib_taux"], atol=1e-12) assert dec.loc["Québec", "contrib_rne"] == pytest.approx(-0.248, abs=5e-4) and dec.loc["Québec", "contrib_taux"] == pytest.approx(-0.249, abs=5e-4) assert dec.loc["Gatineau", "contrib_rne"] == pytest.approx(0.077, abs=5e-4) and dec.loc["Gatineau", "contrib_taux"] == pytest.approx(-0.190, abs=5e-4) assert dec.loc["Saguenay", "ecart_ln_V_n"] == pytest.approx(-0.704, abs=5e-4) and dec.loc["Saguenay", "ratio_valeur"] == pytest.approx(0.494, abs=5e-4) for k in ("Sherbrooke", "Trois-Rivières"): part_taux = dec.loc[k, "contrib_taux"] / dec.loc[k, "ecart_ln_V_n"] assert 0.55 < part_taux < 0.65 assert list(dec.index) == ["Montréal", "Gatineau", "Sherbrooke", "Québec", "Trois-Rivières", "Saguenay"] assert int(dec["logements"].sum()) == 888 and int(dec["n"].sum()) == 48 class TestResidu: def test_valeur_residuelle(self): CN = 397567.33; dep = 0.30 * CN; V = 551029.0 assert dep == pytest.approx(119270.20, abs=0.005) and CN - dep == pytest.approx(278297.13, abs=0.005) VL = c5.valeur_residuelle(V, CN, dep) assert VL == pytest.approx(272732, abs=0.5) and VL / V == pytest.approx(0.495, abs=5e-4) and c5.levier_residuel(V, VL) == pytest.approx(2.02, abs=0.005) assert c5.valeur_residuelle(0.95 * V, CN, dep) == pytest.approx(245180, abs=0.5) assert c5.valeur_residuelle(0.95 * V, CN, dep) / VL - 1 == pytest.approx(-0.101, abs=5e-4) assert c5.valeur_residuelle(0.90 * V, CN, dep) / VL - 1 == pytest.approx(-0.202, abs=5e-4) # identité du levier : dVL/VL = (V/VL) dV/V assert (c5.valeur_residuelle(0.95 * V, CN, dep) - VL) / VL == pytest.approx(c5.levier_residuel(V, VL) * (-0.05), rel=1e-12) assert 1.1 ** c5.elasticite_rente_prix(0.3) - 1 == pytest.approx(0.374, abs=5e-4) def test_capozza(self): cz = c5.decomposition_capozza(P["ra"], P["i"], 25.0, c5.rente_fonciere(10.0, P), 60.0) assert cz["valeur_agricole"] == 40 and cz["accessibilite"] == pytest.approx(538.4, abs=0.05) and cz["total"] == pytest.approx(663.4, abs=0.05) assert cz["accessibilite"] / cz["total"] == pytest.approx(0.81, abs=5e-3) class TestTaxe: def test_formule_exacte_et_approchee(self): c = c5.capitalisation_taxe(500000, 0.0070, 0.0080, 0.05) assert c["dT"] == 500 and c["dV_approx"] == -10000 and c["dV_exact"] == pytest.approx(-8621, abs=0.5) and c["dV_pct_exact"] == pytest.approx(-0.0172, abs=5e-5) # identité : dV_exact = -dT/(i + tau1) et V1 = R/(i+tau1) assert c["dV_exact"] == pytest.approx(-500 / 0.058, rel=1e-12) and c["V1_exact"] == pytest.approx(c["R"] / 0.058, rel=1e-12) assert c["dV_approx"] / c["dV_exact"] == pytest.approx(0.058 / 0.05, rel=1e-12) assert 0.6 * c["dV_approx"] == -6000 c2 = c5.capitalisation_taxe(500000, 0.0070, 0.0070 + 22.4 / 1e4, 0.05) assert c2["dT"] == pytest.approx(1120, abs=1e-6) and c2["dV_approx"] == pytest.approx(-22400, abs=1e-6) assert c2["dV_exact"] == pytest.approx(-18906, abs=0.5) and c2["dV_pct_exact"] == pytest.approx(-0.0378, abs=5e-5) assert c5.valeur_avec_taxe(28500, 0.05, 0.0075) / c5.valeur_avec_taxe(28500, 0.05, 0.0) - 1 == pytest.approx(-0.13, abs=5e-3) def test_taux_2026(self): T = c5.TAUX_2026 ecart = (T["Québec (1 à 5 logements)"] - T["Gatineau (taux de base)"]) / 100 assert ecart * 1e4 == pytest.approx(22.4, abs=0.005) and ecart * 500000 == pytest.approx(1120, abs=0.005) assert c5.taux_effectif(T["Gatineau (taux de base)"], 1.02) == pytest.approx(0.5122, abs=5e-5) assert c5.taux_effectif(T["Québec (1 à 5 logements)"], 1.10) == pytest.approx(0.6785, abs=5e-5) assert 468000 * T["Gatineau (taux de base)"] / 100 == pytest.approx(2445, abs=0.5) and 468000 * T["Taxe scolaire 2026-2027 (toute la province)"] / 100 == pytest.approx(370, abs=0.5) d6 = (T["Québec (6 logements et plus)"] - T["Québec (1 à 5 logements)"]) / 100 assert d6 * 1e4 == pytest.approx(2.33, abs=0.005) and d6 * 2e6 == pytest.approx(466, abs=0.5) and d6 * 2e6 / 0.055 == pytest.approx(8473, abs=0.5) tau = c5.taux_effectif(T["Gatineau (taux de base)"], 1.02) / 100 + T["Taxe scolaire 2026-2027 (toute la province)"] / 100 assert tau / (0.0412 + tau + 0.015 - 0.02) == pytest.approx(0.14, abs=5e-3) def test_grille(self): G = c5.grille_capitalisation(500000, [5, 10, 25, 50, 100], [0.04, 0.05, 0.06, 0.07]) assert G.loc[10, "r=5%"] == -10000 and G.loc[50, "r=4%"] == -62500 and G.loc[100, "r=7%"] == pytest.approx(-71429, abs=0.5) assert G.loc[25, "r=6%"] == pytest.approx(-20833, abs=0.5) and G.loc[5, "r=7%"] == pytest.approx(-3571, abs=0.5) assert G.loc[10, "r=4%"] / G.loc[10, "r=7%"] - 1 == pytest.approx(0.75, rel=1e-9) class TestExercices: def test_ex1(self): a, b = c5.THUNEN["Maraîchage"], c5.THUNEN["Grandes cultures"] assert a["Y"] * (a["p"] - a["c"]) == 1000 and a["Y"] * a["f"] == 60 and b["Y"] * (b["p"] - b["c"]) == 600 and b["Y"] * b["f"] == 20 assert c5.distance_limite_thunen(**a) == pytest.approx(16.67, abs=0.005) and c5.distance_limite_thunen(**b) == 30 assert c5.frontiere_usages(1000, 60, 600, 20) == 10 and c5.rente_thunen(10, **a) == 400 == c5.rente_thunen(10, **b) assert c5.valeur_terrain(1000, 0.04) == 25000 def test_ex2(self): c = c5.capitalisation_taxe(500000, 0.0070, 0.0080, 0.05) assert c["dT"] == 500 and c["dV_approx"] == -10000 and c["dV_exact"] == pytest.approx(-8621, abs=0.5) and c["V1_exact"] == pytest.approx(491379, abs=0.5) net = c5.capitalisation_taxe(500000, 0.0070, 0.0070 + 200 / 500000, 0.05) assert net["dV_approx"] == pytest.approx(-4000, abs=1e-6) and net["dV_exact"] == pytest.approx(-3484, abs=0.5) def test_ex3(self): assert c5.prix_logement(5.0, P) == pytest.approx(223.98, abs=0.005) and c5.prix_logement(10.0, P) == pytest.approx(191.61, abs=0.005) assert c5.prix_exponentiel(5.0, P) == pytest.approx(224.72, abs=0.005) and c5.prix_exponentiel(10.0, P) == pytest.approx(194.22, abs=0.005) assert c5.consommation_logement(0.0, P) == pytest.approx(92.3, abs=0.05) and c5.consommation_logement(10.0, P) == pytest.approx(114.3, abs=0.05) P2 = dict(P, t=525.0) assert c5.gradient_exponentiel(P2) == pytest.approx(0.021875, abs=1e-9) and c5.demi_distance(c5.gradient_exponentiel(P2)) == pytest.approx(31.7, abs=0.05) def test_ex4(self): assert 1.1 ** (1 / 0.3) - 1 == pytest.approx(0.374, abs=5e-4) and 0.9 ** (1 / 0.3) - 1 == pytest.approx(-0.296, abs=5e-4) CN = 397567.33; dep = 0.3 * CN VL = c5.valeur_residuelle(560000, CN, dep); VL2 = c5.valeur_residuelle(532000, CN, dep) assert VL == pytest.approx(281703, abs=0.5) and c5.levier_residuel(560000, VL) == pytest.approx(1.99, abs=0.005) assert VL2 == pytest.approx(253703, abs=0.5) and VL2 / VL - 1 == pytest.approx(-0.099, abs=5e-4) def test_ex5(self): tq, tg, tm = c5.taux_effectif(0.7464, 1.10), c5.taux_effectif(0.5224, 1.02), 0.4631 assert tq == pytest.approx(0.6785, abs=5e-5) and tg == pytest.approx(0.5122, abs=5e-5) assert 500000 * tq / 100 == pytest.approx(3393, abs=0.5) and 500000 * tg / 100 == pytest.approx(2561, abs=0.5) and 500000 * tm / 100 == pytest.approx(2316, abs=0.5) ecart = (tq - tg) / 100 * 500000 assert ecart == pytest.approx(832, abs=0.5) and ecart / 0.05 == pytest.approx(16639, abs=0.5) and 0.6 * ecart / 0.05 == pytest.approx(9983, abs=0.5) def test_ex6(self, data): v = data["v"]; y = np.log(v["prix_vente"].values); d = v["dist_centre_km"].values X = cas1.matrice(v); Xa = X.copy(); Xa["dist_lin"] = d r = h.mco(Xa.values, y); cols = list(Xa.columns) assert r["beta"][cols.index("dist_centre")] == pytest.approx(0.0029, abs=5e-5) and r["se_hc1"][cols.index("dist_centre")] == pytest.approx(0.0081, abs=5e-5) assert r["beta"][cols.index("dist_lin")] == pytest.approx(-0.0064, abs=5e-5) and r["se_hc1"][cols.index("dist_lin")] == pytest.approx(0.0076, abs=1e-4) assert r["beta"][cols.index("sect_Aylmer")] == pytest.approx(0.093, abs=5e-4) and r["r2"] == pytest.approx(0.853, abs=5e-4) Xc = X.drop(columns=["dist_centre"] + [f"sect_{s}" for s in cas1.SECTEURS[1:]]); Xc["dist"] = d m = (d <= 10) & v["secteur"].isin(["Hull", "Gatineau"]).values rr = h.mco(Xc.values[m], y[m]) assert int(m.sum()) == 380 and rr["beta"][-1] == pytest.approx(-0.00589, abs=5e-6) and rr["se_hc1"][-1] == pytest.approx(0.00144, abs=5e-6) and rr["r2"] == pytest.approx(0.80, abs=5e-3) m2 = (v["secteur"] == "Aylmer").values; ra = h.mco(Xc.values[m2], y[m2]) assert int(m2.sum()) == 125 and ra["beta"][-1] == pytest.approx(-0.00675, abs=5e-6) and ra["se_hc1"][-1] == pytest.approx(0.00458, abs=5e-6) m3 = (v["secteur"] == "Hull").values; rh = h.mco(Xc.values[m3], y[m3]) assert int(m3.sum()) == 186 and rh["beta"][-1] == pytest.approx(-0.00746, abs=5e-6) and rh["se_hc1"][-1] == pytest.approx(0.00646, abs=5e-6) def test_ex7(self): E = [(0, 0, 100000), (12, 4, 30000)]; pts = {"A": (3, 0), "B": (9, 3), "C": (12, -6)} dist = {k: [math.hypot(x - cx, y - cy) for cx, cy, _ in E] for k, (x, y) in pts.items()} assert dist["A"] == pytest.approx([3.00, 9.85], abs=0.005) and dist["B"] == pytest.approx([9.49, 3.16], abs=0.005) and dist["C"] == pytest.approx([13.42, 10.00], abs=0.005) for lam, att, ab, bc in [(0.10, (85286, 60592, 37178), 0.089, 0.130), (0.20, (59066, 30935, 10894), 0.175, 0.298)]: A = {k: sum(e * math.exp(-lam * dd) for (_, _, e), dd in zip(E, dist[k])) for k in pts} assert (A["A"], A["B"], A["C"]) == pytest.approx(att, abs=0.5) assert (A["A"] / A["B"]) ** 0.25 - 1 == pytest.approx(ab, abs=5e-4) and (A["B"] / A["C"]) ** 0.25 - 1 == pytest.approx(bc, abs=5e-4) # un seul centre : theta ln A = theta ln E - theta lambda d lam, theta = 0.1, 0.25 for dd in (2.0, 7.0): assert theta * math.log(100000 * math.exp(-lam * dd)) == pytest.approx(theta * math.log(100000) - theta * lam * dd, rel=1e-12)