spb/traite-evaluation-qc
Public
1#!/usr/bin/env python32"""Chapitre 27 — figures (4) :3 ch27-fig-inoccupation-rmr.pdf : (a) inoccupation SCHL par RMR, oct. 2024 et 2025 ; (b) inoccupation selon la taille de l'immeuble, RMR et île de Montréal,4 oct. 2025 — DONNÉES RÉELLES (SCHL, Enquête sur les logements locatifs, tableaux 1.1.1 et 1.3.1), citées avec attribution.5 ch27-fig-cascade.pdf : cascade RBP au marché → RBP contractuel → loyers effectifs → RBE, cas 3 (SYNTHÉTIQUE).6 ch27-fig-trajectoires.pdf : trajectoires du RBP du cas 3 : TAL seul, rotation 15 % (base), rotation 9,4 % (SCHL), marché (SYNTHÉTIQUE).7 ch27-fig-sensibilite.pdf : (a) inoccupation frictionnelle v_f = ρ·d/12 ; (b) RBE et valeur du cas 3 selon v (SYNTHÉTIQUE).8Idempotent ; écrit dans assets/figures/ch27/. Usage (racine) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch27_figures.py"""9from __future__ import annotations10import sys, pathlib11import numpy as np12import matplotlib.pyplot as plt13from matplotlib.ticker import FuncFormatter14sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))15sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))16from traite.style import setup, palette, savefig, LARGEUR, fmt_milliers17from traite import finance as fin18import ch27_revenus as c1920pct1 = FuncFormatter(lambda v, p: f"{v:.1f}".replace(".", ",") + " %")21pct0 = FuncFormatter(lambda v, p: f"{v:.0f} %")22def _f1(v): return f"{v:.1f}".replace(".", ",")232425def fig_inoccupation_rmr(res):26 s = res["schl_rmr"]; t = res["schl_taille"]27 noms = {"Montréal": "Montréal", "Québec": "Québec", "Gatineau (partie QC)": "Gatineau\n(partie QC)", "Sherbrooke": "Sher-\nbrooke", "Trois-Rivières": "Trois-\nRivières", "Saguenay": "Sague-\nnay"}28 fig, (a, b) = plt.subplots(1, 2, figsize=(LARGEUR, 3.3), gridspec_kw={"width_ratios": [1.15, 1]})29 x = np.arange(len(s)); w = 0.3830 a.bar(x - w / 2, s["inocc_2024"], w, color=palette.cat[1], label="Octobre 2024")31 a.bar(x + w / 2, s["inocc_2025"], w, color=palette.cat[0], label="Octobre 2025")32 for xi, v in zip(x + w / 2, s["inocc_2025"]): a.text(xi, v + 0.06, _f1(v), ha="center", va="bottom", fontsize=7)33 for xi, v in zip(x - w / 2, s["inocc_2024"]): a.text(xi, v + 0.06, _f1(v), ha="center", va="bottom", fontsize=6.5, color=palette.ink2)34 a.set_xticks(x); a.set_xticklabels([noms[i] for i in s.index], fontsize=7)35 a.set_ylabel("Taux d'inoccupation, tous les appartements (%)"); a.set_ylim(0, 4.6); a.yaxis.set_major_formatter(pct0)36 a.legend(loc="upper right", fontsize=7); a.set_title("(a) Six RMR du Québec", loc="left", fontsize=8.5)37 tailles = ["3 à 5", "6 à 19", "20 à 49", "50 à 99", "100 et +", "Tous"]38 ile = t[t["zone"] == "Île de Montréal"]["inocc_2025"].values; rmr = t[t["zone"] == "RMR de Montréal"]["inocc_2025"].values39 x2 = np.arange(len(tailles))40 b.bar(x2 - w / 2, rmr, w, color=palette.cat[0], label="RMR de Montréal")41 b.bar(x2 + w / 2, ile, w, color=palette.cat[2], label="Île de Montréal")42 for xi, v in zip(x2 - w / 2, rmr): b.text(xi, v + 0.06, _f1(v), ha="center", va="bottom", fontsize=6.5)43 for xi, v in zip(x2 + w / 2, ile): b.text(xi, v + 0.06, _f1(v), ha="center", va="bottom", fontsize=6.5, color=palette.ink2)44 b.set_xticks(x2); b.set_xticklabels(tailles, fontsize=7); b.set_xlabel("Logements dans l'immeuble")45 b.set_ylim(0, 6.6); b.yaxis.set_major_formatter(pct0); b.legend(loc="upper left", fontsize=7)46 b.set_title("(b) Selon la taille de l'immeuble, octobre 2025", loc="left", fontsize=8.5)47 fig.tight_layout(w_pad=1.5)48 return savefig(fig, "ch27/ch27-fig-inoccupation-rmr.pdf")495051def fig_cascade(res):52 r = res["cas3"]53 etapes = [("RBP aux loyers\nde marché", r["rbp_marche"], "total"), ("Déficit de loyer\n(baux en place)", -r["deficit_loyer"], "delta"),54 ("RBP\ncontractuel", r["rbp_contrat"], "total"), ("Inoccupation et\nmauvaises créances", -r["vacance_pertes"], "delta"),55 ("Loyers\neffectifs", r["loyers_effectifs"], "total"), ("Autres\nrevenus", r["autres_revenus"], "delta"), ("Revenu brut\neffectif (RBE)", r["rbe"], "total")]56 fig, ax = plt.subplots(figsize=(LARGEUR, 3.5))57 cum = 0.058 for i, (lab, val, kind) in enumerate(etapes):59 if kind == "total":60 ax.bar(i, val / 1000, 0.62, color=palette.cat[0] if i in (0, 2) else palette.brand2 if i == 6 else palette.cat[2], zorder=3)61 ax.text(i, val / 1000 + 2.5, fmt_milliers(val) + " $", ha="center", va="bottom", fontsize=7.5)62 cum = val63 else:64 bas = cum + min(val, 0.0)65 ax.bar(i, abs(val) / 1000, 0.62, bottom=bas / 1000, color=palette.cat[3] if val < 0 else palette.cat[4], zorder=3)66 ax.text(i, (cum + val) / 1000 - 2.5 if val < 0 else (cum + val) / 1000 + 2.5, ("−" if val < 0 else "+") + fmt_milliers(abs(val)) + " $",67 ha="center", va="top" if val < 0 else "bottom", fontsize=7.5)68 cum += val69 if i < len(etapes) - 1:70 ax.plot([i + 0.31, i + 1 - 0.31], [cum / 1000, cum / 1000], color=palette.ink2, lw=0.6, ls=":", zorder=2)71 ax.set_xticks(range(len(etapes))); ax.set_xticklabels([e[0] for e in etapes], fontsize=6.8); ax.set_xlim(-0.55, len(etapes) - 0.45)72 ax.set_ylim(140, 195); ax.set_ylabel("Milliers de dollars par année")73 ax.yaxis.set_major_formatter(FuncFormatter(lambda v, p: fmt_milliers(v)))74 ax.set_title("Cas 3 : du revenu brut potentiel au revenu brut effectif (première année, 2026-2027)", loc="left")75 ax.text(0.99, -0.26, "DONNÉES SYNTHÉTIQUES", transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2)76 return savefig(fig, "ch27/ch27-fig-cascade.pdf")777879def fig_trajectoires(res):80 t = res["trajectoires"]["table"]; h = res["trajectoires"]["hyp"]; T = res["trajectoires"]["T"]81 t = t[t["annee"] <= T]82 fig, ax = plt.subplots(figsize=(LARGEUR, 3.3))83 x = t["exercice"].values84 ax.fill_between(x, t["tal_seul"] / 1000, t["marche"] / 1000, color=palette.grid, alpha=0.8, label="Écart entre l'indexation seule et le marché")85 ax.plot(x, t["marche"] / 1000, color=palette.cat[1], marker="s", ms=3.2, label=f"RBP au loyer de marché (indice : +{h['croissance_loyers_marche']*100:.1f} %/an)".replace(".", ","))86 ax.plot(x, t["base_rot15"] / 1000, color=palette.cat[0], marker="o", ms=3.2, label=f"Baux en place : TAL {h['croissance_loyers_contrat']*100:.1f} % + rotation {h['rotation_annuelle']*100:.0f} % (base)".replace(".", ","))87 ax.plot(x, t["rot_schl_094"] / 1000, color=palette.cat[2], marker="^", ms=3.2, ls="--", label="Baux en place : TAL 3,1 % + rotation 9,4 % (SCHL, RMR 2025)")88 ax.plot(x, t["tal_seul"] / 1000, color=palette.cat[3], ls=":", lw=1.4, label="Baux en place : indexation TAL 3,1 % seule (rotation nulle)")89 for k, col, dy in (("tal_seul", palette.cat[3], -6), ("marche", palette.cat[1], 4)):90 ax.text(x[-1] + 0.15, t[k].iloc[-1] / 1000 + dy / 10, fmt_milliers(t[k].iloc[-1]) + " $", fontsize=7, color=palette.ink, va="center")91 ax.set_xticks(x); ax.set_xlabel("Exercice (1er septembre → 31 août)"); ax.set_ylabel("Revenu brut potentiel (milliers de $)")92 ax.yaxis.set_major_formatter(FuncFormatter(lambda v, p: fmt_milliers(v)))93 ax.set_xlim(x[0] - 0.3, x[-1] + 1.4); ax.legend(loc="lower right", fontsize=7)94 ax.set_title("Cas 3 : quatre trajectoires du revenu brut potentiel sur dix ans", loc="left")95 ax.text(0.99, -0.22, "DONNÉES SYNTHÉTIQUES", transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2)96 return savefig(fig, "ch27/ch27-fig-trajectoires.pdf")979899def fig_sensibilite(res):100 s = res["schl_rmr"]; base = res["cas3"]101 fig, (a, b) = plt.subplots(1, 2, figsize=(LARGEUR, 3.2))102 d = np.linspace(0, 3, 61)103 rots = [(0.05, "ρ = 5 %"), (0.094, "ρ = 9,4 % (Montréal 2025)"), (0.13, "ρ = 13 % (Québec 2025)"), (0.162, "ρ = 16,2 % (Gatineau 2025)"), (0.25, "ρ = 25 %")]104 for i, (rho, lab) in enumerate(rots):105 a.plot(d, [fin.taux_vacance_frictionnelle(rho, x) * 100 for x in d], color=palette.cat[i], label=lab)106 a.axhline(s.loc["Montréal", "inocc_2025"], color=palette.ink2, lw=0.7, ls=":")107 a.text(0.05, s.loc["Montréal", "inocc_2025"] + 0.08, "inoccupation SCHL, RMR de Montréal, oct. 2025 : 2,9 %", fontsize=6.8, color=palette.ink2)108 a.scatter([1.0], [base["v_frictionnel_dossier"] * 100], color=palette.ink, marker="*", s=70, zorder=4)109 a.annotate("dossier du cas 3 :\nρ = 15 %, d = 1 mois\n→ 1,25 %", (1.0, base["v_frictionnel_dossier"] * 100), xytext=(1.25, 0.35), fontsize=6.8,110 arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6))111 a.set_xlabel("Durée moyenne de vacance entre deux baux, d (mois)"); a.set_ylabel("Inoccupation frictionnelle v_f = ρ·d/12 (%)")112 a.set_ylim(0, 6.5); a.yaxis.set_major_formatter(pct1); a.legend(fontsize=6.5, loc="upper left")113 a.set_title("(a) Rotation, durée de vacance et inoccupation", loc="left", fontsize=8.5)114 sv = c.sensibilite_v(base, taux=tuple(sorted(set(np.round(np.arange(0.01, 0.0801, 0.0025), 4)) | {0.029, 0.039, 0.059})))115 b.plot(sv["v"] * 100, sv["valeur"] / 1000, color=palette.cat[0])116 b.scatter([base["taux_vacance_pertes"] * 100], [sv.loc[np.isclose(sv["v"], base["taux_vacance_pertes"]), "valeur"].iloc[0] / 1000], color=palette.ink, marker="*", s=70, zorder=4)117 for v0, lab, yt in ((0.029, "SCHL RMR\n2,9 %", 1856), (0.039, "dossier\n3,9 %", 1886), (0.059, "+2 points\n5,9 %", 1856)):118 y = sv.loc[np.isclose(sv["v"], v0), "valeur"].iloc[0] / 1000119 b.plot([v0 * 100, v0 * 100], [1850, y], color=palette.ink2, lw=0.6, ls=":")120 b.text(v0 * 100, yt, lab, fontsize=6.5, ha="center", va="bottom", color=palette.ink2)121 b.set_xlabel("Provision pour inoccupation et pertes, v (% du RBP)"); b.set_ylabel("Valeur par capitalisation directe (milliers de $)")122 b.xaxis.set_major_formatter(pct0); b.yaxis.set_major_formatter(FuncFormatter(lambda v, p: fmt_milliers(v)))123 b.set_ylim(1850, 2160)124 b.text(0.97, 0.95, f"pente : {fmt_milliers(-sv.attrs['dV_dv_pt'])} $ par point de v\n(R = {sv.attrs['R']*100:.3f} %)".replace(".", ","), transform=b.transAxes, ha="right", va="top", fontsize=7)125 b.set_title("(b) Cas 3 : valeur selon la provision v", loc="left", fontsize=8.5)126 fig.tight_layout(w_pad=1.6)127 fig.text(0.99, -0.02, "Panneau (b) : DONNÉES SYNTHÉTIQUES", ha="right", va="bottom", fontsize=7, color=palette.ink2)128 return savefig(fig, "ch27/ch27-fig-sensibilite.pdf")129130131def main():132 setup()133 res = c.calculer()134 for f in (fig_inoccupation_rmr, fig_cascade, fig_trajectoires, fig_sensibilite):135 print("OK", f(res))136137138if __name__ == "__main__":139 main()140