"""Tests du chapitre 3 — Offre, demande et cycles. Tolérances : identités algébriques 1e-9 (relatif) ; valeurs citées dans le texte : 0,5 unité sur les logements et les dollars arrondis, 0,05 point de pourcentage sur les taux ; valeurs réelles lues dans data/raw : exactes (sommes d'entiers) ou 0,01.""" import sys, pathlib, math import numpy as np, pytest sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) import ch03_cycles as cas from dataclasses import replace @pytest.fixture(scope="module") def p4q(): return cas.Parametres4Q() @pytest.fixture(scope="module") def pc(): return cas.ParametresCycle() class TestQuatreQuadrants: def test_parametres_derives(self, p4q): assert p4q.b == pytest.approx(3.4722, abs=5e-4) assert p4q.a == pytest.approx(150_000, abs=1e-6) assert p4q.P0 == pytest.approx(288_000, abs=1e-6) assert p4q.C0 == pytest.approx(1_000, abs=1e-9) assert p4q.c1 == pytest.approx(48.0, abs=1e-9) def test_equilibre_ferme_egal_iteratif(self, p4q): eq, it = cas.equilibre_4q(p4q), cas.equilibre_4q_iteratif(p4q) for k in ("S", "R", "P", "C"): assert eq[k] == pytest.approx(it[k], rel=1e-6) assert eq["S"] == pytest.approx(100_000, abs=1e-6) and eq["R"] == pytest.approx(14_400, abs=1e-6) assert eq["C"] == pytest.approx(p4q.delta * eq["S"], rel=1e-12) # ΔS = 0 assert eq["P"] == pytest.approx(eq["R"] / p4q.i, rel=1e-12) # P = R/i assert eq["C"] == pytest.approx((eq["P"] - p4q.c0) / p4q.c1, rel=1e-9) # C = g(P) def test_baisse_du_taux(self, p4q): lt = cas.equilibre_4q(p4q, i=0.04); ct = cas.court_terme_4q(p4q, i=0.04) assert ct["P"] == pytest.approx(360_000, abs=0.5) assert lt["S"] == pytest.approx(109_375, abs=0.5) assert lt["R"] == pytest.approx(11_700, abs=0.5) assert lt["P"] == pytest.approx(292_500, abs=0.5) assert lt["C"] == pytest.approx(1_093.75, abs=0.01) def test_statique_comparative(self, p4q): sc = cas.statique_comparative(p4q) assert sc.loc["Demande a : +10 %", "R_ct"] == pytest.approx(18_720, abs=0.5) assert sc.loc["Demande a : +10 %", "S_lt"] == pytest.approx(113_846.15, abs=0.5) assert sc.loc["Demande a : +10 %", "R_lt"] == pytest.approx(14_732.31, abs=0.5) assert sc.loc["Demande a : +10 %", "dR_ct_pct"] == pytest.approx(30.0, abs=0.05) assert sc.loc["Demande a : +10 %", "dR_lt_pct"] == pytest.approx(2.31, abs=0.05) assert sc.loc["Coût c0 : +10 %", "S_lt"] == pytest.approx(96_153.85, abs=0.5) assert sc.loc["Coût c0 : +10 %", "dP_lt_pct"] == pytest.approx(7.69, abs=0.05) assert sc.loc["Taux i : 5 % → 4 %", "dP_lt_pct"] == pytest.approx(1.56, abs=0.05) assert sc.loc["Taux i : 5 % → 4 %", "dR_lt_pct"] == pytest.approx(-18.75, abs=0.05) def test_elasticites(self, p4q): el = cas.elasticites_4q(p4q) assert el["eps_C_P"] == pytest.approx(6.0, rel=1e-9) assert el["kappa"] == pytest.approx(0.12, rel=1e-9) assert el["eps_S_a"] == pytest.approx(1.385, abs=5e-3) class TestDynamique: def test_etat_initial_stationnaire(self, pc): df = cas.simuler_cycle(replace(pc, choc_demande=0.0)) assert np.allclose(df["S"], pc.p4q.S0, rtol=1e-9) and np.allclose(df["R"], pc.p4q.R0, rtol=1e-9) assert np.allclose(df["v"], pc.v_nat, atol=1e-12) assert pc.c0 == pytest.approx(276_000, abs=1e-6) and pc.kappa == pytest.approx(0.48, rel=1e-9) def test_stationnaire_apres_choc(self, pc): ss = cas.etat_stationnaire_cycle(pc) assert ss["S"] == pytest.approx(108_902, abs=1.0) assert ss["R"] == pytest.approx(14_453, abs=1.0) assert ss["C"] == pytest.approx(pc.p4q.delta * ss["S"], rel=1e-12) df = cas.simuler_cycle(replace(pc, T=600)) assert df["S"].iloc[-1] == pytest.approx(ss["S"], rel=2e-3) assert df["v"].iloc[-1] == pytest.approx(pc.v_nat, abs=2e-3) def test_mesures_du_cycle_cite(self, pc): r = cas.resume_cycle(cas.simuler_cycle(pc), pc) assert r["R_pointe_pct"] == pytest.approx(15.0, abs=0.1) assert r["t_pointe"] == 13 assert r["R_creux_pct"] == pytest.approx(-7.0, abs=0.1) assert r["t_creux"] == 35 assert r["v_max_pct"] == pytest.approx(5.0, abs=0.05) assert r["v_min_pct"] == pytest.approx(1.17, abs=0.05) assert r["C_max_annuel"] == pytest.approx(4_711, abs=1.0) assert r["periode_trim"] == 47 assert r["amortissement"] == pytest.approx(0.17, abs=0.01) def test_sensibilite(self, pc): s = cas.sensibilite_cycle(pc, delais=(2, 6, 12), mult_c1=(0.25, 1.0), lams=(0.75,)).set_index(["L", "mult_c1"]) assert s.loc[(12, 0.25), "R_creux_pct"] == pytest.approx(-18.06, abs=0.1) assert s.loc[(2, 0.25), "R_creux_pct"] == pytest.approx(-1.21, abs=0.1) assert s.loc[(6, 1.0), "R_creux_pct"] > 0 # offre peu réactive : pas de sous-ajustement assert math.isnan(s.loc[(6, 1.0), "periode_trim"]) # pas de second cycle # le délai allonge la période et creuse le creux assert s.loc[(12, 0.25), "periode_trim"] > s.loc[(6, 0.25), "periode_trim"] > s.loc[(2, 0.25), "periode_trim"] def test_condition_oscillation(self, pc): d = pc.p4q.delta for k in (0.05, 0.12, 0.2449, 0.2451, 0.3, 0.48, 0.6, 1.2): a = cas.condition_oscillation_L1(k, d); b = cas.racines_caracteristiques(k, d, L=1) assert a["oscille"] == b["oscille"] assert a["module"] == pytest.approx(b["module"], rel=1e-9) if a["oscille"]: assert a["periode"] == pytest.approx(b["periode"], rel=1e-9) assert a["module"] == pytest.approx(math.sqrt(k), rel=1e-12) assert cas.condition_oscillation_L1(0.12, d)["seuil"] == pytest.approx(0.2450, abs=1e-4) assert not cas.condition_oscillation_L1(0.12, d)["oscille"] c = cas.condition_oscillation_L1(0.48, d) assert c["oscille"] and c["stable"] and c["periode"] == pytest.approx(8.1, abs=0.05) and c["module"] == pytest.approx(0.693, abs=5e-4) assert not cas.condition_oscillation_L1(1.2, d)["stable"] assert cas.racines_caracteristiques(0.48, d, L=3)["module"] > 1.0 # délai de 3 ans : instable class TestDonneesReelles: def test_mises_en_chantier_quebec(self): ann = cas.mises_en_chantier_annuelles() assert (ann.loc[1990:2025, "trimestres"] == 4).all() and ann.loc[2026, "trimestres"] == 3 attendu = {1990: 48_070, 2000: 24_695, 2005: 50_910, 2015: 37_926, 2020: 54_066, 2021: 67_810, 2022: 57_107, 2023: 38_912, 2024: 48_713, 2025: 59_864, 2026: 42_159} for y, v in attendu.items(): assert ann.loc[y, "total"] == v assert ann.loc[2005, "individuelles"] == 23_930 and ann.loc[2025, "individuelles"] == 8_477 assert ann.loc[2005, "collectifs"] == 26_980 and ann.loc[2025, "collectifs"] == 51_387 assert ann.loc[2025, "part_individuelles"] == pytest.approx(0.1416, abs=5e-4) assert ann.loc[2005, "part_individuelles"] == pytest.approx(0.470, abs=5e-4) # cohérence interne : les cinq types somment au total tot = ann[["individuelles", "jumeles", "bande", "appartements"]].sum(axis=1) assert np.allclose(tot.loc[1990:2026], ann.loc[1990:2026, "total"]) a9 = ann.loc[1990:2026] assert np.allclose(a9["jumeles"] + a9["bande"] + a9["appartements"], a9["collectifs"]) # tableau 3.4 du chapitre (total, individuelles, jumelés + en bande, appartements, part des individuelles) tab = {1990: (48_070, 24_942, 3_623, 19_505, 51.9), 2005: (50_910, 23_930, 3_752, 23_228, 47.0), 2015: (37_926, 9_698, 3_895, 24_333, 25.6), 2021: (67_810, 14_826, 6_265, 46_719, 21.9), 2023: (38_912, 8_601, 2_342, 27_969, 22.1), 2025: (59_864, 8_477, 3_716, 47_671, 14.2)} for y, (tt, ind, jb, ap, part) in tab.items(): r = ann.loc[y] assert (r["total"], r["individuelles"], r["jumeles"] + r["bande"], r["appartements"]) == (tt, ind, jb, ap) assert 100 * r["part_individuelles"] == pytest.approx(part, abs=0.05) # variation 2021 → 2023 et 2023 → 2025 assert (ann.loc[2023, "total"] / ann.loc[2021, "total"] - 1) == pytest.approx(-0.426, abs=5e-4) assert (ann.loc[2025, "total"] / ann.loc[2023, "total"] - 1) == pytest.approx(0.538, abs=5e-4) def test_montreal_incomplet(self): m = cas.mises_en_chantier_montreal_annuelles() assert m.loc[2024, "mois"] == 12 and m.loc[2024, "total"] == 17_570 assert m.loc[2025, "mois"] == 9 and m.loc[2025, "total"] == 21_138 # juillet-septembre 2025 absents du fichier q = cas.mises_en_chantier_montreal_annuelles("Québec") assert q.loc[2025, "mois"] == 12 and q.loc[2025, "total"] == 54_220 def test_taux_directeur(self): ch = cas.changements_taux_directeur() assert len(ch) == 23 assert ch.loc["2022-03-03", "apres"] == 0.50 and ch.loc["2023-07-13", "apres"] == 5.00 assert ch.loc["2025-10-30", "apres"] == 2.25 and ch.loc["2026-10-29", "apres"] == 2.50 assert ch.loc["2022-07-14", "variation_pb"] == 100 assert int(ch.loc["2022-03-03":"2023-07-13", "variation_pb"].sum()) == 475 assert int(ch.loc["2024-06-06":"2025-10-30", "variation_pb"].sum()) == -275 def test_taux_effectifs_a4(self): a4 = cas.taux_effectifs_a4() assert a4["serie_assure"] == "V122667780" and a4["serie_non_assure"] == "V122667786" assert a4["assure_fixe_5ans_plus"] == pytest.approx(4.12) and a4["non_assure_fixe_5ans_plus"] == pytest.approx(4.58) def test_ipc_ipln(self): ipc = cas.ipc_quebec(); ipln = cas.ipln_quebec() assert ipc.index[-1].strftime("%Y-%m") == "2026-09" and ipc.iloc[-1] == 167.7 and ipc.iloc[-13] == 162.6 assert (ipc.iloc[-1] / ipc.iloc[-13] - 1) * 100 == pytest.approx(3.14, abs=0.01) assert ipln.iloc[-1] == 150.0 and (ipln.iloc[-1] / ipln.iloc[-13] - 1) * 100 == pytest.approx(1.28, abs=0.01) loyer = cas.ipc_quebec("Loyer") assert loyer.iloc[-1] == 174.2 and (loyer.iloc[-1] / loyer.iloc[-13] - 1) * 100 == pytest.approx(3.3, abs=0.05) def test_serie_cout_usage(self): cu = cas.serie_cout_usage() assert cu.index[0].strftime("%Y-%m") == "2001-01" and cu.index[-1].strftime("%Y-%m") == "2026-09" t = cu.loc[cas.DATES_TABLEAU_COUT_USAGE] assert t["hyp5"].round(2).tolist() == [7.79, 7.49, 5.29, 4.94, 7.04, 6.09] assert t["pi"].round(2).tolist() == [2.76, 2.02, 2.84, 0.30, 4.16, 3.14] assert t["pr_ipc"].round(1).tolist() == [12.0, 11.4, 17.3, 12.6, 16.1, 15.9] assert np.allclose(cu["u_ipc"], cu["hyp5"] + 100 * (cas.TAUX_TAXE_QUEBEC_2026 + cas.DELTA_ENTRETIEN_RISQUE) - cu["pi"]) assert np.allclose(cu["pr_ipc"] * cu["u_ipc"], 100.0) # plancher du coût d'usage sous l'hypothèse IPLN en 2021-2022 (anticipations extrapolatives) assert cu["u_ipln"].min() < 1.5 and cu["u_ipln"].idxmin().year in (2021, 2022) class TestCoutUsageExemple: def test_formules(self): assert cas.taux_cout_usage(0.06, 0.01, 0.02, 0.03) == pytest.approx(0.06) assert cas.cout_usage(400_000, 0.06, 0.01, 0.02, 0.03) == pytest.approx(24_000) assert cas.ratio_prix_loyer(0.06, 0.01, 0.02, 0.03) == pytest.approx(1 / 0.06) assert cas.ratio_prix_loyer(0.02, 0.0, 0.0, 0.03) == math.inf def test_exemple_cite(self): ex = cas.exemple_cout_usage() assert cas.TAUX_TAXE_QUEBEC_2026 == pytest.approx(0.0082539, abs=1e-7) assert ex["u_affiche_ipc"] == pytest.approx(0.06415, abs=5e-5) assert ex["cout_annuel_affiche_ipc"] == pytest.approx(29_735, abs=0.5) assert ex["cout_mensuel_affiche_ipc"] == pytest.approx(2_478, abs=0.5) assert ex["pr_affiche_ipc"] == pytest.approx(15.59, abs=0.005) assert ex["u_effectif_ipc"] == pytest.approx(0.04805, abs=5e-5) assert ex["cout_annuel_effectif_ipc"] == pytest.approx(22_273, abs=0.5) assert ex["cout_mensuel_effectif_ipc"] == pytest.approx(1_856, abs=0.5) assert ex["pr_effectif_ipc"] == pytest.approx(20.81, abs=0.005) assert ex["u_effectif_ipln"] == pytest.approx(0.06605, abs=5e-5) assert ex["cout_mensuel_effectif_ipln"] == pytest.approx(2_551, abs=0.5) assert ex["pr_effectif_ipln"] == pytest.approx(15.14, abs=0.005) assert ex["var_pr_plus100pb_pct"] == pytest.approx(-17.2, abs=0.05) assert ex["semi_elasticite"] == pytest.approx(-20.8, abs=0.05) assert ex["prix_implicite_loyer_3ch_effectif_ipc"] == pytest.approx(368_336, abs=0.5) # identité : coût annuel / 12 = coût mensuel ; prix implicite × u = loyer annuel assert ex["cout_annuel_effectif_ipc"] / 12 == pytest.approx(ex["cout_mensuel_effectif_ipc"], rel=1e-12) assert ex["prix_implicite_loyer_3ch_effectif_ipc"] * ex["u_effectif_ipc"] == pytest.approx(1475 * 12, rel=1e-12) class TestIndicateurs: def test_mois_inventaire(self): assert cas.mois_inventaire(2_204, 2_161) == pytest.approx(3.06, abs=0.005) ind = cas.indicateurs_apciq() attendu = {"Province": 6.08, "RMR Montréal": 6.59, "RMR Québec": 3.06, "RMR Gatineau": 6.48, "RMR Sherbrooke": 4.35, "RMR Trois-Rivières": 3.60, "RMR Saguenay": 4.03} for k, v in attendu.items(): assert ind.loc[k, "mois_inventaire"] == pytest.approx(v, abs=0.005) assert ind.loc["RMR Montréal", "ventes_mensuelles"] == pytest.approx(3_088.33, abs=0.01)