spb/traite-evaluation-qc
Public
1#!/usr/bin/env python32"""Chapitre 28 — figures (5) : cascade RBP → RNE (cas 3) ; structure des dépenses en % du RBE (cas 3 vs cas 2) ;3évolution 2023-2025 des groupes de dépenses (indice 2023 = 100) et montant normalisé 2026 ; ratio de dépenses vs loyer4moyen (40 comparables du cas 3, courbe iso-dépenses du sujet) ; sensibilité du RNE (réserve par logement, taux de taxe,5gestion). DONNÉES SYNTHÉTIQUES (graine 20261031). Idempotent ; écrit dans assets/figures/ch28/ch28-fig-<slug>.pdf.6Usage (racine) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch28_figures.py"""7from __future__ import annotations8import sys, pathlib9import numpy as np, pandas as pd10import matplotlib.pyplot as plt11from matplotlib.ticker import FuncFormatter12sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))13from traite.style import setup, palette, savefig, LARGEUR, DEMI, fmt_dollars14sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))15from ch28_depenses import calculer, ratio_iso_depenses, GROUPES, ORDRE_GROUPES1617pct0 = FuncFormatter(lambda x, p: f"{x*100:.0f} %")18pct1 = FuncFormatter(lambda x, p: f"{x*100:.1f}".replace(".", ",") + " %")19kdol = FuncFormatter(lambda x, p: f"{x/1000:,.0f}".replace(",", " ") + " k$")20NOTE = "DONNÉES SYNTHÉTIQUES"212223def fig_cascade(res):24 b = res["base"]; cl = res["classement"]25 etapes = [("Revenu brut\npotentiel", b["rbp_contrat"], "total"), ("Inoccupation\net pertes", -b["vacance_pertes"], "neg"), ("Autres\nrevenus", b["autres_revenus"], "pos"),26 ("Revenu brut\neffectif", b["rbe"], "total"), ("Dépenses\nfixes", -cl["fixes"], "neg"), ("Dépenses\nvariables", -cl["variables_hors_gestion"], "neg"),27 ("Gestion\nimputée", -cl["gestion"], "neg"), ("Réserve de\nremplacement", -cl["reserve"], "neg"), ("Revenu net\nd'exploitation", b["rne"], "total")]28 fig, ax = plt.subplots(figsize=(LARGEUR, 3.3))29 cum = 0.030 for i, (nom, v, k) in enumerate(etapes):31 if k == "total":32 ax.bar(i, v, color=palette.cat[0], width=0.7); cum = v; top = v33 else:34 bas = cum + v if v < 0 else cum35 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)36 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)37 if i < len(etapes) - 1:38 ax.plot([i + 0.35, i + 0.65], [cum, cum], color=palette.ink2, lw=0.6, ls=":")39 ax.set_xticks(range(len(etapes))); ax.set_xticklabels([e[0] for e in etapes], fontsize=7.2)40 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)41 ax.set_title("Cas 3 : du revenu brut potentiel au revenu net d'exploitation normalisé", loc="left")42 ax.text(0.99, -0.28, NOTE, transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2)43 return savefig(fig, "ch28/ch28-fig-cascade.pdf")444546def fig_structure(res):47 g3 = res["classement"]["groupes_pct_rbe"]; g2 = res["cas2"]["classement"]["groupes_pct_rbe"]48 fig, ax = plt.subplots(figsize=(LARGEUR, 3.4))49 y = np.arange(len(ORDRE_GROUPES)); h = 0.3850 ax.barh(y + h / 2, g3.values, height=h, color=palette.cat[0], label="Cas 3 — 12 logements, Montréal (ratio total 46,6 %)")51 ax.barh(y - h / 2, g2.values, height=h, color=palette.cat[1], label="Cas 2 — triplex, Québec (ratio total 45,7 %)")52 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)53 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)54 ax.set_yticks(y); ax.set_yticklabels(ORDRE_GROUPES); ax.invert_yaxis(); ax.xaxis.set_major_formatter(pct0); ax.set_xlim(0, 0.29)55 ax.set_xlabel("Dépense normalisée en pourcentage du revenu brut effectif"); ax.grid(axis="y", visible=False)56 ax.legend(loc="lower right", fontsize=7.5)57 ax.set_title("Structure des dépenses normalisées, par groupe de postes", loc="left")58 ax.text(0.99, -0.22, NOTE, transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2)59 return savefig(fig, "ch28/ch28-fig-structure.pdf")606162def fig_evolution(res):63 dep = res["dep"].copy(); dep["groupe"] = dep["poste"].map(GROUPES)64 g = dep.groupby(["groupe", "annee"])["montant"].sum().unstack("annee").reindex(ORDRE_GROUPES).dropna(how="all")65 g = g[g[2023] > 0]66 det = res["detail"].assign(groupe=res["detail"]["poste"].map(GROUPES)).groupby("groupe")["normalise"].sum()67 fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 3.2), gridspec_kw={"width_ratios": [1.15, 1]})68 ax = axes[0]69 for i, (grp, r) in enumerate(g.iterrows()):70 idx = r / r[2023] * 10071 ax.plot([2023, 2024, 2025], idx.values, marker="o", color=palette.cat[i % 6], lw=1.4, ms=3.5, ls="-" if i < 6 else "--")72 ax.text(2025.07, idx[2025], grp, fontsize=6.8, va="center", color=palette.ink)73 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)74 ax.text(2025.07, tot[2025] / tot[2023] * 100, "Total", fontsize=7.2, va="center", fontweight="bold")75 ax.set_xticks([2023, 2024, 2025]); ax.set_xlim(2022.8, 2026.1); ax.set_ylabel("Indice (2023 = 100)"); ax.set_title("Historique déclaré, indice par groupe", loc="left", fontsize=8.5)76 ax = axes[1]77 grp_order = [x for x in ORDRE_GROUPES if x in g.index or x in det.index]78 h25 = pd.Series({k: g.loc[k, 2025] if k in g.index else 0.0 for k in grp_order}); hn = det.reindex(grp_order).fillna(0.0)79 y = np.arange(len(grp_order)); w = 0.3880 ax.barh(y + w / 2, h25.values, height=w, color=palette.cat[4], label="Déclaré 2025")81 ax.barh(y - w / 2, hn.values, height=w, color=palette.cat[0], label="Normalisé (première année)")82 ax.set_yticks(y); ax.set_yticklabels(grp_order, fontsize=7); ax.invert_yaxis(); ax.xaxis.set_major_formatter(kdol); ax.grid(axis="y", visible=False)83 ax.legend(loc="lower right", fontsize=7); ax.set_title("2025 déclaré et montant normalisé", loc="left", fontsize=8.5)84 fig.text(0.99, -0.02, NOTE, ha="right", va="bottom", fontsize=7, color=palette.ink2)85 fig.tight_layout(w_pad=1.5)86 return savefig(fig, "ch28/ch28-fig-evolution.pdf")878889def fig_ratio_loyer(res):90 c = res["comps"]; b = res["base"]; hyp = res["hyp"]; m = res["marche"]91 fig, ax = plt.subplots(figsize=(LARGEUR, 3.3))92 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,93 label="Ventes comparables (taille du point = nombre de logements)")94 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)")95 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)96 ax.scatter([m["loyer_moyen"]], [m["ratio"]], marker="D", s=70, facecolor="white", edgecolor=palette.cat[3], lw=1.4, zorder=5)97 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))98 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))99 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)100 ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:,.0f} $".replace(",", " "))); ax.yaxis.set_major_formatter(pct0)101 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)102 ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.2), ncol=1, fontsize=7.5)103 ax.set_title("Ratio de dépenses et niveau des loyers : quarante ventes et le sujet du cas 3", loc="left")104 ax.text(0.99, -0.42, NOTE, transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2)105 return savefig(fig, "ch28/ch28-fig-ratio-loyer.pdf")106107108def fig_sensibilite(res):109 s = res["sensibilites"]; noi0 = res["base"]["rne"]110 fig, axes = plt.subplots(1, 3, figsize=(LARGEUR, 2.9), sharey=True)111 # réserve112 ax = axes[0]; r = s["reserve"]113 ax.plot(r["reserve_par_logement"], r["rne"], color=palette.cat[0], marker="o", ms=3.5)114 ax.axvline(675.25, color=palette.ink2, lw=0.7, ls=":"); ax.set_xlabel("Réserve ($ par logement par an)"); ax.set_title("Réserve de remplacement", loc="left", fontsize=8.5)115 ax.text(690, noi0 + 300, "675 $ (dossier)", fontsize=7)116 # taxes : taux continu117 ax = axes[1]; taux = np.linspace(0.0040, 0.0080, 50); role = res["hyp"]["valeur_role_2026"]; taxe0 = role * res["hyp"]["taux_taxe_fonciere"]118 ax.plot(taux * 100, noi0 - (role * taux - taxe0), color=palette.cat[1])119 for _, row in s["taxe"].iterrows():120 if row["taux_par_100"] <= 0.80: ax.scatter([row["taux_par_100"]], [row["rne"]], color=palette.cat[1], s=22, zorder=4)121 ax.axvline(0.4631, color=palette.ink2, lw=0.7, ls=":"); ax.set_xlabel("Taxe municipale ($ par 100 $ du rôle)"); ax.set_title("Taux de taxe (rôle inchangé)", loc="left", fontsize=8.5)122 ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:.2f}".replace(".", ",")))123 ax.text(0.48, noi0 + 300, "Montréal 0,4631", fontsize=7); ax.text(0.70, noi0 - 6200, "Gatineau 6+\n0,6964", fontsize=6.8); ax.text(0.715, noi0 - 9500, "Québec 6+\n0,7697", fontsize=6.8, ha="left")124 # gestion125 ax = axes[2]; g = s["gestion"]126 ax.plot(g["gestion_pct"], g["rne"], color=palette.cat[2], marker="o", ms=3.5)127 ax.axvline(0.04, color=palette.ink2, lw=0.7, ls=":"); ax.set_xlabel("Gestion imputée (% du RBE)"); ax.set_title("Taux de gestion", loc="left", fontsize=8.5)128 ax.xaxis.set_major_formatter(pct0); ax.text(0.045, noi0 + 300, "4 % (dossier)", fontsize=7)129 for ax in axes:130 ax.axhline(noi0, color=palette.ink2, lw=0.7, ls=":"); ax.yaxis.set_major_formatter(kdol)131 axes[0].set_ylabel("RNE normalisé ($/an)")132 fig.text(0.99, -0.04, NOTE, ha="right", va="bottom", fontsize=7, color=palette.ink2)133 fig.tight_layout(w_pad=1.2)134 return savefig(fig, "ch28/ch28-fig-sensibilite.pdf")135136137def main():138 setup(); res = calculer()139 outs = [fig_cascade(res), fig_structure(res), fig_evolution(res), fig_ratio_loyer(res), fig_sensibilite(res)]140 for o in outs: print("écrit :", o)141142143if __name__ == "__main__":144 main()145