SPB Git
11.3 KB · 150 lines python
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 = [("RBP\n(baux)", b["rbp_contrat"], "total"), ("Inocc.\net pertes", -b["vacance_pertes"], "neg"), ("Autres\nrevenus", b["autres_revenus"], "pos"),26              ("RBE", 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", -cl["reserve"], "neg"), ("RNE", 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.4)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()64    REG = {"Taxes foncières": "Taxes foncières", "Assurances": "Assurances", "Énergie des aires communes": "Énergie des aires communes",65           "Entretien et réparations": "Autres postes variables", "Conciergerie et services": "Autres postes variables", "Honoraires et publicité": "Autres postes variables",66           "Réserve de remplacement": "Réserve de remplacement"}67    ORDRE = ["Taxes foncières", "Assurances", "Énergie des aires communes", "Autres postes variables", "Réserve de remplacement"]68    dep["groupe"] = dep["poste"].map(GROUPES).map(REG)69    g = dep.groupby(["groupe", "annee"])["montant"].sum().unstack("annee").reindex(ORDRE)70    det = res["detail"].assign(groupe=res["detail"]["poste"].map(GROUPES).map(lambda x: REG.get(x, "Gestion imputée"))).groupby("groupe")["normalise"].sum()71    fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 3.4), gridspec_kw={"width_ratios": [1.1, 1]})72    ax = axes[0]73    for i, (grp, r) in enumerate(g.iterrows()):74        ax.plot([2023, 2024, 2025], (r / r[2023] * 100).values, marker="o", color=palette.cat[i], lw=1.4, ms=3.5, label=grp)75    tot = res["historique"].loc["Total", [2023, 2024, 2025]]76    ax.plot([2023, 2024, 2025], (tot / tot[2023] * 100).values, color=palette.ink, lw=2.2, marker="s", ms=4, label="Total déclaré")77    ax.set_xticks([2023, 2024, 2025]); ax.set_xlim(2022.8, 2025.2); ax.set_ylabel("Indice (2023 = 100)")78    ax.set_title("Historique déclaré : indice par groupe", loc="left", fontsize=8.5)79    ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.17), ncol=2, fontsize=6.8, handlelength=1.6, columnspacing=1.0)80    ax = axes[1]81    ordre_d = ORDRE + ["Gestion imputée"]82    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)83    y = np.arange(len(ordre_d)); w = 0.3884    ax.barh(y + w / 2, h25.values, height=w, color=palette.cat[4], label="Déclaré 2025")85    ax.barh(y - w / 2, hn.values, height=w, color=palette.cat[0], label="Normalisé (première année)")86    for yy, v in zip(y - w / 2, hn.values): ax.text(v + 400, yy, fmt_dollars(v), va="center", fontsize=6.5)87    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)88    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)89    fig.text(0.99, -0.02, NOTE, ha="right", va="bottom", fontsize=7, color=palette.ink2)90    fig.tight_layout(w_pad=1.5)91    return savefig(fig, "ch28/ch28-fig-evolution.pdf")929394def fig_ratio_loyer(res):95    c = res["comps"]; b = res["base"]; hyp = res["hyp"]; m = res["marche"]96    fig, ax = plt.subplots(figsize=(LARGEUR, 3.3))97    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,98               label="Ventes comparables (taille du point = nombre de logements)")99    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)")100    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)101    ax.scatter([m["loyer_moyen"]], [m["ratio"]], marker="D", s=70, facecolor="white", edgecolor=palette.cat[3], lw=1.4, zorder=5)102    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))103    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))104    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)105    ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:,.0f} $".replace(",", " "))); ax.yaxis.set_major_formatter(pct0)106    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)107    ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.2), ncol=1, fontsize=7.5)108    ax.set_title("Ratio de dépenses et niveau des loyers : quarante ventes et le sujet du cas 3", loc="left")109    ax.text(0.99, -0.42, NOTE, transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2)110    return savefig(fig, "ch28/ch28-fig-ratio-loyer.pdf")111112113def fig_sensibilite(res):114    s = res["sensibilites"]; noi0 = res["base"]["rne"]115    fig, axes = plt.subplots(1, 3, figsize=(LARGEUR, 3.0), sharey=True)116    # réserve117    ax = axes[0]; r = s["reserve"]118    ax.plot(r["reserve_par_logement"], r["rne"], color=palette.cat[0], marker="o", ms=3.5)119    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)120    ax.text(690, noi0 + 300, "675 $ (dossier)", fontsize=7)121    # taxes : taux continu122    ax = axes[1]; taux = np.linspace(0.0040, 0.0080, 50); role = res["hyp"]["valeur_role_2026"]; taxe0 = role * res["hyp"]["taux_taxe_fonciere"]123    ax.plot(taux * 100, noi0 - (role * taux - taxe0), color=palette.cat[1])124    for _, row in s["taxe"].iterrows():125        if row["taux_par_100"] <= 0.80: ax.scatter([row["taux_par_100"]], [row["rne"]], color=palette.cat[1], s=22, zorder=4)126    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)127    ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:.2f}".replace(".", ",")))128    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)129    # gestion130    ax = axes[2]; g = s["gestion"]131    ax.plot(g["gestion_pct"], g["rne"], color=palette.cat[2], marker="o", ms=3.5)132    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)133    ax.xaxis.set_major_formatter(pct0); ax.text(0.045, noi0 + 300, "4 % (dossier)", fontsize=7)134    for ax in axes:135        ax.axhline(noi0, color=palette.ink2, lw=0.7, ls=":"); ax.yaxis.set_major_formatter(kdol)136    axes[0].set_ylabel("RNE normalisé ($/an)")137    fig.text(0.99, -0.04, NOTE, ha="right", va="bottom", fontsize=7, color=palette.ink2)138    fig.tight_layout(w_pad=1.2)139    return savefig(fig, "ch28/ch28-fig-sensibilite.pdf")140141142def main():143    setup(); res = calculer()144    outs = [fig_cascade(res), fig_structure(res), fig_evolution(res), fig_ratio_loyer(res), fig_sensibilite(res)]145    for o in outs: print("écrit :", o)146147148if __name__ == "__main__":149    main()150
Files · 87c44d2be896891b2c9c8e7fa2e8f7fe990e491b
Menu

Theme