spb/traite-evaluation-qc
Public
1#!/usr/bin/env python32"""Chapitre 28 — Dépenses, réserves, CAPEX et RNE : calculs du cas 3 (12 logements, Rosemont) et du cas 2 (triplex, Limoilou).3DONNÉES SYNTHÉTIQUES (graine 20261031).45Tous les chiffres cités dans manuscript/chapters/ch28.tex et solutions/ch28.tex sortent d'ici. Les calculs partagés6(revenus, dépenses normalisées, RNE) viennent de traite.cas3 et traite.cas2, pour coïncider avec les chapitres 29, 30,732, 46 et 47 ; les formules financières viennent de traite.finance. Aucune formule partagée n'est réécrite ; les8calculs propres au chapitre (classement, normalisation alternative, équivalence réserve/CAPEX, sensibilités) sont ici.910Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch28_depenses.py11"""12from __future__ import annotations13import sys, pathlib14import numpy as np, pandas as pd15sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))16from traite import cas3, cas2, finance as fin1718ROOT = pathlib.Path(__file__).resolve().parents[3]1920# Taux global médian extrait au chapitre 29 (traite.cas3.taux_extraits ; revenu net APRÈS réserve). Repris ici pour les21# démonstrations de cohérence ; recalculé par calculer() et vérifié par le test.22GROUPES = { # regroupement des postes pour les figures et les ratios de référence23 "Taxes municipales": "Taxes foncières", "Taxe scolaire": "Taxes foncières", "Assurances": "Assurances",24 "Électricité (aires communes)": "Énergie des aires communes", "Chauffage (aires communes, gaz)": "Énergie des aires communes",25 "Eau et égouts (taxe)": "Taxes foncières", "Entretien et réparations": "Entretien et réparations",26 "Conciergerie": "Conciergerie et services", "Déneigement et paysagement": "Conciergerie et services",27 "Déneigement et entretien extérieur": "Conciergerie et services",28 "Frais juridiques et comptables": "Honoraires et publicité", "Publicité et location": "Honoraires et publicité",29 "Gestion (4 % du RBE)": "Gestion", "Gestion (propriétaire-occupant : imputée 4 %)": "Gestion",30 "Réserve de remplacement (par logement)": "Réserve de remplacement", "Réserve de remplacement": "Réserve de remplacement",31}32ORDRE_GROUPES = ["Taxes foncières", "Assurances", "Énergie des aires communes", "Entretien et réparations",33 "Conciergerie et services", "Honoraires et publicité", "Gestion", "Réserve de remplacement"]3435# Composantes à vie courte couvertes par la réserve — HYPOTHÈSES PÉDAGOGIQUES déclarées (coût unitaire par logement, durée36# de vie en années). Elles servent uniquement à illustrer les deux méthodes de calcul d'une réserve (linéaire et fonds37# d'amortissement) ; elles ne proviennent d'aucun barème publié.38COMPOSANTES_RESERVE = [39 ("Cuisinière et réfrigérateur", 2200.0, 12),40 ("Chauffe-eau", 1100.0, 10),41 ("Revêtements de sol", 3600.0, 20),42 ("Peinture et finition intérieure", 1500.0, 7),43]444546def _groupes(detail: pd.DataFrame, col: str = "normalise") -> pd.Series:47 g = detail.assign(groupe=detail["poste"].map(GROUPES)).groupby("groupe")[col].sum()48 return g.reindex(ORDRE_GROUPES).fillna(0.0)495051def historique_cas3(dep: pd.DataFrame) -> pd.DataFrame:52 """Pivot 2023-2025 par poste, avec variation totale 2025/2023 et taux de croissance annuel composé."""53 piv = dep.pivot(index="poste", columns="annee", values="montant").astype(float)54 ordre = ["Taxes municipales", "Taxe scolaire", "Assurances", "Électricité (aires communes)", "Chauffage (aires communes, gaz)",55 "Eau et égouts (taxe)", "Entretien et réparations", "Conciergerie", "Déneigement et paysagement",56 "Frais juridiques et comptables", "Publicité et location", "Réserve de remplacement (par logement)"]57 piv = piv.reindex(ordre)58 piv.loc["Total"] = piv.sum()59 with np.errstate(divide="ignore", invalid="ignore"):60 piv["var_2025_2023"] = piv[2025] / piv[2023] - 161 piv["tcac"] = (piv[2025] / piv[2023]) ** 0.5 - 162 return piv636465def normalisation_alternative(dep: pd.DataFrame, g: float = 0.03, annee_cible: int = 2026) -> pd.DataFrame:66 """Pour les postes indexés : (a) dernière année × (1+g) [règle du livre] ; (b) moyenne des trois exercices, chacun67 ramené en dollars de l'année cible au taux g (moyenne indexée). Permet de comparer les deux conventions."""68 piv = dep.pivot(index="poste", columns="annee", values="montant").astype(float)69 rows = []70 for poste, r in piv.iterrows():71 indexes = {an: r[an] * (1 + g) ** (annee_cible - an) for an in (2023, 2024, 2025)}72 rows.append({"poste": poste, "derniere_indexee": indexes[2025], "moyenne_indexee": float(np.mean(list(indexes.values()))),73 "min_indexe": min(indexes.values()), "max_indexe": max(indexes.values())})74 out = pd.DataFrame(rows).set_index("poste")75 out["ecart"] = out["moyenne_indexee"] - out["derniere_indexee"]76 return out777879def reserve_par_composantes(composantes=COMPOSANTES_RESERVE, nb_logements: int = 12, taux: float = 0.03) -> dict:80 """Réserve annuelle par la méthode linéaire (coût/durée) et par le fonds d'amortissement (coût × SFF au taux donné)."""81 rows = []82 for nom, cout, vie in composantes:83 lin = cout / vie84 sff = cout * fin.facteur_fonds_amortissement(taux, vie)85 rows.append({"composante": nom, "cout_par_logement": cout, "duree_vie": vie, "lineaire_par_logement": lin, "fonds_par_logement": sff,86 "lineaire_immeuble": lin * nb_logements, "fonds_immeuble": sff * nb_logements})87 df = pd.DataFrame(rows)88 return {"detail": df, "lineaire_par_logement": float(df["lineaire_par_logement"].sum()), "fonds_par_logement": float(df["fonds_par_logement"].sum()),89 "lineaire_immeuble": float(df["lineaire_immeuble"].sum()), "fonds_immeuble": float(df["fonds_immeuble"].sum()), "taux": taux}909192def demonstration_reserve(noi_apres: float, reserve: float, R_apres: float) -> dict:93 """Cohérence réserve/taux : V cohérent ; taux « avant réserve » équivalent R' = R + RR/V ; valeur erronée si R' est94 appliqué au revenu après réserve ; écart exact ΔV = RR/R' ; erreur relative −RR/(RNE+RR)."""95 V = fin.capitalisation_directe(noi_apres, R_apres)96 R_avant = fin.taux_global_extrait(noi_apres + reserve, V)97 V_err = fin.capitalisation_directe(noi_apres, R_avant)98 V_err_inverse = fin.capitalisation_directe(noi_apres + reserve, R_apres) # taux après réserve sur revenu avant réserve99 return {"V": V, "R_apres": R_apres, "R_avant": R_avant, "ecart_taux_pb": (R_avant - R_apres) * 1e4, "V_err": V_err,100 "delta_V": V - V_err, "delta_V_formule": reserve / R_avant, "erreur_relative": V_err / V - 1,101 "erreur_relative_formule": -reserve / (noi_apres + reserve), "approx_reserve_sur_R": reserve / R_apres,102 "V_err_inverse": V_err_inverse, "erreur_relative_inverse": V_err_inverse / V - 1}103104105def equivalence_capex(hyp: dict, r: float | None = None, T: int | None = None) -> dict:106 """Les CAPEX programmés du dossier (flux actualisés) convertis en charge annuelle équivalente sur l'horizon : montre107 ce qu'une réserve « pour travaux lourds » représenterait si on la comptait en plus des CAPEX (double comptage)."""108 r = hyp["taux_actualisation"] if r is None else r; T = hyp["horizon_ans"] if T is None else T109 an0 = int(hyp["date_evaluation"][:4])110 flux = [0.0] * (T + 1)111 for an, m in hyp["capex"].items():112 t = int(an) - an0 + 1113 if 1 <= t <= T: flux[t] = float(m)114 va = fin.van(flux, r) # flux[0] = 0 à t = 0115 annuite = va * fin.facteur_recouvrement(r, T)116 total = float(sum(hyp["capex"].values()))117 return {"capex": {int(k): float(v) for k, v in hyp["capex"].items()}, "total": total, "va": va, "annuite_equivalente": annuite,118 "moyenne_simple": total / T, "r": r, "T": T}119120121def classement(detail: pd.DataFrame, rbe: float, nb_log: int, surface_m2: float) -> dict:122 d = detail.copy()123 fixes = float(d.loc[d["type"] == "fixe", "normalise"].sum()); variables = float(d.loc[d["type"] == "variable", "normalise"].sum())124 reserve = float(d.loc[d["type"] == "réserve", "normalise"].sum()); total = fixes + variables + reserve125 gestion = float(d.loc[d["poste"].str.startswith("Gestion"), "normalise"].sum())126 return {"fixes": fixes, "variables": variables, "variables_hors_gestion": variables - gestion, "gestion": gestion, "reserve": reserve, "total": total,127 "part_fixes": fixes / total, "part_variables": variables / total, "part_reserve": reserve / total,128 "fixes_pct_rbe": fixes / rbe, "variables_pct_rbe": variables / rbe, "reserve_pct_rbe": reserve / rbe, "total_pct_rbe": total / rbe,129 "total_par_logement": total / nb_log, "total_par_m2": total / surface_m2, "groupes": _groupes(d),130 "groupes_pct_rbe": _groupes(d) / rbe, "groupes_par_logement": _groupes(d) / nb_log}131132133def rbe_fonction_loyer(loyer_moyen: float, nb_log: int, v: float, autres: float) -> float:134 return fin.revenu_brut_effectif(loyer_moyen * nb_log * 12, v, autres)135136137def ratio_iso_depenses(loyer_moyen, base: dict, hyp: dict, nb_log: int = 12, gestion: float = 0.04):138 """Ratio de dépenses du sujet si son loyer moyen était L (dépenses hors gestion et réserve inchangées, gestion en % du RBE)."""139 L = np.asarray(loyer_moyen, dtype=float)140 rbe = rbe_fonction_loyer(L, nb_log, hyp["taux_inoccupation_pertes"], hyp["autres_revenus_annuels"])141 de_hors_gestion = base["depenses_hors_reserve"] - gestion * base["rbe"]142 return (de_hors_gestion + gestion * rbe + base["reserve"]) / rbe143144145def sensibilites(base: dict, hyp: dict, R: float, nb_log: int = 12) -> dict:146 """Sensibilité du RNE (et de V = RNE/R à taux inchangé) à la réserve par logement, au taux de taxe et au taux de gestion."""147 rbe = base["rbe"]; de_hors_res = base["depenses_hors_reserve"]; res0 = base["reserve"]; noi0 = base["rne"]; V0 = noi0 / R148 role = hyp["valeur_role_2026"]; taxe0 = role * hyp["taux_taxe_fonciere"]; gestion0 = 0.04 * rbe149 # réserve par logement150 rows_r = []151 for rpl in [0, 100, 300, 500, 675.25, 900, 1200]:152 res = rpl * nb_log; noi = fin.rne(rbe, de_hors_res, res)153 rows_r.append({"reserve_par_logement": rpl, "reserve": res, "rne": noi, "V": noi / R, "dV_pct": noi / noi0 - 1, "ratio": (de_hors_res + res) / rbe})154 # taux de taxe municipale ($/100 $), rôle inchangé155 rows_t = []156 scen = [("Montréal, taxe générale 2026 (base)", hyp["taux_taxe_fonciere"]),157 ("Compte 2025 indexé de 5,3 % (Rosemont)", 10631 * 1.053 / role),158 ("Gatineau, six logements ou plus (6,964 $/1 000 $)", 0.006964),159 ("Québec, six logements et plus (0,7697 $/100 $)", 0.007697),160 ("Taux + 10 points de base", hyp["taux_taxe_fonciere"] + 0.001)]161 for nom, tau in scen:162 taxe = role * tau; noi = fin.rne(rbe, de_hors_res - taxe0 + taxe, res0)163 rows_t.append({"scenario": nom, "taux_par_100": tau * 100, "taxe": taxe, "delta_taxe": taxe - taxe0, "rne": noi, "V": noi / R, "dV_pct": noi / noi0 - 1})164 # taux de gestion165 rows_g = []166 for m in [0.0, 0.03, 0.04, 0.05, 0.06, 0.10]:167 noi = fin.rne(rbe, de_hors_res - gestion0 + m * rbe, res0)168 rows_g.append({"gestion_pct": m, "gestion": m * rbe, "rne": noi, "V": noi / R, "dV_pct": noi / noi0 - 1})169 # dérivée : un dollar de dépense = 1/R dollars de valeur170 return {"reserve": pd.DataFrame(rows_r), "taxe": pd.DataFrame(rows_t), "gestion": pd.DataFrame(rows_g), "dV_dDE": -1.0 / R, "V0": V0,171 "elasticite_rne_depenses": -(de_hors_res + res0) / noi0}172173174def cas2_detail() -> dict:175 d = cas2.charger(); b = cas2.rne(d["loyers"], d["depenses"]); s = d["sujet"]176 det = b["detail"].copy(); det["type_livre"] = det["type"].replace({"imputée": "variable"})177 det_cl = pd.DataFrame({"poste": det["poste"], "normalise": det["normalise"], "type": det["type_livre"]})178 cl = classement(det_cl, b["rbe"], s["nb_logements"], s["superficie_habitable_m2"])179 # vérification des taxes de Québec 2026 : taux 1-5 logements + tarifs par logement ; taxe scolaire sur (rôle − 25 000 $)180 role = s["valeur_role_2025"]; n = s["nb_logements"]181 taxe_gen = role * 0.007464; tarifs = n * (386 + 195); taxe_mun_recalc = taxe_gen + tarifs182 scol_recalc = max(role - 25000, 0) * 0.0007899183 ind = cas2.indicateurs_marche(cas2.comparables_plex(d["ventes"], s))184 return {**d, "base": b, "indicateurs": ind, "classement": cl, "detail": det, "rne_par_logement": b["rne"] / n, "rne_par_m2": b["rne"] / s["superficie_habitable_m2"],185 "taxes_quebec_recalc": {"taxe_generale": taxe_gen, "tarifs": tarifs, "total": taxe_mun_recalc, "declaree": 5900.0, "ecart": 5900.0 - taxe_mun_recalc,186 "scolaire_recalc": scol_recalc, "scolaire_declaree": 470.0},187 "gestion_imputee": float(det.loc[det["type"] == "imputée", "normalise"].sum()), "reserve_par_logement": b["reserve"] / n}188189190def calculer() -> dict:191 d = cas3.charger(); etat, dep, hyp, sujet, comps = d["etat"], d["dep"], d["hyp"], d["sujet"], d["comps"]192 base = cas3.rne(etat, dep, hyp); tx = cas3.taux_extraits(comps); R = tx["mediane"]193 nb = sujet["nb_logements"]; surf = sujet["superficie_locative_m2"]194 hist = historique_cas3(dep); alt = normalisation_alternative(dep, hyp["croissance_depenses"])195 cl = classement(base["detail"], base["rbe"], nb, surf)196 det = base["detail"].copy(); det["regle"] = det["poste"].map(lambda p: "rôle × taux" if p.startswith("Tax") else ("maintenue" if p.startswith("Réserve") else ("% du RBE" if p.startswith("Gestion") else "× 1,03")))197 det["pct_rbe"] = det["normalise"] / base["rbe"]; det["par_logement"] = det["normalise"] / nb198 demo = demonstration_reserve(base["rne"], base["reserve"], R)199 res_comp = reserve_par_composantes(nb_logements=nb, taux=hyp["croissance_depenses"])200 eq_capex = equivalence_capex(hyp)201 sens = sensibilites(base, hyp, R, nb)202 # revenu net et ratio si les loyers étaient au marché (même convention que l'exercice 29.7)203 rbe_m = fin.revenu_brut_effectif(base["rbp_marche"], hyp["taux_inoccupation_pertes"], hyp["autres_revenus_annuels"])204 de_m = base["depenses_hors_reserve"] - 0.04 * base["rbe"] + 0.04 * rbe_m205 noi_m = fin.rne(rbe_m, de_m, base["reserve"])206 marche = {"rbe": rbe_m, "depenses_hors_reserve": de_m, "rne": noi_m, "ratio": (de_m + base["reserve"]) / rbe_m,207 "loyer_moyen": base["rbp_marche"] / 12 / nb, "loyer_moyen_contrat": base["rbp_contrat"] / 12 / nb}208 comps_stats = {"n": len(comps), "ratio_median": float(comps["ratio_depenses"].median()), "ratio_q1": float(comps["ratio_depenses"].quantile(0.25)),209 "ratio_q3": float(comps["ratio_depenses"].quantile(0.75)), "ratio_min": float(comps["ratio_depenses"].min()), "ratio_max": float(comps["ratio_depenses"].max()),210 "corr_ratio_loyer": float(comps[["ratio_depenses", "loyer_mensuel_moyen"]].corr().iloc[0, 1]),211 "loyer_median": float(comps["loyer_mensuel_moyen"].median())}212 # revenu net historique 2025 « tel que déclaré » (sans gestion, sans indexation) pour l'erreur du RNE historique213 dep_2025 = float(hist.loc["Total", 2025]); noi_hist = base["rbe"] - dep_2025214 return {**d, "base": base, "R": R, "taux": tx, "nb_logements": nb, "surface_m2": surf, "historique": hist, "alternative": alt, "classement": cl, "detail": det,215 "demo_reserve": demo, "reserve_composantes": res_comp, "equivalence_capex": eq_capex, "sensibilites": sens, "marche": marche, "comps_stats": comps_stats,216 "rne_par_logement": base["rne"] / nb, "rne_par_m2": base["rne"] / surf, "rne_pct_rbe": base["rne"] / base["rbe"],217 "depenses_2025_declarees": dep_2025, "rne_historique_2025": noi_hist, "ecart_normalisation": base["depenses_totales"] - dep_2025,218 "V_historique": noi_hist / R, "V": base["rne"] / R, "cas2": cas2_detail()}219220221def exercices(res: dict) -> dict:222 """Chiffres des solutions (solutions/ch28.tex)."""223 base, hyp, R = res["base"], res["hyp"], res["R"]224 ex = {}225 # 28.1 — classement et ratios d'un huit logements (données de l'énoncé)226 rbe1 = 8 * 1250 * 12 * (1 - 0.04) + 3600227 postes1 = {"Taxes municipales": 1450000 * 0.004631, "Taxe scolaire": 1450000 * 0.0007899, "Assurances": 7400.0, "Électricité des aires communes": 2100.0,228 "Entretien et réparations": 6800.0, "Déneigement et paysagement": 3900.0, "Gestion (5 % du RBE)": 0.05 * rbe1, "Réserve (600 $/log.)": 8 * 600.0}229 fixes1 = postes1["Taxes municipales"] + postes1["Taxe scolaire"] + postes1["Assurances"]; res1 = postes1["Réserve (600 $/log.)"]230 var1 = sum(postes1.values()) - fixes1 - res1231 ex["ex1"] = {"rbe": rbe1, "postes": postes1, "fixes": fixes1, "variables": var1, "reserve": res1, "total": fixes1 + var1 + res1,232 "rne": rbe1 - fixes1 - var1 - res1, "ratio": (fixes1 + var1 + res1) / rbe1, "rne_par_logement": (rbe1 - fixes1 - var1 - res1) / 8,233 "part_fixes": fixes1 / (fixes1 + var1 + res1)}234 # 28.2 — normalisation : dernière année indexée vs moyenne indexée pour trois postes du cas 3235 alt = res["alternative"]236 ex["ex2"] = {p: {"derniere": alt.loc[p, "derniere_indexee"], "moyenne": alt.loc[p, "moyenne_indexee"], "ecart": alt.loc[p, "ecart"]}237 for p in ["Assurances", "Entretien et réparations", "Conciergerie"]}238 ex["ex2"]["ecart_total"] = sum(v["ecart"] for k, v in ex["ex2"].items() if isinstance(v, dict))239 ex["ex2"]["dV"] = ex["ex2"]["ecart_total"] / R240 # 28.3 — taxes : rôle × taux vs compte indexé ; effet sur RNE et V241 role = hyp["valeur_role_2026"]; taxe_base = role * hyp["taux_taxe_fonciere"]; taxe_idx = 10631 * 1.053242 ex["ex3"] = {"taxe_base": taxe_base, "taxe_indexee": taxe_idx, "ecart": taxe_idx - taxe_base, "rne_alt": base["rne"] - (taxe_idx - taxe_base),243 "V_base": base["rne"] / R, "V_alt": (base["rne"] - (taxe_idx - taxe_base)) / R, "dV": -(taxe_idx - taxe_base) / R,244 "scolaire_sans_exemption": role * hyp["taux_taxe_scolaire"], "scolaire_avec_exemption": (role - 25000) * hyp["taux_taxe_scolaire"],245 "ecart_exemption": 25000 * hyp["taux_taxe_scolaire"]}246 # 28.4 — réserve : linéaire vs fonds d'amortissement à 3 % et à 5 %247 rc3 = reserve_par_composantes(taux=0.03); rc5 = reserve_par_composantes(taux=0.05)248 ex["ex4"] = {"lineaire": rc3["lineaire_par_logement"], "fonds_3": rc3["fonds_par_logement"], "fonds_5": rc5["fonds_par_logement"],249 "lineaire_immeuble": rc3["lineaire_immeuble"], "fonds_3_immeuble": rc3["fonds_immeuble"], "convention": base["reserve"] / 12,250 "detail3": rc3["detail"]}251 # 28.5 — cohérence réserve/taux sur un comparable de 20 logements252 noi_c, rr_c, P_c = 182000.0, 20 * 675.0, 4100000.0253 R_c_apres = noi_c / P_c; R_c_avant = (noi_c + rr_c) / P_c254 ex["ex5"] = {"R_apres": R_c_apres, "R_avant": R_c_avant, "ecart_pb": (R_c_avant - R_c_apres) * 1e4,255 "V_coherent": base["rne"] / R_c_apres, "V_err": base["rne"] / R_c_avant, "erreur_rel": (base["rne"] / R_c_avant) / (base["rne"] / R_c_apres) - 1,256 "delta_V": base["rne"] / R_c_apres - base["rne"] / R_c_avant, "delta_formule_sujet": base["reserve"] / R_c_avant,257 "ratio_reserve_comparable": rr_c / (noi_c + rr_c), "ratio_reserve_sujet": base["reserve"] / (base["rne"] + base["reserve"]),258 "V_avant_sur_avant": (base["rne"] + base["reserve"]) / R_c_avant}259 # 28.6 — gestion imputée et propriétaire-occupant (cas 2)260 c2 = res["cas2"]; b2 = c2["base"]261 noi_sans_gestion = b2["rne"] + c2["gestion_imputee"]262 ex["ex6"] = {"gestion": c2["gestion_imputee"], "rne_avec": b2["rne"], "rne_sans": noi_sans_gestion, "ratio_avec": b2["ratio_depenses"],263 "ratio_sans": (b2["depenses_totales"] - c2["gestion_imputee"]) / b2["rbe"], "gestion_5": 0.05 * b2["rbe"], "rne_5": b2["rne"] - 0.01 * b2["rbe"],264 "taux_median_plex": c2["indicateurs"]["taux_median"], "V_avec": b2["rne"] / c2["indicateurs"]["taux_median"],265 "V_sans": noi_sans_gestion / c2["indicateurs"]["taux_median"], "surevaluation": noi_sans_gestion / b2["rne"] - 1}266 # 28.7 — CAPEX vs réserve : annuité équivalente et double comptage267 eq = res["equivalence_capex"]268 ex["ex7"] = {**eq, "rne_double": base["rne"] - eq["annuite_equivalente"], "V_double": (base["rne"] - eq["annuite_equivalente"]) / R,269 "erreur_rel_double": -eq["annuite_equivalente"] / base["rne"], "va_a_R": None}270 # valeur actuelle de la réserve perpétuelle vs CAPEX perpétuels : composante de 2 200 $/log. tous les 12 ans à 3 %271 C = 2200.0 * 12; N = 12; r = 0.03272 ex["ex7"]["va_capex_perpetuel"] = C / ((1 + r) ** N - 1); ex["ex7"]["reserve_equiv"] = C * fin.facteur_fonds_amortissement(r, N)273 ex["ex7"]["va_reserve_perpetuelle"] = ex["ex7"]["reserve_equiv"] / r274 return ex275276277def _f(x): return f"{x:,.2f}".replace(",", " ").replace(".", ",")278279280if __name__ == "__main__":281 pd.set_option("display.width", 220); pd.set_option("display.max_columns", 30)282 res = calculer(); b = res["base"]283 print("=== Cas 3 — historique 2023-2025 ($)"); print(res["historique"].round(3).to_string())284 print("\n=== Cas 3 — dépenses normalisées (première année)"); print(res["detail"].round(2).to_string())285 print(f"\nRBE {_f(b['rbe'])} | DE hors réserve {_f(b['depenses_hors_reserve'])} | réserve {_f(b['reserve'])} | total {_f(b['depenses_totales'])} | ratio {b['ratio_depenses']*100:.2f} %")286 print(f"RNE {_f(b['rne'])} | par logement {_f(res['rne_par_logement'])} | par m² {_f(res['rne_par_m2'])} | RNE/RBE {res['rne_pct_rbe']*100:.1f} %")287 cl = res["classement"]288 print(f"Fixes {_f(cl['fixes'])} ({cl['part_fixes']*100:.1f} %) | variables {_f(cl['variables'])} ({cl['part_variables']*100:.1f} %) | réserve {_f(cl['reserve'])} ({cl['part_reserve']*100:.1f} %)")289 print(f"Dépenses par logement {_f(cl['total_par_logement'])} | par m² {_f(cl['total_par_m2'])}")290 print("Groupes (% du RBE) :"); print((cl["groupes_pct_rbe"] * 100).round(2).to_string())291 print(f"\nDépenses déclarées 2025 {_f(res['depenses_2025_declarees'])} ; écart de normalisation {_f(res['ecart_normalisation'])} ; RNE historique {_f(res['rne_historique_2025'])} ; V hist {_f(res['V_historique'])} vs V {_f(res['V'])}")292 print("\n=== Normalisation alternative (moyenne indexée vs dernière indexée)"); print(res["alternative"].round(2).to_string())293 print("\n=== Démonstration réserve / taux"); print({k: round(v, 6) for k, v in res["demo_reserve"].items()})294 print("\n=== Réserve par composantes (hypothèses pédagogiques)"); print(res["reserve_composantes"]["detail"].round(2).to_string())295 print({k: round(v, 2) for k, v in res["reserve_composantes"].items() if k != "detail"})296 print("\n=== CAPEX programmés → annuité équivalente"); print({k: (round(v, 2) if isinstance(v, float) else v) for k, v in res["equivalence_capex"].items()})297 print("\n=== Loyers au marché"); print({k: round(v, 4) for k, v in res["marche"].items()})298 print("\n=== Comparables : ratios"); print({k: round(v, 4) for k, v in res["comps_stats"].items()})299 s = res["sensibilites"]300 print("\n=== Sensibilité réserve"); print(s["reserve"].round(4).to_string())301 print("\n=== Sensibilité taxes"); print(s["taxe"].round(4).to_string())302 print("\n=== Sensibilité gestion"); print(s["gestion"].round(4).to_string())303 print(f"dV/dDE = {s['dV_dDE']:.3f} ; élasticité RNE/dépenses = {s['elasticite_rne_depenses']:.3f}")304 c2 = res["cas2"]305 print("\n=== Cas 2"); print(c2["detail"].round(2).to_string())306 print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in c2["base"].items() if k != "detail"})307 print("classement :", {k: (round(v, 4) if isinstance(v, float) else None) for k, v in c2["classement"].items() if isinstance(v, float)})308 print("taxes Québec recalculées :", {k: round(v, 2) for k, v in c2["taxes_quebec_recalc"].items()})309 print(f"RNE/log {_f(c2['rne_par_logement'])} ; RNE/m² {_f(c2['rne_par_m2'])} ; réserve/log {_f(c2['reserve_par_logement'])}")310 print("\n=== Exercices")311 ex = exercices(res)312 for k, v in ex.items():313 print(k, {kk: (round(vv, 4) if isinstance(vv, float) else ("<df>" if isinstance(vv, pd.DataFrame) else vv)) for kk, vv in v.items()} if isinstance(v, dict) else v)314