#!/usr/bin/env python3 """Chapitre 28 — figures (5) : cascade RBP → RNE (cas 3) ; structure des dépenses en % du RBE (cas 3 vs cas 2) ; évolution 2023-2025 des groupes de dépenses (indice 2023 = 100) et montant normalisé 2026 ; ratio de dépenses vs loyer moyen (40 comparables du cas 3, courbe iso-dépenses du sujet) ; sensibilité du RNE (réserve par logement, taux de taxe, gestion). DONNÉES SYNTHÉTIQUES (graine 20261031). Idempotent ; écrit dans assets/figures/ch28/ch28-fig-.pdf. Usage (racine) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch28_figures.py""" from __future__ import annotations import sys, pathlib import numpy as np, pandas as pd import matplotlib.pyplot as plt from matplotlib.ticker import FuncFormatter sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) from traite.style import setup, palette, savefig, LARGEUR, DEMI, fmt_dollars sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) from ch28_depenses import calculer, ratio_iso_depenses, GROUPES, ORDRE_GROUPES pct0 = FuncFormatter(lambda x, p: f"{x*100:.0f} %") pct1 = FuncFormatter(lambda x, p: f"{x*100:.1f}".replace(".", ",") + " %") kdol = FuncFormatter(lambda x, p: f"{x/1000:,.0f}".replace(",", " ") + " k$") NOTE = "DONNÉES SYNTHÉTIQUES" def fig_cascade(res): b = res["base"]; cl = res["classement"] etapes = [("RBP\n(baux)", b["rbp_contrat"], "total"), ("Inocc.\net pertes", -b["vacance_pertes"], "neg"), ("Autres\nrevenus", b["autres_revenus"], "pos"), ("RBE", b["rbe"], "total"), ("Dépenses\nfixes", -cl["fixes"], "neg"), ("Dépenses\nvariables", -cl["variables_hors_gestion"], "neg"), ("Gestion\nimputée", -cl["gestion"], "neg"), ("Réserve", -cl["reserve"], "neg"), ("RNE", b["rne"], "total")] fig, ax = plt.subplots(figsize=(LARGEUR, 3.3)) cum = 0.0 for i, (nom, v, k) in enumerate(etapes): if k == "total": ax.bar(i, v, color=palette.cat[0], width=0.7); cum = v; top = v else: bas = cum + v if v < 0 else cum ax.bar(i, abs(v), bottom=bas, color=palette.cat[3] if v < 0 else palette.cat[2], width=0.7); cum += v; top = max(bas + abs(v), bas) ax.text(i, top + 2500, fmt_dollars(abs(v)) if k != "total" else fmt_dollars(v), ha="center", va="bottom", fontsize=7.2, color=palette.ink) if i < len(etapes) - 1: ax.plot([i + 0.35, i + 0.65], [cum, cum], color=palette.ink2, lw=0.6, ls=":") ax.set_xticks(range(len(etapes))); ax.set_xticklabels([e[0] for e in etapes], fontsize=7.4) ax.yaxis.set_major_formatter(kdol); ax.set_ylabel("Dollars par année (première année)"); ax.set_ylim(0, 195000); ax.grid(axis="x", visible=False) ax.set_title("Cas 3 : du revenu brut potentiel au revenu net d'exploitation normalisé", loc="left") ax.text(0.99, -0.28, NOTE, transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2) return savefig(fig, "ch28/ch28-fig-cascade.pdf") def fig_structure(res): g3 = res["classement"]["groupes_pct_rbe"]; g2 = res["cas2"]["classement"]["groupes_pct_rbe"] fig, ax = plt.subplots(figsize=(LARGEUR, 3.4)) y = np.arange(len(ORDRE_GROUPES)); h = 0.38 ax.barh(y + h / 2, g3.values, height=h, color=palette.cat[0], label="Cas 3 — 12 logements, Montréal (ratio total 46,6 %)") ax.barh(y - h / 2, g2.values, height=h, color=palette.cat[1], label="Cas 2 — triplex, Québec (ratio total 45,7 %)") for yy, v in zip(y + h / 2, g3.values): ax.text(v + 0.003, yy, f"{v*100:.1f} %".replace(".", ","), va="center", fontsize=7.2) for yy, v in zip(y - h / 2, g2.values): ax.text(v + 0.003, yy, f"{v*100:.1f} %".replace(".", ","), va="center", fontsize=7.2) ax.set_yticks(y); ax.set_yticklabels(ORDRE_GROUPES); ax.invert_yaxis(); ax.xaxis.set_major_formatter(pct0); ax.set_xlim(0, 0.29) ax.set_xlabel("Dépense normalisée en pourcentage du revenu brut effectif"); ax.grid(axis="y", visible=False) ax.legend(loc="lower right", fontsize=7.5) ax.set_title("Structure des dépenses normalisées, par groupe de postes", loc="left") ax.text(0.99, -0.22, NOTE, transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2) return savefig(fig, "ch28/ch28-fig-structure.pdf") def fig_evolution(res): dep = res["dep"].copy() REG = {"Taxes foncières": "Taxes foncières", "Assurances": "Assurances", "Énergie des aires communes": "Énergie des aires communes", "Entretien et réparations": "Autres postes variables", "Conciergerie et services": "Autres postes variables", "Honoraires et publicité": "Autres postes variables", "Réserve de remplacement": "Réserve de remplacement"} ORDRE = ["Taxes foncières", "Assurances", "Énergie des aires communes", "Autres postes variables", "Réserve de remplacement"] dep["groupe"] = dep["poste"].map(GROUPES).map(REG) g = dep.groupby(["groupe", "annee"])["montant"].sum().unstack("annee").reindex(ORDRE) det = res["detail"].assign(groupe=res["detail"]["poste"].map(GROUPES).map(lambda x: REG.get(x, "Gestion imputée"))).groupby("groupe")["normalise"].sum() fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 3.4), gridspec_kw={"width_ratios": [1.1, 1]}) ax = axes[0] for i, (grp, r) in enumerate(g.iterrows()): ax.plot([2023, 2024, 2025], (r / r[2023] * 100).values, marker="o", color=palette.cat[i], lw=1.4, ms=3.5, label=grp) tot = res["historique"].loc["Total", [2023, 2024, 2025]] ax.plot([2023, 2024, 2025], (tot / tot[2023] * 100).values, color=palette.ink, lw=2.2, marker="s", ms=4, label="Total déclaré") ax.set_xticks([2023, 2024, 2025]); ax.set_xlim(2022.8, 2025.2); ax.set_ylabel("Indice (2023 = 100)") ax.set_title("Historique déclaré : indice par groupe", loc="left", fontsize=8.5) ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.17), ncol=2, fontsize=6.8, handlelength=1.6, columnspacing=1.0) ax = axes[1] ordre_d = ORDRE + ["Gestion imputée"] h25 = pd.Series({k: g.loc[k, 2025] if k in g.index else 0.0 for k in ordre_d}); hn = det.reindex(ordre_d).fillna(0.0) y = np.arange(len(ordre_d)); w = 0.38 ax.barh(y + w / 2, h25.values, height=w, color=palette.cat[4], label="Déclaré 2025") ax.barh(y - w / 2, hn.values, height=w, color=palette.cat[0], label="Normalisé (première année)") for yy, v in zip(y - w / 2, hn.values): ax.text(v + 400, yy, fmt_dollars(v), va="center", fontsize=6.5) ax.set_yticks(y); ax.set_yticklabels(ordre_d, fontsize=7); ax.invert_yaxis(); ax.xaxis.set_major_formatter(kdol); ax.grid(axis="y", visible=False); ax.set_xlim(0, 48000) ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.17), ncol=1, fontsize=6.8); ax.set_title("2025 déclaré et montant normalisé", loc="left", fontsize=8.5) fig.text(0.99, -0.02, NOTE, ha="right", va="bottom", fontsize=7, color=palette.ink2) fig.tight_layout(w_pad=1.5) return savefig(fig, "ch28/ch28-fig-evolution.pdf") def fig_ratio_loyer(res): c = res["comps"]; b = res["base"]; hyp = res["hyp"]; m = res["marche"] fig, ax = plt.subplots(figsize=(LARGEUR, 3.3)) ax.scatter(c["loyer_mensuel_moyen"], c["ratio_depenses"], s=26 + 1.6 * c["nb_logements"], color=palette.cat[0], alpha=0.75, edgecolor=palette.surface, lw=0.6, label="Ventes comparables (taille du point = nombre de logements)") L = np.linspace(950, 1650, 200); ax.plot(L, ratio_iso_depenses(L, b, hyp), color=palette.cat[3], lw=1.6, label="Sujet : ratio si le loyer moyen était L (dépenses inchangées)") ax.scatter([m["loyer_moyen_contrat"]], [b["ratio_depenses"]], marker="D", s=70, color=palette.cat[3], edgecolor=palette.ink, lw=1.2, zorder=5) ax.scatter([m["loyer_moyen"]], [m["ratio"]], marker="D", s=70, facecolor="white", edgecolor=palette.cat[3], lw=1.4, zorder=5) ax.annotate("sujet, loyers contractuels\n1 150 $ ; 46,6 %", (m["loyer_moyen_contrat"], b["ratio_depenses"]), xytext=(985, 0.50), fontsize=7.3, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.7)) ax.annotate("sujet, loyers de marché\n1 292 $ ; 42,1 %", (m["loyer_moyen"], m["ratio"]), xytext=(1380, 0.47), fontsize=7.3, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.7)) med = res["comps_stats"]["ratio_median"]; ax.axhline(med, color=palette.ink2, lw=0.8, ls=":"); ax.text(1640, med + 0.004, f"médiane des comparables {med*100:.1f} %".replace(".", ","), ha="right", fontsize=7) ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:,.0f} $".replace(",", " "))); ax.yaxis.set_major_formatter(pct0) ax.set_xlabel("Loyer mensuel moyen par logement"); ax.set_ylabel("Dépenses (réserve comprise) / revenu brut effectif"); ax.set_xlim(950, 1650); ax.set_ylim(0.28, 0.56) ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.2), ncol=1, fontsize=7.5) ax.set_title("Ratio de dépenses et niveau des loyers : quarante ventes et le sujet du cas 3", loc="left") ax.text(0.99, -0.42, NOTE, transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2) return savefig(fig, "ch28/ch28-fig-ratio-loyer.pdf") def fig_sensibilite(res): s = res["sensibilites"]; noi0 = res["base"]["rne"] fig, axes = plt.subplots(1, 3, figsize=(LARGEUR, 3.0), sharey=True) # réserve ax = axes[0]; r = s["reserve"] ax.plot(r["reserve_par_logement"], r["rne"], color=palette.cat[0], marker="o", ms=3.5) ax.axvline(675.25, color=palette.ink2, lw=0.7, ls=":"); ax.set_xlabel("Réserve (\\$/logement/an)"); ax.set_title("Réserve de remplacement", loc="left", fontsize=8.5) ax.text(690, noi0 + 300, "675 $ (dossier)", fontsize=7) # taxes : taux continu ax = axes[1]; taux = np.linspace(0.0040, 0.0080, 50); role = res["hyp"]["valeur_role_2026"]; taxe0 = role * res["hyp"]["taux_taxe_fonciere"] ax.plot(taux * 100, noi0 - (role * taux - taxe0), color=palette.cat[1]) for _, row in s["taxe"].iterrows(): if row["taux_par_100"] <= 0.80: ax.scatter([row["taux_par_100"]], [row["rne"]], color=palette.cat[1], s=22, zorder=4) ax.axvline(0.4631, color=palette.ink2, lw=0.7, ls=":"); ax.set_xlabel("Taxe (\\$ par 100 \\$ du rôle)"); ax.set_title("Taux de taxe (rôle inchangé)", loc="left", fontsize=8.5) ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:.2f}".replace(".", ","))) ax.text(0.475, noi0 + 300, "Montréal 0,4631", fontsize=7); ax.text(0.585, noi0 - 4300, "Gatineau 6+ 0,6964", fontsize=6.8); ax.text(0.585, noi0 - 7600, "Québec 6+ 0,7697", fontsize=6.8) # gestion ax = axes[2]; g = s["gestion"] ax.plot(g["gestion_pct"], g["rne"], color=palette.cat[2], marker="o", ms=3.5) ax.axvline(0.04, color=palette.ink2, lw=0.7, ls=":"); ax.set_xlabel("Gestion (% du RBE)"); ax.set_title("Taux de gestion", loc="left", fontsize=8.5) ax.xaxis.set_major_formatter(pct0); ax.text(0.045, noi0 + 300, "4 % (dossier)", fontsize=7) for ax in axes: ax.axhline(noi0, color=palette.ink2, lw=0.7, ls=":"); ax.yaxis.set_major_formatter(kdol) axes[0].set_ylabel("RNE normalisé ($/an)") fig.text(0.99, -0.04, NOTE, ha="right", va="bottom", fontsize=7, color=palette.ink2) fig.tight_layout(w_pad=1.2) return savefig(fig, "ch28/ch28-fig-sensibilite.pdf") def main(): setup(); res = calculer() outs = [fig_cascade(res), fig_structure(res), fig_evolution(res), fig_ratio_loyer(res), fig_sensibilite(res)] for o in outs: print("écrit :", o) if __name__ == "__main__": main()