#!/usr/bin/env python3 """Chapitre 27 — figures (4) : 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, 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. ch27-fig-cascade.pdf : cascade RBP au marché → RBP contractuel → loyers effectifs → RBE, cas 3 (SYNTHÉTIQUE). ch27-fig-trajectoires.pdf : trajectoires du RBP du cas 3 : TAL seul, rotation 15 % (base), rotation 9,4 % (SCHL), marché (SYNTHÉTIQUE). ch27-fig-sensibilite.pdf : (a) inoccupation frictionnelle v_f = ρ·d/12 ; (b) RBE et valeur du cas 3 selon v (SYNTHÉTIQUE). Idempotent ; écrit dans assets/figures/ch27/. Usage (racine) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch27_figures.py""" from __future__ import annotations import sys, pathlib import numpy as np import matplotlib.pyplot as plt from matplotlib.ticker import FuncFormatter sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) from traite.style import setup, palette, savefig, LARGEUR, fmt_milliers from traite import finance as fin import ch27_revenus as c pct1 = FuncFormatter(lambda v, p: f"{v:.1f}".replace(".", ",") + " %") pct0 = FuncFormatter(lambda v, p: f"{v:.0f} %") def _f1(v): return f"{v:.1f}".replace(".", ",") def fig_inoccupation_rmr(res): s = res["schl_rmr"]; t = res["schl_taille"] 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"} fig, (a, b) = plt.subplots(1, 2, figsize=(LARGEUR, 3.3), gridspec_kw={"width_ratios": [1.15, 1]}) x = np.arange(len(s)); w = 0.38 a.bar(x - w / 2, s["inocc_2024"], w, color=palette.cat[1], label="Octobre 2024") a.bar(x + w / 2, s["inocc_2025"], w, color=palette.cat[0], label="Octobre 2025") 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) 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) a.set_xticks(x); a.set_xticklabels([noms[i] for i in s.index], fontsize=7) a.set_ylabel("Taux d'inoccupation, tous les appartements (%)"); a.set_ylim(0, 4.6); a.yaxis.set_major_formatter(pct0) a.legend(loc="upper right", fontsize=7); a.set_title("(a) Six RMR du Québec", loc="left", fontsize=8.5) tailles = ["3 à 5", "6 à 19", "20 à 49", "50 à 99", "100 et +", "Tous"] ile = t[t["zone"] == "Île de Montréal"]["inocc_2025"].values; rmr = t[t["zone"] == "RMR de Montréal"]["inocc_2025"].values x2 = np.arange(len(tailles)) b.bar(x2 - w / 2, rmr, w, color=palette.cat[0], label="RMR de Montréal") b.bar(x2 + w / 2, ile, w, color=palette.cat[2], label="Île de Montréal") for xi, v in zip(x2 - w / 2, rmr): b.text(xi, v + 0.06, _f1(v), ha="center", va="bottom", fontsize=6.5) 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) b.set_xticks(x2); b.set_xticklabels(tailles, fontsize=7); b.set_xlabel("Logements dans l'immeuble") b.set_ylim(0, 6.6); b.yaxis.set_major_formatter(pct0); b.legend(loc="upper left", fontsize=7) b.set_title("(b) Selon la taille de l'immeuble, octobre 2025", loc="left", fontsize=8.5) fig.tight_layout(w_pad=1.5) return savefig(fig, "ch27/ch27-fig-inoccupation-rmr.pdf") def fig_cascade(res): r = res["cas3"] etapes = [("RBP au\nmarché", r["rbp_marche"], "total"), ("Déficit\nde loyer", -r["deficit_loyer"], "delta"), ("RBP\ncontractuel", r["rbp_contrat"], "total"), ("Inoccupation\net créances", -r["vacance_pertes"], "delta"), ("Loyers\neffectifs", r["loyers_effectifs"], "total"), ("Autres\nrevenus", r["autres_revenus"], "delta"), ("RBE", r["rbe"], "total")] fig, ax = plt.subplots(figsize=(LARGEUR, 3.5)) cum = 0.0 for i, (lab, val, kind) in enumerate(etapes): if kind == "total": 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) ax.text(i, val / 1000 + 2.5, fmt_milliers(val) + " $", ha="center", va="bottom", fontsize=7.5) cum = val else: bas = cum + min(val, 0.0) ax.bar(i, abs(val) / 1000, 0.62, bottom=bas / 1000, color=palette.cat[3] if val < 0 else palette.cat[4], zorder=3) 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)) + " $", ha="center", va="top" if val < 0 else "bottom", fontsize=7.5) cum += val if i < len(etapes) - 1: ax.plot([i + 0.31, i + 1 - 0.31], [cum / 1000, cum / 1000], color=palette.ink2, lw=0.6, ls=":", zorder=2) 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) ax.set_ylim(140, 195); ax.set_ylabel("Milliers de dollars par année") ax.yaxis.set_major_formatter(FuncFormatter(lambda v, p: fmt_milliers(v))) ax.set_title("Cas 3 : du revenu brut potentiel au revenu brut effectif (première année, 2026-2027)", loc="left") ax.text(0.99, -0.26, "DONNÉES SYNTHÉTIQUES", transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2) return savefig(fig, "ch27/ch27-fig-cascade.pdf") def fig_trajectoires(res): t = res["trajectoires"]["table"]; h = res["trajectoires"]["hyp"]; T = res["trajectoires"]["T"] t = t[t["annee"] <= T] fig, ax = plt.subplots(figsize=(LARGEUR, 3.9)) x = t["exercice"].values 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é") 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(".", ",")) 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(".", ",")) 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)") 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)") for k, col, dy in (("tal_seul", palette.cat[3], -6), ("marche", palette.cat[1], 4)): 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") ax.set_xticks(x); ax.set_xlabel("Exercice (1er septembre → 31 août)"); ax.set_ylabel("Revenu brut potentiel (milliers de $)") ax.yaxis.set_major_formatter(FuncFormatter(lambda v, p: fmt_milliers(v))) ax.set_xlim(x[0] - 0.3, x[-1] + 1.4); ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.17), ncol=2, fontsize=6.8) ax.set_title("Cas 3 : quatre trajectoires du revenu brut potentiel sur dix ans", loc="left") ax.text(0.99, -0.40, "DONNÉES SYNTHÉTIQUES", transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2) return savefig(fig, "ch27/ch27-fig-trajectoires.pdf") def fig_sensibilite(res): s = res["schl_rmr"]; base = res["cas3"] fig, (a, b) = plt.subplots(1, 2, figsize=(LARGEUR, 3.2)) d = np.linspace(0, 3, 61) rots = [(0.05, "ρ = 5 %"), (0.094, "ρ = 9,4 % (Montréal 2025)"), (0.13, "ρ = 13 % (Québec 2025)"), (0.162, "ρ = 16,2 % (Gatineau 2025)"), (0.20, "ρ = 20 %")] for i, (rho, lab) in enumerate(rots): a.plot(d, [fin.taux_vacance_frictionnelle(rho, x) * 100 for x in d], color=palette.cat[i], label=lab) a.axhline(s.loc["Montréal", "inocc_2025"], color=palette.ink2, lw=0.7, ls=":") a.text(0.05, s.loc["Montréal", "inocc_2025"] - 0.12, "SCHL, RMR de Montréal, oct. 2025 : 2,9 %", fontsize=6.3, color=palette.ink2, va="top") a.scatter([1.0], [base["v_frictionnel_dossier"] * 100], color=palette.ink, marker="*", s=70, zorder=4) 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, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6)) a.set_xlabel("Durée moyenne de vacance entre deux baux, d (mois)"); a.set_ylabel("Inoccupation frictionnelle v_f = ρ·d/12 (%)") a.set_ylim(0, 5.2); a.yaxis.set_major_formatter(pct1); a.legend(fontsize=6.5, loc="upper left") a.set_title("(a) Rotation, durée de vacance et inoccupation", loc="left", fontsize=8.5) 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}))) b.plot(sv["v"] * 100, sv["valeur"] / 1000, color=palette.cat[0]) 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) 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)): y = sv.loc[np.isclose(sv["v"], v0), "valeur"].iloc[0] / 1000 b.plot([v0 * 100, v0 * 100], [1850, y], color=palette.ink2, lw=0.6, ls=":") b.text(v0 * 100, yt, lab, fontsize=6.5, ha="center", va="bottom", color=palette.ink2) b.set_xlabel("Provision pour inoccupation et pertes, v (% du RBP)"); b.set_ylabel("Valeur par capitalisation directe (milliers de $)") b.xaxis.set_major_formatter(pct0); b.yaxis.set_major_formatter(FuncFormatter(lambda v, p: fmt_milliers(v))) b.set_ylim(1850, 2160) 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) b.set_title("(b) Cas 3 : valeur selon la provision v", loc="left", fontsize=8.5) fig.tight_layout(w_pad=1.6) fig.text(0.99, -0.02, "Panneau (b) : DONNÉES SYNTHÉTIQUES", ha="right", va="bottom", fontsize=7, color=palette.ink2) return savefig(fig, "ch27/ch27-fig-sensibilite.pdf") def main(): setup() res = c.calculer() for f in (fig_inoccupation_rmr, fig_cascade, fig_trajectoires, fig_sensibilite): print("OK", f(res)) if __name__ == "__main__": main()