Ch. 11 livré et validé (compilation isolée, tests, fusion des registres)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
48 changed files +2,252 −35
added
analysis/python/cases/ch28_depenses.py
+313 −0
@@ -0,0 +1,313 @@ | |||||
| 1 | +#!/usr/bin/env python3 | ||||
| 2 | +"""Chapitre 28 — Dépenses, réserves, CAPEX et RNE : calculs du cas 3 (12 logements, Rosemont) et du cas 2 (triplex, Limoilou). | ||||
| 3 | +DONNÉES SYNTHÉTIQUES (graine 20261031). | ||||
| 4 | + | ||||
| 5 | +Tous les chiffres cités dans manuscript/chapters/ch28.tex et solutions/ch28.tex sortent d'ici. Les calculs partagés | ||||
| 6 | +(revenus, dépenses normalisées, RNE) viennent de traite.cas3 et traite.cas2, pour coïncider avec les chapitres 29, 30, | ||||
| 7 | +32, 46 et 47 ; les formules financières viennent de traite.finance. Aucune formule partagée n'est réécrite ; les | ||||
| 8 | +calculs propres au chapitre (classement, normalisation alternative, équivalence réserve/CAPEX, sensibilités) sont ici. | ||||
| 9 | + | ||||
| 10 | +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch28_depenses.py | ||||
| 11 | +""" | ||||
| 12 | +from __future__ import annotations | ||||
| 13 | +import sys, pathlib | ||||
| 14 | +import numpy as np, pandas as pd | ||||
| 15 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | ||||
| 16 | +from traite import cas3, cas2, finance as fin | ||||
| 17 | + | ||||
| 18 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | ||||
| 19 | + | ||||
| 20 | +# Taux global médian extrait au chapitre 29 (traite.cas3.taux_extraits ; revenu net APRÈS réserve). Repris ici pour les | ||||
| 21 | +# démonstrations de cohérence ; recalculé par calculer() et vérifié par le test. | ||||
| 22 | +GROUPES = { # regroupement des postes pour les figures et les ratios de référence | ||||
| 23 | + "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 | +} | ||||
| 32 | +ORDRE_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"] | ||||
| 34 | + | ||||
| 35 | +# Composantes à vie courte couvertes par la réserve — HYPOTHÈSES PÉDAGOGIQUES déclarées (coût unitaire par logement, durée | ||||
| 36 | +# de vie en années). Elles servent uniquement à illustrer les deux méthodes de calcul d'une réserve (linéaire et fonds | ||||
| 37 | +# d'amortissement) ; elles ne proviennent d'aucun barème publié. | ||||
| 38 | +COMPOSANTES_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 | +] | ||||
| 44 | + | ||||
| 45 | + | ||||
| 46 | +def _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) | ||||
| 49 | + | ||||
| 50 | + | ||||
| 51 | +def 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] - 1 | ||||
| 61 | + piv["tcac"] = (piv[2025] / piv[2023]) ** 0.5 - 1 | ||||
| 62 | + return piv | ||||
| 63 | + | ||||
| 64 | + | ||||
| 65 | +def 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, chacun | ||||
| 67 | + 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 out | ||||
| 77 | + | ||||
| 78 | + | ||||
| 79 | +def 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 / vie | ||||
| 84 | + 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} | ||||
| 90 | + | ||||
| 91 | + | ||||
| 92 | +def 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' est | ||||
| 94 | + 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éserve | ||||
| 99 | + 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} | ||||
| 103 | + | ||||
| 104 | + | ||||
| 105 | +def 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 : montre | ||||
| 107 | + 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 T | ||||
| 109 | + 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 + 1 | ||||
| 113 | + if 1 <= t <= T: flux[t] = float(m) | ||||
| 114 | + va = fin.van(flux, r) # flux[0] = 0 à t = 0 | ||||
| 115 | + 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} | ||||
| 119 | + | ||||
| 120 | + | ||||
| 121 | +def 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 + reserve | ||||
| 125 | + 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} | ||||
| 131 | + | ||||
| 132 | + | ||||
| 133 | +def 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) | ||||
| 135 | + | ||||
| 136 | + | ||||
| 137 | +def 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"]) / rbe | ||||
| 143 | + | ||||
| 144 | + | ||||
| 145 | +def 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 / R | ||||
| 148 | + role = hyp["valeur_role_2026"]; taxe0 = role * hyp["taux_taxe_fonciere"]; gestion0 = 0.04 * rbe | ||||
| 149 | + # réserve par logement | ||||
| 150 | + 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 gestion | ||||
| 165 | + 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 valeur | ||||
| 170 | + 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} | ||||
| 172 | + | ||||
| 173 | + | ||||
| 174 | +def 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 + tarifs | ||||
| 182 | + scol_recalc = max(role - 25000, 0) * 0.0007899 | ||||
| 183 | + 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} | ||||
| 188 | + | ||||
| 189 | + | ||||
| 190 | +def 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"] / nb | ||||
| 198 | + 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_m | ||||
| 205 | + 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 historique | ||||
| 213 | + dep_2025 = float(hist.loc["Total", 2025]); noi_hist = base["rbe"] - dep_2025 | ||||
| 214 | + 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()} | ||||
| 219 | + | ||||
| 220 | + | ||||
| 221 | +def 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) + 3600 | ||||
| 227 | + 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 - res1 | ||||
| 231 | + 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 3 | ||||
| 235 | + 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"] / R | ||||
| 240 | + # 28.3 — taxes : rôle × taux vs compte indexé ; effet sur RNE et V | ||||
| 241 | + role = hyp["valeur_role_2026"]; taxe_base = role * hyp["taux_taxe_fonciere"]; taxe_idx = 10631 * 1.053 | ||||
| 242 | + 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 logements | ||||
| 252 | + noi_c, rr_c, P_c = 182000.0, 20 * 675.0, 4100000.0 | ||||
| 253 | + R_c_apres = noi_c / P_c; R_c_avant = (noi_c + rr_c) / P_c | ||||
| 254 | + 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 comptage | ||||
| 267 | + 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.03 | ||||
| 272 | + 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"] / r | ||||
| 274 | + return ex | ||||
| 275 | + | ||||
| 276 | + | ||||
| 277 | +def _f(x): return f"{x:,.2f}".replace(",", " ").replace(".", ",") | ||||
| 278 | + | ||||
| 279 | + | ||||
| 280 | +if __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) | ||||
added
analysis/python/cases/ch33_six_logements.py
+331 −0
@@ -0,0 +1,331 @@ | |||||
| 1 | +#!/usr/bin/env python3 | ||||
| 2 | +"""Chapitre 33 — Immeubles de six logements et plus : tous les chiffres cités dans manuscript/chapters/ch33.tex | ||||
| 3 | +et solutions/ch33.tex (DONNÉES SYNTHÉTIQUES des cas 3 et 4, plus des données RÉELLES de la SCHL lues dans data/raw). | ||||
| 4 | + | ||||
| 5 | +Contenu : | ||||
| 6 | + - schl_ile_montreal() : lecture des tableaux 1.3.1/1.3.2/1.3.4 (taille d'immeuble) et 1.2.1/1.2.2 (année de | ||||
| 7 | + construction) du classeur SCHL « rmr-montreal-2025-fr.xlsx » (Enquête sur les logements locatifs, octobre 2025), | ||||
| 8 | + bloc « Île de Montréal (zones 1-18) » — données réelles, citées avec attribution (conditions d'utilisation SCHL). | ||||
| 9 | + - sommaire_cas4() : portefeuille du cas 4 par RMR (traite.cas4.sommaire) + statistiques de taux et de taille. | ||||
| 10 | + - effet_taille() : effet de la taille sur le taux global dans le cas 3 (régression linéaire de traite.cas3) et dans | ||||
| 11 | + le cas 4 (régression semi-log de traite.cas4.effets_regionaux), et taux médians par classe de taille. | ||||
| 12 | + - unites_cas3() : analyse par logement, par m², par chambre et multiplicateurs pour le sujet du cas 3. | ||||
| 13 | + - p45() : évaluation sommaire par capitalisation d'un immeuble de 36 logements du cas 4 (P45, Gatineau) avec | ||||
| 14 | + plusieurs taux candidats (Montréal cas 3, Gatineau cas 4, régression cas 4) pour isoler l'effet de RMR et de taille. | ||||
| 15 | + - sensibilite_p45() : grilles RMR × état et courbe taille → taux prédit et valeur. | ||||
| 16 | + - exercices() : chiffres des solutions. | ||||
| 17 | + | ||||
| 18 | +Aucune formule financière n'est réécrite : traite.finance, traite.cas3, traite.cas4, traite.hedonique. | ||||
| 19 | +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch33_six_logements.py | ||||
| 20 | +""" | ||||
| 21 | +from __future__ import annotations | ||||
| 22 | +import sys, pathlib | ||||
| 23 | +import numpy as np, pandas as pd | ||||
| 24 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | ||||
| 25 | +from traite import cas3, cas4, finance as fin, hedonique as h | ||||
| 26 | + | ||||
| 27 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | ||||
| 28 | +SCHL_XLSX = ROOT / "data/raw/schl_rml_2025/rmr-montreal-2025-fr.xlsx" | ||||
| 29 | + | ||||
| 30 | +# ----------------------------------------------------------------------------- constantes citées (sources : notes-B, notes-E, ch. 47) | ||||
| 31 | +V_FINALE_CH47 = 2_060_000 # valeur marchande finale du cas 3 au ch. 47 (réconciliation 45/35/20) — vérifiée par test contre ch47_dossier | ||||
| 32 | +TAXE_QUEBEC_1A5 = 0.7464 / 100 # Ville de Québec 2026, résidentiel 1 à 5 logements, $ par 100 $ (R.V.Q. 3492) | ||||
| 33 | +TAXE_QUEBEC_6PLUS = 0.7697 / 100 # Ville de Québec 2026, résidentiel 6 logements et plus | ||||
| 34 | +TARIF_QUEBEC_EAU = 386.0 # $ par logement (aqueduc-égout) ; + 129 $ par chambre (non utilisé ici) | ||||
| 35 | +TARIF_QUEBEC_MATIERES = 195.0 # $ par logement (matières résiduelles) | ||||
| 36 | +IPCB_MTL_BASSE_HAUTEUR_T3_2026 = 0.092 # IPCB Montréal, immeuble < 5 étages, variation annuelle T3 2026 (StatCan 18-10-0289-01) | ||||
| 37 | +IPCB_MTL_TOUR_T3_2026 = 0.079 # idem, tour ≥ 5 étages | ||||
| 38 | +ID_36 = "P45" # immeuble de 36 logements retenu dans le cas 4 (Gatineau, 1954, état 2) | ||||
| 39 | +RMR_LISTE = ["Montréal", "Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"] | ||||
| 40 | + | ||||
| 41 | + | ||||
| 42 | +# ----------------------------------------------------------------------------- SCHL (données réelles) | ||||
| 43 | + | ||||
| 44 | +def _lire_bloc(ws, titre_bloc: str, n_groupes: int, largeur_groupe: int, labels_arret=("Tous les immeubles",)) -> pd.DataFrame: | ||||
| 45 | + """Lit les lignes du bloc `titre_bloc` d'une feuille SCHL. Chaque ligne = [libellé, g1..gk] ; chaque groupe de | ||||
| 46 | + `largeur_groupe` cellules = (oct. 2024, qualité, oct. 2025, qualité[, flèche]). Retourne, par ligne, les valeurs | ||||
| 47 | + d'octobre 2025 des groupes 2 chambres (3e) et tous (5e), avec leur lettre de qualité.""" | ||||
| 48 | + rows = list(ws.iter_rows(values_only=True)) | ||||
| 49 | + out, dans_bloc = [], False | ||||
| 50 | + for r in rows: | ||||
| 51 | + lab = r[0] | ||||
| 52 | + if isinstance(lab, str) and lab.startswith(titre_bloc): | ||||
| 53 | + dans_bloc = True; continue | ||||
| 54 | + if not dans_bloc or lab is None: | ||||
| 55 | + continue | ||||
| 56 | + def val(g, off): | ||||
| 57 | + v = r[1 + g * largeur_groupe + off] | ||||
| 58 | + if v is None or str(v).strip() in ("**", "-", ""): | ||||
| 59 | + return np.nan | ||||
| 60 | + return float(str(v).replace(" ", "").replace(" ", "").replace(",", ".")) | ||||
| 61 | + def qual(g, off): | ||||
| 62 | + q = r[1 + g * largeur_groupe + off] | ||||
| 63 | + return str(q).strip() if isinstance(q, str) else "" | ||||
| 64 | + out.append({"categorie": str(lab).strip(), "deux_ch_2025": val(2, 2), "deux_ch_qual": qual(2, 3), | ||||
| 65 | + "tous_2025": val(4, 2), "tous_qual": qual(4, 3), "tous_2024": val(4, 0)}) | ||||
| 66 | + if str(lab).strip() in labels_arret: | ||||
| 67 | + break | ||||
| 68 | + return pd.DataFrame(out) | ||||
| 69 | + | ||||
| 70 | + | ||||
| 71 | +def schl_ile_montreal() -> dict: | ||||
| 72 | + """Tableaux SCHL (octobre 2025), île de Montréal : inoccupation, loyer moyen et rotation par taille d'immeuble ; | ||||
| 73 | + inoccupation et loyer moyen par année de construction. Valeurs « tous les appartements » et « 2 chambres ».""" | ||||
| 74 | + import openpyxl | ||||
| 75 | + wb = openpyxl.load_workbook(SCHL_XLSX, read_only=True, data_only=True) | ||||
| 76 | + bloc = "Île de Montréal" | ||||
| 77 | + taille_inocc = _lire_bloc(wb["Tableau 1.3.1"], bloc, 5, 5) | ||||
| 78 | + taille_loyer = _lire_bloc(wb["Tableau 1.3.2"], bloc, 5, 4) | ||||
| 79 | + taille_rot = _lire_bloc(wb["Tableau 1.3.4"], bloc, 5, 5) | ||||
| 80 | + annee_inocc = _lire_bloc(wb["Tableau 1.2.1"], bloc, 5, 5) | ||||
| 81 | + annee_loyer = _lire_bloc(wb["Tableau 1.2.2"], bloc, 5, 4) | ||||
| 82 | + taille = taille_inocc[["categorie"]].copy() | ||||
| 83 | + taille["inoccupation_tous"] = taille_inocc["tous_2025"].values | ||||
| 84 | + taille["inoccupation_2024"] = taille_inocc["tous_2024"].values | ||||
| 85 | + taille["loyer_tous"] = taille_loyer.set_index("categorie").loc[taille["categorie"], "tous_2025"].values | ||||
| 86 | + taille["loyer_2ch"] = taille_loyer.set_index("categorie").loc[taille["categorie"], "deux_ch_2025"].values | ||||
| 87 | + taille["rotation_tous"] = taille_rot.set_index("categorie").loc[taille["categorie"], "tous_2025"].values | ||||
| 88 | + annee = annee_inocc[["categorie"]].copy() | ||||
| 89 | + annee["inoccupation_tous"] = annee_inocc["tous_2025"].values | ||||
| 90 | + annee["loyer_tous"] = annee_loyer.set_index("categorie").loc[annee["categorie"], "tous_2025"].values | ||||
| 91 | + annee["loyer_2ch"] = annee_loyer.set_index("categorie").loc[annee["categorie"], "deux_ch_2025"].values | ||||
| 92 | + annee = annee[annee["categorie"] != "Inconnue"].reset_index(drop=True) | ||||
| 93 | + return {"taille": taille, "annee": annee} | ||||
| 94 | + | ||||
| 95 | + | ||||
| 96 | +# ----------------------------------------------------------------------------- cas 4 : sommaire et effets | ||||
| 97 | + | ||||
| 98 | +def sommaire_cas4() -> dict: | ||||
| 99 | + d = cas4.charger(); pf = d["portefeuille"] | ||||
| 100 | + s = cas4.sommaire(pf) | ||||
| 101 | + g = s["par_rmr"].copy() | ||||
| 102 | + stats = pf.groupby("rmr").agg(taux_median=("taux_global", "median"), taux_min=("taux_global", "min"), taux_max=("taux_global", "max"), | ||||
| 103 | + logements_moyen=("nb_logements", "mean"), loyer_moyen=("loyer_mensuel_moyen", "mean"), | ||||
| 104 | + inocc_marche=("taux_inoccupation_marche", "first"), age_moyen=("age", "mean")) | ||||
| 105 | + g = g.join(stats) | ||||
| 106 | + g["rne_par_logement"] = g["rne"] / g["logements"] | ||||
| 107 | + g = g.loc[RMR_LISTE] | ||||
| 108 | + pf = pf.assign(valeur_par_logement=pf["valeur_2026"] / pf["nb_logements"], rne_par_logement=pf["rne_2026"] / pf["nb_logements"], | ||||
| 109 | + mrb=pf["valeur_2026"] / pf["revenu_brut_potentiel"], rpv=pf["dette"] / pf["valeur_2026"]) | ||||
| 110 | + return {"par_rmr": g, "total": s["total"], "portefeuille": pf, "series": d["series"]} | ||||
| 111 | + | ||||
| 112 | + | ||||
| 113 | +def effet_taille(pf: pd.DataFrame) -> dict: | ||||
| 114 | + """Effet de la taille sur le taux global : (i) cas 3, pente linéaire par logement (traite.cas3.taux_extraits) ; | ||||
| 115 | + (ii) cas 4, élasticité semi-log (traite.cas4.effets_regionaux) ; (iii) taux médians par classe de taille (cas 4).""" | ||||
| 116 | + t3 = cas3.tout() | ||||
| 117 | + reg3 = t3["taux"]["regression"] | ||||
| 118 | + eff4 = cas4.effets_regionaux(pf) | ||||
| 119 | + b_ln = float(eff4.loc["ln_logements", "coef"]); se_ln = float(eff4.loc["ln_logements", "se_hc1"]) | ||||
| 120 | + classes = pd.cut(pf["nb_logements"], bins=[5, 8, 16, 24, 48, 72], labels=["6–8", "12–16", "24", "36–48", "72"]) | ||||
| 121 | + par_classe = pf.groupby(classes, observed=True).agg(n=("id_immeuble", "count"), taux_median=("taux_global", "median"), | ||||
| 122 | + valeur_par_logement=("valeur_2026", lambda v: float(v.sum()) / float(pf.loc[v.index, "nb_logements"].sum()))) | ||||
| 123 | + # effet, en points de base, de passer de 12 à 36 logements, toutes choses égales par ailleurs, à un taux de 5,5 % (cas 4) et à la médiane du cas 3 | ||||
| 124 | + r_ref4 = float(np.exp(eff4.loc["const", "coef"])) # Montréal, état 3, 12 logements, 50 ans, sans risque | ||||
| 125 | + d_pb_cas4 = (np.exp(b_ln * np.log(36 / 12)) - 1) * r_ref4 * 1e4 | ||||
| 126 | + d_pb_cas3 = reg3["taille"] * (36 - 12) * 1e4 | ||||
| 127 | + return {"cas3_pente_pb_par_logement": reg3["taille"] * 1e4, "cas3_constante": reg3["constante"], "cas3_mediane": t3["taux"]["mediane"], | ||||
| 128 | + "cas3_n": t3["taux"]["n"], "cas3_comparables": t3["taux"]["comparables"], "cas3_valeur_cap": t3["valeur_capitalisation"], | ||||
| 129 | + "cas3_rne": t3["base"]["rne"], "cas3_base": t3["base"], "cas3_etat": t3["etat"], "cas3_sujet": t3["sujet"], "cas3_dcf": t3["dcf"], | ||||
| 130 | + "cas4_coef_ln": b_ln, "cas4_se_ln": se_ln, "cas4_effet_doublement_pct": float(np.exp(b_ln * np.log(2)) - 1), | ||||
| 131 | + "cas4_effets": eff4, "cas4_r_ref": r_ref4, "d_pb_12_36_cas4": float(d_pb_cas4), "d_pb_12_36_cas3": float(d_pb_cas3), "par_classe": par_classe} | ||||
| 132 | + | ||||
| 133 | + | ||||
| 134 | +# ----------------------------------------------------------------------------- unités d'analyse (cas 3) | ||||
| 135 | + | ||||
| 136 | +def unites_cas3(et: dict, valeur: float = V_FINALE_CH47) -> dict: | ||||
| 137 | + etat, sujet, base = et["cas3_etat"], et["cas3_sujet"], et["cas3_base"] | ||||
| 138 | + n = int(sujet["nb_logements"]); m2 = float(sujet["superficie_locative_m2"]) | ||||
| 139 | + chambres = int(etat["chambres"].sum()) | ||||
| 140 | + rbp = base["rbp_contrat"]; rne = base["rne"] | ||||
| 141 | + return {"valeur": valeur, "n": n, "m2": m2, "chambres": chambres, "par_logement": valeur / n, "par_m2": valeur / m2, "par_chambre": valeur / chambres, | ||||
| 142 | + "m2_par_logement": m2 / n, "chambres_par_logement": chambres / n, "mrb": fin.multiplicateur_revenu_brut(valeur, rbp), | ||||
| 143 | + "mrb_marche": fin.multiplicateur_revenu_brut(valeur, base["rbp_marche"]), "taux_implicite": fin.taux_global_extrait(rne, valeur), | ||||
| 144 | + "rne_par_logement": rne / n, "loyer_moyen": float(etat["loyer_mensuel_contrat"].mean()), "loyer_moyen_m2": float((etat["loyer_mensuel_contrat"].sum()) / m2), | ||||
| 145 | + "valeur_cap": et["cas3_valeur_cap"], "valeur_cap_par_logement": et["cas3_valeur_cap"] / n} | ||||
| 146 | + | ||||
| 147 | + | ||||
| 148 | +# ----------------------------------------------------------------------------- P45 : 36 logements à Gatineau | ||||
| 149 | + | ||||
| 150 | +def _X_ligne(rmr: str, etat: int, n: int, age: float, inond: int = 0, sol: int = 0) -> np.ndarray: | ||||
| 151 | + x = [1.0, etat - 3, np.log(n / 12), inond, sol, (age - 50) / 10] | ||||
| 152 | + x += [1.0 if rmr == r else 0.0 for r in ["Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"]] | ||||
| 153 | + return np.array(x) | ||||
| 154 | + | ||||
| 155 | + | ||||
| 156 | +def _regression_cas4(pf: pd.DataFrame) -> dict: | ||||
| 157 | + """Même spécification que traite.cas4.effets_regionaux, mais on conserve les résidus pour le facteur de Duan.""" | ||||
| 158 | + X = pd.DataFrame({"const": 1.0, "etat": pf["etat_1a5"] - 3, "ln_logements": np.log(pf["nb_logements"] / 12), "inondable": pf["zone_inondable"], | ||||
| 159 | + "sol": pf["contamination_sol"], "age": (pf["age"] - 50) / 10}) | ||||
| 160 | + for r in ["Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"]: | ||||
| 161 | + X[f"rmr_{r}"] = (pf["rmr"] == r).astype(float) | ||||
| 162 | + res = h.mco(X.values, np.log(pf["taux_global"].values)) | ||||
| 163 | + res["duan"] = float(np.mean(np.exp(res["residus"]))) | ||||
| 164 | + res["colonnes"] = list(X.columns) | ||||
| 165 | + return res | ||||
| 166 | + | ||||
| 167 | + | ||||
| 168 | +def taux_predit(reg: dict, rmr: str, etat: int, n: int, age: float, inond: int = 0, sol: int = 0, duan: bool = True) -> float: | ||||
| 169 | + x = _X_ligne(rmr, etat, n, age, inond, sol) | ||||
| 170 | + return float(np.exp(x @ reg["beta"]) * (reg["duan"] if duan else 1.0)) | ||||
| 171 | + | ||||
| 172 | + | ||||
| 173 | +def p45(som: dict, et: dict, amortissement_mois: int = 300) -> dict: | ||||
| 174 | + pf = som["portefeuille"]; row = pf.set_index("id_immeuble").loc[ID_36] | ||||
| 175 | + reg = _regression_cas4(pf) | ||||
| 176 | + rne = float(row["rne_2026"]); n = int(row["nb_logements"]); rmr = str(row["rmr"]); etat = int(row["etat_1a5"]); age = float(row["age"]) | ||||
| 177 | + g = som["par_rmr"] | ||||
| 178 | + taux = { | ||||
| 179 | + "montreal_cas3_mediane": et["cas3_mediane"], # erreur : taux d'un autre marché | ||||
| 180 | + "gatineau_pondere": float(g.loc[rmr, "taux_pondere"]), # RNE/valeur agrégés, Gatineau (cas 4) | ||||
| 181 | + "gatineau_median": float(g.loc[rmr, "taux_median"]), # médiane des 8 immeubles de Gatineau | ||||
| 182 | + "regression_sujet": taux_predit(reg, rmr, etat, n, age), # régression semi-log, caractéristiques de P45 | ||||
| 183 | + "regression_si_montreal": taux_predit(reg, "Montréal", etat, n, age), # même immeuble à Montréal (effet RMR) | ||||
| 184 | + "regression_si_12_logements": taux_predit(reg, rmr, etat, 12, age), # même immeuble à 12 logements (effet taille) | ||||
| 185 | + "regression_si_etat_3": taux_predit(reg, rmr, 3, n, age), # même immeuble en état moyen | ||||
| 186 | + "fichier": float(row["taux_global"]), # taux inscrit au dossier synthétique | ||||
| 187 | + } | ||||
| 188 | + valeurs = {k: fin.capitalisation_directe(rne, R) for k, R in taux.items()} | ||||
| 189 | + par_log = {k: v / n for k, v in valeurs.items()} | ||||
| 190 | + hyp = fin.Hypotheque(float(row["dette"]), float(row["taux_dette"]), amortissement_mois) | ||||
| 191 | + fin_ = {"dette": float(row["dette"]), "taux_dette": float(row["taux_dette"]), "echeance": int(row["echeance_dette"]), "service_dette": hyp.service_dette_annuel, | ||||
| 192 | + "rcd": fin.rcd(rne, hyp.service_dette_annuel), "rpv_fichier": float(row["dette"]) / float(row["valeur_2026"]), | ||||
| 193 | + "rpv_regression": float(row["dette"]) / valeurs["regression_sujet"], "constante": hyp.constante_hypothecaire} | ||||
| 194 | + # décomposition de l'écart Montréal cas 3 → régression sujet (en points de base, cumul multiplicatif) | ||||
| 195 | + r_mtl12 = taux_predit(reg, "Montréal", etat, 12, age) | ||||
| 196 | + decomposition = {"regression_montreal_12_log_etat2_age72": r_mtl12, | ||||
| 197 | + "effet_taille_pb": (taux_predit(reg, "Montréal", etat, n, age) - r_mtl12) * 1e4, | ||||
| 198 | + "effet_rmr_pb": (taux["regression_sujet"] - taux_predit(reg, "Montréal", etat, n, age)) * 1e4} | ||||
| 199 | + return {"id": ID_36, "rmr": rmr, "n": n, "annee": int(row["annee_construction"]), "age": age, "etat": etat, "loyer_moyen": float(row["loyer_mensuel_moyen"]), | ||||
| 200 | + "rbp": float(row["revenu_brut_potentiel"]), "ratio_depenses": float(row["ratio_depenses"]), "rne": rne, "rne_par_logement": rne / n, | ||||
| 201 | + "inocc_marche": float(row["taux_inoccupation_marche"]), "valeur_fichier": float(row["valeur_2026"]), "valeur_fichier_par_logement": float(row["valeur_2026"]) / n, | ||||
| 202 | + "mrb_fichier": float(row["valeur_2026"]) / float(row["revenu_brut_potentiel"]), "taux": taux, "valeurs": valeurs, "par_logement": par_log, | ||||
| 203 | + "regression": reg, "financement": fin_, "decomposition": decomposition, | ||||
| 204 | + "ecart_montreal_vs_sujet": valeurs["montreal_cas3_mediane"] / valeurs["regression_sujet"] - 1, | ||||
| 205 | + "gatineau_valeur_par_logement": float(g.loc[rmr, "valeur_par_logement"]), "montreal_valeur_par_logement": float(g.loc["Montréal", "valeur_par_logement"])} | ||||
| 206 | + | ||||
| 207 | + | ||||
| 208 | +# ----------------------------------------------------------------------------- sensibilité | ||||
| 209 | + | ||||
| 210 | +def sensibilite_p45(p: dict) -> dict: | ||||
| 211 | + reg, n, age, rne = p["regression"], p["n"], p["age"], p["rne"] | ||||
| 212 | + grille = pd.DataFrame({r: [taux_predit(reg, r, e, n, age) for e in range(1, 6)] for r in RMR_LISTE}, index=[f"état {e}" for e in range(1, 6)]) | ||||
| 213 | + grille_valeur_log = grille.map(lambda R: rne / R / n) | ||||
| 214 | + tailles = [6, 12, 24, 36, 48, 72] | ||||
| 215 | + courbe = pd.DataFrame({"n": tailles, "taux_gatineau": [taux_predit(reg, "Gatineau", p["etat"], k, age) for k in tailles], | ||||
| 216 | + "taux_montreal": [taux_predit(reg, "Montréal", p["etat"], k, age) for k in tailles]}) | ||||
| 217 | + courbe["valeur_par_logement_gatineau"] = rne / n / courbe["taux_gatineau"] # RNE par logement constant, taille variable | ||||
| 218 | + R0 = p["taux"]["regression_sujet"] | ||||
| 219 | + pb = pd.DataFrame({"delta_pb": [-50, -25, 0, 25, 50]}) | ||||
| 220 | + pb["taux"] = R0 + pb["delta_pb"] / 1e4; pb["valeur"] = rne / pb["taux"]; pb["variation_pct"] = pb["valeur"] / (rne / R0) - 1 | ||||
| 221 | + # élasticité exacte : dV/V = -dR/R | ||||
| 222 | + return {"grille_taux": grille, "grille_valeur_par_logement": grille_valeur_log, "courbe_taille": courbe, "taux_pb": pb, | ||||
| 223 | + "elasticite": -1.0, "variation_25pb_pct": float(R0 / (R0 + 0.0025) - 1)} | ||||
| 224 | + | ||||
| 225 | + | ||||
| 226 | +# ----------------------------------------------------------------------------- exercices | ||||
| 227 | + | ||||
| 228 | +def exercices(som: dict, et: dict, u: dict, p: dict) -> dict: | ||||
| 229 | + ex = {} | ||||
| 230 | + # ex1 — unités de comparaison : un 16 logements fictif (données de l'énoncé) évalué par les trois unités du cas 3 | ||||
| 231 | + n16, m2_16, ch16 = 16, 1040.0, 26 | ||||
| 232 | + ex["ex1"] = {"par_logement": u["par_logement"] * n16, "par_m2": u["par_m2"] * m2_16, "par_chambre": u["par_chambre"] * ch16, | ||||
| 233 | + "m2_par_logement_sujet": u["m2_par_logement"], "m2_par_logement_16": m2_16 / n16, "chambres_par_logement_16": ch16 / n16} | ||||
| 234 | + ex["ex1"]["ecart_max_pct"] = max(ex["ex1"]["par_logement"], ex["ex1"]["par_m2"], ex["ex1"]["par_chambre"]) / min(ex["ex1"]["par_logement"], ex["ex1"]["par_m2"], ex["ex1"]["par_chambre"]) - 1 | ||||
| 235 | + # ex2 — taxes de la Ville de Québec : 5 logements (1-5) vs 6 logements (6+) pour une même valeur au rôle | ||||
| 236 | + role = 1_800_000 | ||||
| 237 | + taxe5 = role * TAXE_QUEBEC_1A5 + 5 * (TARIF_QUEBEC_EAU + TARIF_QUEBEC_MATIERES) | ||||
| 238 | + taxe6 = role * TAXE_QUEBEC_6PLUS + 6 * (TARIF_QUEBEC_EAU + TARIF_QUEBEC_MATIERES) | ||||
| 239 | + ex["ex2"] = {"role": role, "taxe_5": taxe5, "taxe_6": taxe6, "ecart_taux_seul": role * (TAXE_QUEBEC_6PLUS - TAXE_QUEBEC_1A5), | ||||
| 240 | + "ecart_total": taxe6 - taxe5, "effet_valeur_a_5pct": role * (TAXE_QUEBEC_6PLUS - TAXE_QUEBEC_1A5) / 0.055} | ||||
| 241 | + # ex3 — effet de taille (cas 3) : 36 logements au lieu de 12 | ||||
| 242 | + R12 = et["cas3_constante"]; R36 = et["cas3_constante"] + (et["cas3_pente_pb_par_logement"] / 1e4) * (36 - 12) | ||||
| 243 | + rne_log = et["cas3_rne"] / 12 | ||||
| 244 | + ex["ex3"] = {"R12": R12, "R36": R36, "ecart_pb": (R36 - R12) * 1e4, "valeur_par_logement_12": rne_log / R12, "valeur_par_logement_36": rne_log / R36, | ||||
| 245 | + "prime_pct": rne_log / R36 / (rne_log / R12) - 1, "cas4_doublement_pct": et["cas4_effet_doublement_pct"]} | ||||
| 246 | + # ex4 — erreur de marché : P45 au taux montréalais | ||||
| 247 | + ex["ex4"] = {"valeur_montreal": p["valeurs"]["montreal_cas3_mediane"], "valeur_regression": p["valeurs"]["regression_sujet"], | ||||
| 248 | + "ecart": p["valeurs"]["montreal_cas3_mediane"] - p["valeurs"]["regression_sujet"], "ecart_pct": p["ecart_montreal_vs_sujet"], | ||||
| 249 | + "valeur_gatineau_pondere": p["valeurs"]["gatineau_pondere"], "effet_rmr_pb": p["decomposition"]["effet_rmr_pb"], "effet_taille_pb": p["decomposition"]["effet_taille_pb"]} | ||||
| 250 | + # ex5 — diligence : 4 baux surévalués de 120 $/mois dans l'état locatif de P45 ; vacance 3,8 %, gestion 4 % du RBE, R = régression | ||||
| 251 | + delta_loyer = 4 * 120 * 12 | ||||
| 252 | + R = p["taux"]["regression_sujet"]; v = p["inocc_marche"] | ||||
| 253 | + delta_rne = delta_loyer * (1 - v) * (1 - 0.04) | ||||
| 254 | + ex["ex5"] = {"delta_rbp": delta_loyer, "delta_rne": delta_rne, "delta_valeur": delta_rne / R, "delta_valeur_pct": delta_rne / R / p["valeurs"]["regression_sujet"], | ||||
| 255 | + "R": R, "v": v} | ||||
| 256 | + # ex6 — régression : 24 logements, état 3, 45 ans, dans chaque RMR ; IC à 95 % approximatif sur l'effet RMR (HC1) | ||||
| 257 | + reg = p["regression"]; cols = reg["colonnes"] | ||||
| 258 | + ex6 = {} | ||||
| 259 | + for r in RMR_LISTE: | ||||
| 260 | + R = taux_predit(reg, r, 3, 24, 45) | ||||
| 261 | + if r == "Montréal": | ||||
| 262 | + lo = hi = None | ||||
| 263 | + else: | ||||
| 264 | + j = cols.index(f"rmr_{r}"); b, s = reg["beta"][j], reg["se_hc1"][j] | ||||
| 265 | + lo, hi = np.exp(b - 1.96 * s) - 1, np.exp(b + 1.96 * s) - 1 | ||||
| 266 | + ex6[r] = {"taux": R, "effet_pct": (np.exp(reg["beta"][cols.index(f"rmr_{r}")]) - 1) if r != "Montréal" else 0.0, "ic_lo": lo, "ic_hi": hi} | ||||
| 267 | + ex["ex6"] = {"par_rmr": ex6, "r2": reg["r2"], "n": reg["n"], "duan": reg["duan"]} | ||||
| 268 | + # ex7 — immeuble neuf : coût indexé vs revenu (données de l'énoncé : coût 2025 hors terrain 6 400 000 $, terrain 1 100 000 $, 24 logements, | ||||
| 269 | + # loyer 1 975 $/mois, inoccupation 5,6 % (SCHL, île, immeubles 2015+), dépenses 32 % du RBE, taux neuf CBRE Montréal 4,50-4,75 %) | ||||
| 270 | + cout_2025, terrain, n_neuf, loyer_neuf, v_neuf, ratio_neuf = 6_400_000, 1_100_000, 24, 1975, 0.056, 0.32 | ||||
| 271 | + cout_2026 = cout_2025 * (1 + IPCB_MTL_BASSE_HAUTEUR_T3_2026) | ||||
| 272 | + rbp = n_neuf * loyer_neuf * 12; rbe = fin.revenu_brut_effectif(rbp, v_neuf); rne_neuf = fin.rne(rbe, ratio_neuf * rbe) | ||||
| 273 | + ex["ex7"] = {"cout_2026": cout_2026, "cout_total": cout_2026 + terrain, "cout_par_logement": (cout_2026 + terrain) / n_neuf, "rbp": rbp, "rbe": rbe, "rne": rne_neuf, | ||||
| 274 | + "V_450": fin.capitalisation_directe(rne_neuf, 0.045), "V_475": fin.capitalisation_directe(rne_neuf, 0.0475), | ||||
| 275 | + "taux_implicite_cout": rne_neuf / (cout_2026 + terrain), "valeur_assurable_hors_terrain": cout_2026} | ||||
| 276 | + return ex | ||||
| 277 | + | ||||
| 278 | + | ||||
| 279 | +# ----------------------------------------------------------------------------- assemblage et impression | ||||
| 280 | + | ||||
| 281 | +def calculer() -> dict: | ||||
| 282 | + som = sommaire_cas4(); et = effet_taille(som["portefeuille"]); u = unites_cas3(et); p = p45(som, et); s = sensibilite_p45(p) | ||||
| 283 | + schl = schl_ile_montreal() | ||||
| 284 | + return {"sommaire": som, "effet_taille": et, "unites": u, "p45": p, "sensibilite": s, "schl": schl, "exercices": exercices(som, et, u, p)} | ||||
| 285 | + | ||||
| 286 | + | ||||
| 287 | +def _d(x): return f"{x:,.0f}".replace(",", " ") + " $" | ||||
| 288 | +def _p(x, d=2): return f"{100*x:.{d}f}".replace(".", ",") + " %" | ||||
| 289 | + | ||||
| 290 | + | ||||
| 291 | +def imprimer(R: dict) -> None: | ||||
| 292 | + pd.set_option("display.width", 200); pd.set_option("display.max_columns", 20) | ||||
| 293 | + print("=== SCHL, île de Montréal, octobre 2025 (données réelles) ===") | ||||
| 294 | + print(R["schl"]["taille"].to_string(index=False)); print(R["schl"]["annee"].to_string(index=False)) | ||||
| 295 | + g = R["sommaire"]["par_rmr"]; t = R["sommaire"]["total"] | ||||
| 296 | + print("\n=== Cas 4 : sommaire par RMR ===") | ||||
| 297 | + print(g[["n", "logements", "valeur", "rne", "taux_pondere", "taux_median", "taux_min", "taux_max", "rpv", "valeur_par_logement", "rne_par_logement", "logements_moyen", "loyer_moyen"]].to_string()) | ||||
| 298 | + print(f"Total : {t['n']} immeubles, {t['logements']} logements, valeur {_d(t['valeur'])}, RNE {_d(t['rne'])}, taux pondéré {_p(t['taux_pondere'])}, RPV {_p(t['rpv'], 1)}, valeur/logement {_d(t['valeur']/t['logements'])}") | ||||
| 299 | + et = R["effet_taille"] | ||||
| 300 | + print("\n=== Effet de taille ===") | ||||
| 301 | + print(f"Cas 3 : pente {et['cas3_pente_pb_par_logement']:.2f} pb/logement ; constante {_p(et['cas3_constante'], 3)} ; médiane {_p(et['cas3_mediane'], 3)} ; 12→36 : {et['d_pb_12_36_cas3']:.1f} pb") | ||||
| 302 | + print(f"Cas 4 : coef ln(n/12) = {et['cas4_coef_ln']:.4f} (ET HC1 {et['cas4_se_ln']:.4f}) ; doublement → {_p(et['cas4_effet_doublement_pct'])} du taux ; 12→36 à R réf. {_p(et['cas4_r_ref'], 2)} : {et['d_pb_12_36_cas4']:.1f} pb") | ||||
| 303 | + print(et["par_classe"].to_string()); print(et["cas4_effets"].to_string()) | ||||
| 304 | + u = R["unites"] | ||||
| 305 | + print("\n=== Unités (cas 3, valeur finale ch. 47) ===") | ||||
| 306 | + print(f"V = {_d(u['valeur'])} ; {u['n']} log., {u['m2']:.0f} m², {u['chambres']} chambres → {_d(u['par_logement'])}/log., {_d(u['par_m2'])}/m², {_d(u['par_chambre'])}/chambre ; " | ||||
| 307 | + f"{u['m2_par_logement']:.1f} m²/log. ; {u['chambres_par_logement']:.2f} ch./log. ; MRB contrat {u['mrb']:.2f}, marché {u['mrb_marche']:.2f} ; taux implicite {_p(u['taux_implicite'])} ; " | ||||
| 308 | + f"RNE/log. {_d(u['rne_par_logement'])} ; loyer moyen {_d(u['loyer_moyen'])}/mois ; loyer {u['loyer_moyen_m2']:.2f} $/m²/mois ; V_cap {_d(u['valeur_cap'])} ({_d(u['valeur_cap_par_logement'])}/log.)") | ||||
| 309 | + p = R["p45"] | ||||
| 310 | + print(f"\n=== {p['id']} : {p['n']} logements, {p['rmr']}, {p['annee']} ({p['age']:.0f} ans), état {p['etat']} ===") | ||||
| 311 | + print(f"Loyer moyen {_d(p['loyer_moyen'])}/mois ; RBP {_d(p['rbp'])} ; ratio dépenses {_p(p['ratio_depenses'], 1)} ; inoccupation de marché {_p(p['inocc_marche'], 1)} ; RNE {_d(p['rne'])} ({_d(p['rne_par_logement'])}/log.)") | ||||
| 312 | + print(f"Valeur au dossier {_d(p['valeur_fichier'])} ({_d(p['valeur_fichier_par_logement'])}/log., MRB {p['mrb_fichier']:.2f}, taux {_p(p['taux']['fichier'])})") | ||||
| 313 | + for k in p["taux"]: | ||||
| 314 | + print(f" {k:30s} R = {_p(p['taux'][k], 3)} V = {_d(p['valeurs'][k])} ({_d(p['par_logement'][k])}/log.)") | ||||
| 315 | + d = p["decomposition"] | ||||
| 316 | + print(f"Décomposition : Montréal 12 log. état 2 âge 72 → {_p(d['regression_montreal_12_log_etat2_age72'], 3)} ; effet taille {d['effet_taille_pb']:+.1f} pb ; effet RMR {d['effet_rmr_pb']:+.1f} pb") | ||||
| 317 | + print(f"Écart Montréal cas 3 vs régression : {_p(p['ecart_montreal_vs_sujet'], 1)} ; valeur/log. Gatineau cas 4 {_d(p['gatineau_valeur_par_logement'])}, Montréal {_d(p['montreal_valeur_par_logement'])}") | ||||
| 318 | + f = p["financement"] | ||||
| 319 | + print(f"Dette {_d(f['dette'])} à {_p(f['taux_dette'])} (échéance {f['echeance']}) ; service (300 mois, hyp.) {_d(f['service_dette'])} ; RCD {f['rcd']:.2f} ; RPV dossier {_p(f['rpv_fichier'], 1)} ; RPV sur V régression {_p(f['rpv_regression'], 1)} ; constante {_p(f['constante'])}") | ||||
| 320 | + reg = p["regression"]; print(f"Régression cas 4 : n = {reg['n']}, R² = {reg['r2']:.3f}, facteur de Duan {reg['duan']:.4f}") | ||||
| 321 | + s = R["sensibilite"] | ||||
| 322 | + print("\n=== Sensibilité P45 ===") | ||||
| 323 | + print((s["grille_taux"] * 100).round(2).to_string()); print(s["grille_valeur_par_logement"].round(0).to_string()); print(s["courbe_taille"].to_string(index=False)); print(s["taux_pb"].to_string(index=False)) | ||||
| 324 | + print(f"Variation de la valeur pour +25 pb : {_p(s['variation_25pb_pct'])}") | ||||
| 325 | + print("\n=== Exercices ===") | ||||
| 326 | + for k, v in R["exercices"].items(): | ||||
| 327 | + print(k, {kk: (round(vv, 4) if isinstance(vv, float) else vv) for kk, vv in v.items()} if k != "ex6" else {r: {kk: (round(vv, 4) if isinstance(vv, float) else vv) for kk, vv in d.items()} for r, d in v["par_rmr"].items()} | {"r2": round(v["r2"], 3), "duan": round(v["duan"], 4)}) | ||||
| 328 | + | ||||
| 329 | + | ||||
| 330 | +if __name__ == "__main__": | ||||
| 331 | + imprimer(calculer()) | ||||
modified
analysis/python/figures/ch25_figures.py
+17 −16
@@ -52,7 +52,8 @@ def fig_carte(S): | |||||
| 52 | th = np.linspace(0, 2 * np.pi, 200) | 52 | th = np.linspace(0, 2 * np.pi, 200) | ||
| 53 | ax.plot(suj["lon"] + 4 / 78 * np.cos(th), suj["lat"] + 4 / 111 * np.sin(th), color=palette.cat[3], lw=0.8, ls=":") | 53 | ax.plot(suj["lon"] + 4 / 78 * np.cos(th), suj["lat"] + 4 / 111 * np.sin(th), color=palette.cat[3], lw=0.8, ls=":") | ||
| 54 | for sct, (la, lo) in cas.CENTROIDES.items(): | 54 | for sct, (la, lo) in cas.CENTROIDES.items(): | ||
| 55 | − ax.text(lo, la + 0.030, sct, ha="center", fontsize=8, color=palette.ink, fontweight="semibold") | 55 | + dy = -0.036 if sct == "Masson-Angers" else 0.030 | ||
| 56 | + ax.text(lo, la + dy, sct, ha="center", fontsize=8, color=palette.ink, fontweight="semibold") | ||||
| 56 | ax.set_xlabel("Longitude (coordonnées fictives)"); ax.set_ylabel("Latitude (coordonnées fictives)") | 57 | ax.set_xlabel("Longitude (coordonnées fictives)"); ax.set_ylabel("Latitude (coordonnées fictives)") | ||
| 57 | ax.set_aspect(111 / 78); ax.legend(loc="lower right", fontsize=7.5) | 58 | ax.set_aspect(111 / 78); ax.legend(loc="lower right", fontsize=7.5) | ||
| 58 | ax.set_xlim(-75.92, -75.33); ax.set_ylim(45.34, 45.64) | 59 | ax.set_xlim(-75.92, -75.33); ax.set_ylim(45.34, 45.64) | ||
@@ -61,7 +62,7 @@ def fig_carte(S): | |||||
| 61 | 62 | ||||
| 62 | def fig_lotissement(S): | 63 | def fig_lotissement(S): | ||
| 63 | L = S["lotissement"]; det = L["detail"]; p = L["params"]; m = S["comparaison"]["modele"] | 64 | L = S["lotissement"]; det = L["detail"]; p = L["params"]; m = S["comparaison"]["modele"] | ||
| 64 | − fig, (a1, a2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.4), gridspec_kw={"width_ratios": [1.1, 1]}) | 65 | + fig, (a1, a2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.6), gridspec_kw={"width_ratios": [1.0, 1.0], "wspace": 0.95}) | ||
| 65 | yrs = det["annee"].values | 66 | yrs = det["annee"].values | ||
| 66 | couts = -(det["infrastructure"] + det["honoraires"] + det["taxes"] + det["frais_vente"] + det["profit"]).values | 67 | couts = -(det["infrastructure"] + det["honoraires"] + det["taxes"] + det["frais_vente"] + det["profit"]).values | ||
| 67 | a1.bar(yrs, det["revenus"].values, color=palette.cat[0], width=0.6, label="Produit des ventes de lots") | 68 | a1.bar(yrs, det["revenus"].values, color=palette.cat[0], width=0.6, label="Produit des ventes de lots") | ||
@@ -69,24 +70,24 @@ def fig_lotissement(S): | |||||
| 69 | r = p["taux"]; cum = np.cumsum([f / (1 + r) ** t for t, f in zip(yrs, L["flux"])]) | 70 | r = p["taux"]; cum = np.cumsum([f / (1 + r) ** t for t, f in zip(yrs, L["flux"])]) | ||
| 70 | a1.plot(yrs, cum, color=palette.cat[3], marker="o", lw=1.6, label="Valeur actualisée cumulée (10 %)") | 71 | a1.plot(yrs, cum, color=palette.cat[3], marker="o", lw=1.6, label="Valeur actualisée cumulée (10 %)") | ||
| 71 | for t, v in zip(yrs, cum): | 72 | for t, v in zip(yrs, cum): | ||
| 72 | − a1.annotate(fmt_dollars(v), (t, v), xytext=(0, 6), textcoords="offset points", ha="center", fontsize=7, color=palette.ink) | 73 | + a1.annotate(fmt_milliers(v / 1000), (t, v), xytext=(0, 6), textcoords="offset points", ha="center", fontsize=7, color=palette.ink) | ||
| 73 | a1.axhline(0, color=palette.ink2, lw=0.6) | 74 | a1.axhline(0, color=palette.ink2, lw=0.6) | ||
| 74 | − a1.set_xticks(yrs); a1.set_xticklabels([f"Année {t}" for t in yrs]); a1.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p_: fmt_milliers(x / 1000))) | 75 | + a1.set_xticks(yrs); a1.set_xticklabels([str(t) for t in yrs]); a1.set_xlabel("Année (flux de fin d'année)"); a1.set_ylim(-1_250_000, 1_450_000); a1.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p_: fmt_milliers(x / 1000))) | ||
| 75 | a1.set_ylabel("Milliers de dollars"); a1.set_title("(a) Flux du lotissement de 12 lots", loc="left", fontsize=9) | 76 | a1.set_ylabel("Milliers de dollars"); a1.set_title("(a) Flux du lotissement de 12 lots", loc="left", fontsize=9) | ||
| 76 | − a1.legend(fontsize=7, loc="upper left") | 77 | + a1.legend(fontsize=6.5, loc="lower left", handlelength=1.2) | ||
| 77 | sl = cas.sensibilite_lotissement(m); base = sl["base"] | 78 | sl = cas.sensibilite_lotissement(m); base = sl["base"] | ||
| 78 | − items = [("Absorption : 2 à 6 lots/an", sl["absorption"][2], sl["absorption"][6]), | 79 | + items = [("Absorption\n2 → 6 lots/an", sl["absorption"][2], sl["absorption"][6]), | ||
| 79 | − ("Prix des lots : −10 % / +10 %", sl["prix"][-0.10], sl["prix"][0.10]), | 80 | + ("Prix des lots\n−10 % → +10 %", sl["prix"][-0.10], sl["prix"][0.10]), | ||
| 80 | − ("Infrastructures : 80 000 / 50 000 $ par lot", sl["infrastructure"][80_000], sl["infrastructure"][50_000]), | 81 | + ("Infrastructures\n80 000 → 50 000 $/lot", sl["infrastructure"][80_000], sl["infrastructure"][50_000]), | ||
| 81 | − ("Profit du promoteur : 20 % / 10 %", sl["profit"][0.20], sl["profit"][0.10]), | 82 | + ("Profit du promoteur\n20 % → 10 %", sl["profit"][0.20], sl["profit"][0.10]), | ||
| 82 | − ("Taux d'actualisation : 15 % / 8 %", sl["taux"][0.15], sl["taux"][0.08])] | 83 | + ("Taux d'actualisation\n15 % → 8 %", sl["taux"][0.15], sl["taux"][0.08])] | ||
| 83 | items.sort(key=lambda x: abs(x[2] - x[1]), reverse=True) | 84 | items.sort(key=lambda x: abs(x[2] - x[1]), reverse=True) | ||
| 84 | for k, (lab, lo, hi) in enumerate(items): | 85 | for k, (lab, lo, hi) in enumerate(items): | ||
| 85 | a2.barh(k, lo - base, left=base, color=palette.cat[1], height=0.55) | 86 | a2.barh(k, lo - base, left=base, color=palette.cat[1], height=0.55) | ||
| 86 | a2.barh(k, hi - base, left=base, color=palette.cat[0], height=0.55) | 87 | a2.barh(k, hi - base, left=base, color=palette.cat[0], height=0.55) | ||
| 87 | a2.text(min(lo, hi) - 8000, k, fmt_milliers((min(lo, hi)) / 1000), va="center", ha="right", fontsize=7, color=palette.ink) | 88 | a2.text(min(lo, hi) - 8000, k, fmt_milliers((min(lo, hi)) / 1000), va="center", ha="right", fontsize=7, color=palette.ink) | ||
| 88 | a2.text(max(lo, hi) + 8000, k, fmt_milliers((max(lo, hi)) / 1000), va="center", ha="left", fontsize=7, color=palette.ink) | 89 | a2.text(max(lo, hi) + 8000, k, fmt_milliers((max(lo, hi)) / 1000), va="center", ha="left", fontsize=7, color=palette.ink) | ||
| 89 | − a2.set_yticks(range(len(items))); a2.set_yticklabels([i[0] for i in items], fontsize=7.5) | 90 | + a2.set_yticks(range(len(items))); a2.set_yticklabels([i[0] for i in items], fontsize=7) | ||
| 90 | a2.axvline(base, color=palette.ink, lw=0.8) | 91 | a2.axvline(base, color=palette.ink, lw=0.8) | ||
| 91 | a2.set_xlim(base - 420_000, base + 420_000); a2.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p_: fmt_milliers(x / 1000))) | 92 | a2.set_xlim(base - 420_000, base + 420_000); a2.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p_: fmt_milliers(x / 1000))) | ||
| 92 | a2.set_xlabel("Valeur du site brut (milliers de dollars)"); a2.set_title(f"(b) Sensibilité autour de {fmt_dollars(base)}", loc="left", fontsize=9) | 93 | a2.set_xlabel("Valeur du site brut (milliers de dollars)"); a2.set_title(f"(b) Sensibilité autour de {fmt_dollars(base)}", loc="left", fontsize=9) | ||
@@ -101,17 +102,17 @@ def fig_residuelle(S): | |||||
| 101 | for d, col, ls in [(0.44, palette.cat[1], "--"), (0.52, palette.cat[0], "-"), (0.60, palette.cat[2], "-.")]: | 102 | for d, col, ls in [(0.44, palette.cat[1], "--"), (0.52, palette.cat[0], "-"), (0.60, palette.cat[2], "-.")]: | ||
| 102 | VL = [cas.residuelle_cas3({"taux_depreciation": d, "R_L": r}, t3)["V_L"] for r in RL] | 103 | VL = [cas.residuelle_cas3({"taux_depreciation": d, "R_L": r}, t3)["V_L"] for r in RL] | ||
| 103 | CB = cas.residuelle_cas3({"taux_depreciation": d}, t3)["C_B"] | 104 | CB = cas.residuelle_cas3({"taux_depreciation": d}, t3)["C_B"] | ||
| 104 | − ax.plot(RL * 100, VL, color=col, ls=ls, lw=1.6, label=f"Dépréciation {d*100:.0f} % — $C_B$ = {fmt_dollars(CB)}") | 105 | + ax.plot(RL * 100, VL, color=col, ls=ls, lw=1.6, label=f"Dépréciation {d*100:.0f} % : bâtiment déprécié {fmt_dollars(CB)}") | ||
| 105 | ax.axhline(0, color=palette.ink2, lw=0.6) | 106 | ax.axhline(0, color=palette.ink2, lw=0.6) | ||
| 106 | ax.scatter([rs["R_L"] * 100], [rs["V_L"]], s=60, color=palette.cat[3], zorder=5, edgecolor=palette.ink, linewidths=0.5) | 107 | ax.scatter([rs["R_L"] * 100], [rs["V_L"]], s=60, color=palette.cat[3], zorder=5, edgecolor=palette.ink, linewidths=0.5) | ||
| 107 | − ax.annotate(f"Cas de base : $R_L$ = 3,75 %, n = 40 ans\n$V_L$ = {fmt_dollars(rs['V_L'])}", (rs["R_L"] * 100, rs["V_L"]), xytext=(4.2, 900_000), fontsize=8, | 108 | + ax.annotate(f"Cas de base : taux du terrain 3,75 %, 40 ans restants\nvaleur résiduelle {fmt_dollars(rs['V_L'])}", (rs["R_L"] * 100, rs["V_L"]), xytext=(4.05, 1_000_000), fontsize=8, | ||
| 108 | arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6)) | 109 | arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6)) | ||
| 109 | ext = rs["V_cap"] - rs["C_B"] | 110 | ext = rs["V_cap"] - rs["C_B"] | ||
| 110 | ax.axhline(ext, color=palette.ink2, lw=0.8, ls=":") | 111 | ax.axhline(ext, color=palette.ink2, lw=0.8, ls=":") | ||
| 111 | − ax.text(3.02, ext + 25_000, f"Par extraction sur la capitalisation directe : $V$ − $C_B$ = {fmt_dollars(ext)}", fontsize=7.5, color=palette.ink2) | 112 | + ax.text(3.02, ext + 25_000, f"Extraction sur la valeur capitalisée (V − bâtiment déprécié) : {fmt_dollars(ext)}", fontsize=7.5, color=palette.ink2) | ||
| 112 | − ax.set_xlabel("Taux de rendement du terrain $R_L$ (%)"); ax.set_ylabel("Valeur résiduelle du terrain $V_L$ ($)") | 113 | + ax.set_xlabel("Taux de rendement du terrain $R_L$ (%)"); ax.set_ylabel("Valeur résiduelle du terrain (dollars)") | ||
| 113 | ax.yaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars)); ax.set_ylim(-300_000, 1_300_000) | 114 | ax.yaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars)); ax.set_ylim(-300_000, 1_300_000) | ||
| 114 | − ax.legend(loc="upper right", fontsize=7.5) | 115 | + ax.legend(loc="lower left", fontsize=7.5) | ||
| 115 | savefig(fig, "ch25/ch25-fig-residuelle-sensibilite.pdf") | 116 | savefig(fig, "ch25/ch25-fig-residuelle-sensibilite.pdf") | ||
| 116 | 117 | ||||
| 117 | 118 | ||||
modified
analysis/python/figures/ch26_figures.py
+5 −6
@@ -40,13 +40,13 @@ def fig_anciennete(res): | |||||
| 40 | fig, (a1, a2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.1), gridspec_kw={"width_ratios": [1.15, 1]}) | 40 | fig, (a1, a2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.1), gridspec_kw={"width_ratios": [1.15, 1]}) | ||
| 41 | # (a) distribution de l'ancienneté (part des logements) et courbes de survie (1 − ρ)^t — même échelle de proportions, pas de double axe | 41 | # (a) distribution de l'ancienneté (part des logements) et courbes de survie (1 − ρ)^t — même échelle de proportions, pas de double axe | ||
| 42 | anc = t["anciennete"].values; bins = np.arange(0, 21, 2) | 42 | anc = t["anciennete"].values; bins = np.arange(0, 21, 2) | ||
| 43 | − a1.hist(anc, bins=bins, weights=np.full(len(anc), 1 / len(anc)), color=palette.cat[0], alpha=0.85, edgecolor=palette.surface, zorder=3, label="part des 12 logements (ancienneté observée)") | 43 | + a1.hist(anc, bins=bins, weights=np.full(len(anc), 1 / len(anc)), color=palette.cat[0], alpha=0.85, edgecolor=palette.surface, zorder=3, label="part des 12 logements") | ||
| 44 | tt = np.linspace(0, 20, 200) | 44 | tt = np.linspace(0, 20, 200) | ||
| 45 | − for rho, c, ls, lab in ((0.085, palette.cat[2], "-", "survie $(1-\\rho)^t$, ρ = 8,5 % (SCHL, 6–19 log., Montréal 2025)"), (0.094, palette.cat[3], "--", "survie, ρ = 9,4 % (SCHL, RMR Montréal 2025)"), (0.15, palette.cat[1], ":", "survie, ρ = 15 % (hypothèse du cas 3)")): | 45 | + for rho, c, ls, lab in ((0.085, palette.cat[2], "-", "survie $(1-\\rho)^t$, ρ = 8,5 % (SCHL, 6–19 log.)"), (0.094, palette.cat[3], "--", "survie, ρ = 9,4 % (SCHL, RMR Montréal)"), (0.15, palette.cat[1], ":", "survie, ρ = 15 % (cas 3)")): | ||
| 46 | a1.plot(tt, survie(rho, tt), color=c, linestyle=ls, linewidth=1.4, label=lab) | 46 | a1.plot(tt, survie(rho, tt), color=c, linestyle=ls, linewidth=1.4, label=lab) | ||
| 47 | a1.set_xlabel("Ancienneté du locataire (années)"); a1.set_ylabel("Proportion"); a1.set_ylim(0, 1.05); a1.set_xlim(0, 20) | 47 | a1.set_xlabel("Ancienneté du locataire (années)"); a1.set_ylabel("Proportion"); a1.set_ylim(0, 1.05); a1.set_xlim(0, 20) | ||
| 48 | a1.set_title("(a) Ancienneté observée et survie théorique", loc="left") | 48 | a1.set_title("(a) Ancienneté observée et survie théorique", loc="left") | ||
| 49 | − a1.legend(loc="upper center", bbox_to_anchor=(0.5, -0.24), fontsize=6.6, ncol=1) | 49 | + a1.legend(loc="upper left", bbox_to_anchor=(0.0, -0.20), fontsize=6.3, ncol=2, frameon=False) | ||
| 50 | # (b) écart mensuel selon l'ancienneté, par type | 50 | # (b) écart mensuel selon l'ancienneté, par type | ||
| 51 | marq = {"3½": "o", "4½": "s", "5½": "^"} | 51 | marq = {"3½": "o", "4½": "s", "5½": "^"} | ||
| 52 | for i, (p, m) in enumerate(marq.items()): | 52 | for i, (p, m) in enumerate(marq.items()): | ||
@@ -57,9 +57,8 @@ def fig_anciennete(res): | |||||
| 57 | a2.yaxis.set_major_formatter(dollars); a2.set_xlabel("Ancienneté du locataire (années)"); a2.set_ylabel("Écart marché − contrat ($/mois)") | 57 | a2.yaxis.set_major_formatter(dollars); a2.set_xlabel("Ancienneté du locataire (années)"); a2.set_ylabel("Écart marché − contrat ($/mois)") | ||
| 58 | a2.set_title("(b) Écart mensuel selon l'ancienneté", loc="left"); a2.legend(title="Type", loc="upper left", fontsize=7) | 58 | a2.set_title("(b) Écart mensuel selon l'ancienneté", loc="left"); a2.legend(title="Type", loc="upper left", fontsize=7) | ||
| 59 | a2.set_xlim(0, 20); a2.set_ylim(0, 360) | 59 | a2.set_xlim(0, 20); a2.set_ylim(0, 360) | ||
| 60 | − a2.legend(title="Type", loc="upper left", fontsize=7, bbox_to_anchor=(0.0, -0.24), ncol=3) | 60 | + a2.text(1.0, -0.36, SYNTH, transform=a2.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2) | ||
| 61 | − fig.text(0.99, -0.30, SYNTH, ha="right", va="bottom", fontsize=7, color=palette.ink2) | 61 | + fig.subplots_adjust(wspace=0.38) | ||
| 62 | − fig.tight_layout() | ||||
| 63 | return savefig(fig, "ch26/ch26-fig-anciennete.pdf") | 62 | return savefig(fig, "ch26/ch26-fig-anciennete.pdf") | ||
| 64 | 63 | ||||
| 65 | 64 | ||||
modified
analysis/python/figures/ch27_figures.py
+9 −9
@@ -50,9 +50,9 @@ def fig_inoccupation_rmr(res): | |||||
| 50 | 50 | ||||
| 51 | def fig_cascade(res): | 51 | def fig_cascade(res): | ||
| 52 | r = res["cas3"] | 52 | r = res["cas3"] | ||
| 53 | − etapes = [("RBP aux loyers\nde marché", r["rbp_marche"], "total"), ("Déficit de loyer\n(baux en place)", -r["deficit_loyer"], "delta"), | 53 | + etapes = [("RBP au\nmarché", r["rbp_marche"], "total"), ("Déficit\nde loyer", -r["deficit_loyer"], "delta"), | ||
| 54 | − ("RBP\ncontractuel", r["rbp_contrat"], "total"), ("Inoccupation et\nmauvaises créances", -r["vacance_pertes"], "delta"), | 54 | + ("RBP\ncontractuel", r["rbp_contrat"], "total"), ("Inoccupation\net créances", -r["vacance_pertes"], "delta"), | ||
| 55 | − ("Loyers\neffectifs", r["loyers_effectifs"], "total"), ("Autres\nrevenus", r["autres_revenus"], "delta"), ("Revenu brut\neffectif (RBE)", r["rbe"], "total")] | 55 | + ("Loyers\neffectifs", r["loyers_effectifs"], "total"), ("Autres\nrevenus", r["autres_revenus"], "delta"), ("RBE", r["rbe"], "total")] | ||
| 56 | fig, ax = plt.subplots(figsize=(LARGEUR, 3.5)) | 56 | fig, ax = plt.subplots(figsize=(LARGEUR, 3.5)) | ||
| 57 | cum = 0.0 | 57 | cum = 0.0 | ||
| 58 | for i, (lab, val, kind) in enumerate(etapes): | 58 | for i, (lab, val, kind) in enumerate(etapes): | ||
@@ -79,7 +79,7 @@ def fig_cascade(res): | |||||
| 79 | def fig_trajectoires(res): | 79 | def fig_trajectoires(res): | ||
| 80 | t = res["trajectoires"]["table"]; h = res["trajectoires"]["hyp"]; T = res["trajectoires"]["T"] | 80 | t = res["trajectoires"]["table"]; h = res["trajectoires"]["hyp"]; T = res["trajectoires"]["T"] | ||
| 81 | t = t[t["annee"] <= T] | 81 | t = t[t["annee"] <= T] | ||
| 82 | − fig, ax = plt.subplots(figsize=(LARGEUR, 3.3)) | 82 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.9)) | ||
| 83 | x = t["exercice"].values | 83 | x = t["exercice"].values | ||
| 84 | 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é") | 84 | 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é") | ||
| 85 | 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(".", ",")) | 85 | 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(".", ",")) | ||
@@ -90,9 +90,9 @@ def fig_trajectoires(res): | |||||
| 90 | 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") | 90 | 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") | ||
| 91 | ax.set_xticks(x); ax.set_xlabel("Exercice (1er septembre → 31 août)"); ax.set_ylabel("Revenu brut potentiel (milliers de $)") | 91 | ax.set_xticks(x); ax.set_xlabel("Exercice (1er septembre → 31 août)"); ax.set_ylabel("Revenu brut potentiel (milliers de $)") | ||
| 92 | ax.yaxis.set_major_formatter(FuncFormatter(lambda v, p: fmt_milliers(v))) | 92 | ax.yaxis.set_major_formatter(FuncFormatter(lambda v, p: fmt_milliers(v))) | ||
| 93 | − ax.set_xlim(x[0] - 0.3, x[-1] + 1.4); ax.legend(loc="lower right", fontsize=7) | 93 | + 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) | ||
| 94 | ax.set_title("Cas 3 : quatre trajectoires du revenu brut potentiel sur dix ans", loc="left") | 94 | ax.set_title("Cas 3 : quatre trajectoires du revenu brut potentiel sur dix ans", loc="left") | ||
| 95 | − ax.text(0.99, -0.22, "DONNÉES SYNTHÉTIQUES", transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2) | 95 | + ax.text(0.99, -0.40, "DONNÉES SYNTHÉTIQUES", transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2) | ||
| 96 | return savefig(fig, "ch27/ch27-fig-trajectoires.pdf") | 96 | return savefig(fig, "ch27/ch27-fig-trajectoires.pdf") | ||
| 97 | 97 | ||||
| 98 | 98 | ||||
@@ -100,16 +100,16 @@ def fig_sensibilite(res): | |||||
| 100 | s = res["schl_rmr"]; base = res["cas3"] | 100 | s = res["schl_rmr"]; base = res["cas3"] | ||
| 101 | fig, (a, b) = plt.subplots(1, 2, figsize=(LARGEUR, 3.2)) | 101 | fig, (a, b) = plt.subplots(1, 2, figsize=(LARGEUR, 3.2)) | ||
| 102 | d = np.linspace(0, 3, 61) | 102 | d = np.linspace(0, 3, 61) | ||
| 103 | − rots = [(0.05, "ρ = 5 %"), (0.094, "ρ = 9,4 % (Montréal 2025)"), (0.13, "ρ = 13 % (Québec 2025)"), (0.162, "ρ = 16,2 % (Gatineau 2025)"), (0.25, "ρ = 25 %")] | 103 | + 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 %")] | ||
| 104 | for i, (rho, lab) in enumerate(rots): | 104 | for i, (rho, lab) in enumerate(rots): | ||
| 105 | a.plot(d, [fin.taux_vacance_frictionnelle(rho, x) * 100 for x in d], color=palette.cat[i], label=lab) | 105 | a.plot(d, [fin.taux_vacance_frictionnelle(rho, x) * 100 for x in d], color=palette.cat[i], label=lab) | ||
| 106 | a.axhline(s.loc["Montréal", "inocc_2025"], color=palette.ink2, lw=0.7, ls=":") | 106 | a.axhline(s.loc["Montréal", "inocc_2025"], color=palette.ink2, lw=0.7, ls=":") | ||
| 107 | − a.text(0.05, s.loc["Montréal", "inocc_2025"] + 0.08, "inoccupation SCHL, RMR de Montréal, oct. 2025 : 2,9 %", fontsize=6.8, color=palette.ink2) | 107 | + 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") | ||
| 108 | a.scatter([1.0], [base["v_frictionnel_dossier"] * 100], color=palette.ink, marker="*", s=70, zorder=4) | 108 | a.scatter([1.0], [base["v_frictionnel_dossier"] * 100], color=palette.ink, marker="*", s=70, zorder=4) | ||
| 109 | 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, | 109 | 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, | ||
| 110 | arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6)) | 110 | arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6)) | ||
| 111 | a.set_xlabel("Durée moyenne de vacance entre deux baux, d (mois)"); a.set_ylabel("Inoccupation frictionnelle v_f = ρ·d/12 (%)") | 111 | a.set_xlabel("Durée moyenne de vacance entre deux baux, d (mois)"); a.set_ylabel("Inoccupation frictionnelle v_f = ρ·d/12 (%)") | ||
| 112 | − a.set_ylim(0, 6.5); a.yaxis.set_major_formatter(pct1); a.legend(fontsize=6.5, loc="upper left") | 112 | + a.set_ylim(0, 5.2); a.yaxis.set_major_formatter(pct1); a.legend(fontsize=6.5, loc="upper left") | ||
| 113 | a.set_title("(a) Rotation, durée de vacance et inoccupation", loc="left", fontsize=8.5) | 113 | a.set_title("(a) Rotation, durée de vacance et inoccupation", loc="left", fontsize=8.5) | ||
| 114 | 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}))) | 114 | 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}))) | ||
| 115 | b.plot(sv["v"] * 100, sv["valeur"] / 1000, color=palette.cat[0]) | 115 | b.plot(sv["v"] * 100, sv["valeur"] / 1000, color=palette.cat[0]) | ||
added
analysis/python/figures/ch28_figures.py
+144 −0
@@ -0,0 +1,144 @@ | |||||
| 1 | +#!/usr/bin/env python3 | ||||
| 2 | +"""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 loyer | ||||
| 4 | +moyen (40 comparables du cas 3, courbe iso-dépenses du sujet) ; sensibilité du RNE (réserve par logement, taux de taxe, | ||||
| 5 | +gestion). DONNÉES SYNTHÉTIQUES (graine 20261031). Idempotent ; écrit dans assets/figures/ch28/ch28-fig-<slug>.pdf. | ||||
| 6 | +Usage (racine) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch28_figures.py""" | ||||
| 7 | +from __future__ import annotations | ||||
| 8 | +import sys, pathlib | ||||
| 9 | +import numpy as np, pandas as pd | ||||
| 10 | +import matplotlib.pyplot as plt | ||||
| 11 | +from matplotlib.ticker import FuncFormatter | ||||
| 12 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | ||||
| 13 | +from traite.style import setup, palette, savefig, LARGEUR, DEMI, fmt_dollars | ||||
| 14 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) | ||||
| 15 | +from ch28_depenses import calculer, ratio_iso_depenses, GROUPES, ORDRE_GROUPES | ||||
| 16 | + | ||||
| 17 | +pct0 = FuncFormatter(lambda x, p: f"{x*100:.0f} %") | ||||
| 18 | +pct1 = FuncFormatter(lambda x, p: f"{x*100:.1f}".replace(".", ",") + " %") | ||||
| 19 | +kdol = FuncFormatter(lambda x, p: f"{x/1000:,.0f}".replace(",", " ") + " k$") | ||||
| 20 | +NOTE = "DONNÉES SYNTHÉTIQUES" | ||||
| 21 | + | ||||
| 22 | + | ||||
| 23 | +def 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.0 | ||||
| 30 | + 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 = v | ||||
| 33 | + else: | ||||
| 34 | + bas = cum + v if v < 0 else cum | ||||
| 35 | + 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") | ||||
| 44 | + | ||||
| 45 | + | ||||
| 46 | +def 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.38 | ||||
| 50 | + 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") | ||||
| 60 | + | ||||
| 61 | + | ||||
| 62 | +def 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] * 100 | ||||
| 71 | + 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.38 | ||||
| 80 | + 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") | ||||
| 87 | + | ||||
| 88 | + | ||||
| 89 | +def 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") | ||||
| 106 | + | ||||
| 107 | + | ||||
| 108 | +def 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éserve | ||||
| 112 | + 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 continu | ||||
| 117 | + 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 | + # gestion | ||||
| 125 | + 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") | ||||
| 135 | + | ||||
| 136 | + | ||||
| 137 | +def 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) | ||||
| 141 | + | ||||
| 142 | + | ||||
| 143 | +if __name__ == "__main__": | ||||
| 144 | + main() | ||||
added
analysis/python/figures/ch32_figures.py
+128 −0
@@ -0,0 +1,128 @@ | |||||
| 1 | +"""Figures du chapitre 32 — Duplex à quintuplex (données SYNTHÉTIQUES du cas 2, sauf la figure des médianes APCIQ, citée). | ||||
| 2 | +Produit : assets/figures/ch32/ch32-fig-{mrb-taille-quartier,taux-loyer-relatif,prix-logement-quartier,plex-apciq-rmr,sensibilite-occupant}.pdf | ||||
| 3 | +Exécution : python3 analysis/python/figures/ch32_figures.py (depuis la racine).""" | ||||
| 4 | +from __future__ import annotations | ||||
| 5 | +import sys, pathlib | ||||
| 6 | +import numpy as np | ||||
| 7 | +import matplotlib.pyplot as plt | ||||
| 8 | +from matplotlib.ticker import FuncFormatter | ||||
| 9 | + | ||||
| 10 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | ||||
| 11 | +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases")) | ||||
| 12 | +from traite.style import setup, palette, savefig, LARGEUR, DEMI, fmt_dollars, fmt_milliers # noqa: E402 | ||||
| 13 | +import ch32_plex as C # noqa: E402 | ||||
| 14 | + | ||||
| 15 | +QUARTIERS = ["Limoilou", "Saint-Roch", "Saint-Sauveur", "Maizerets"] | ||||
| 16 | + | ||||
| 17 | + | ||||
| 18 | +def _boxstyle(bp, color): | ||||
| 19 | + for k in ("boxes", "whiskers", "caps", "medians"): | ||||
| 20 | + for a in bp[k]: a.set_color(color); a.set_linewidth(1.2) | ||||
| 21 | + for a in bp["medians"]: a.set_color(palette.ink); a.set_linewidth(1.6) | ||||
| 22 | + for a in bp["fliers"]: a.set(marker="o", markersize=3, markerfacecolor="none", markeredgecolor=color, alpha=0.7) | ||||
| 23 | + | ||||
| 24 | + | ||||
| 25 | +def fig_mrb(R): | ||||
| 26 | + c = R["comparables"] | ||||
| 27 | + fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 3.0), gridspec_kw={"width_ratios": [1, 2]}) | ||||
| 28 | + ax = axes[0] | ||||
| 29 | + data = [c.loc[c["nb_logements"] == n, "mrb"] for n in (2, 3)] | ||||
| 30 | + bp = ax.boxplot(data, widths=0.55, patch_artist=False); _boxstyle(bp, palette.cat[0]) | ||||
| 31 | + ax.set_xticks([1, 2]); ax.set_xticklabels([f"Duplex\n(n = {len(data[0])})", f"Triplex\n(n = {len(data[1])})"]) | ||||
| 32 | + ax.set_ylabel("Multiplicateur du revenu brut (MRB)"); ax.set_title("Selon la taille", loc="left") | ||||
| 33 | + for i, d in enumerate(data, 1): | ||||
| 34 | + ax.text(i + 0.33, d.median(), f"{d.median():.1f}".replace(".", ","), va="center", fontsize=8, color=palette.ink) | ||||
| 35 | + ax = axes[1] | ||||
| 36 | + data = [c.loc[c["quartier"] == q, "mrb"] for q in QUARTIERS] | ||||
| 37 | + bp = ax.boxplot(data, widths=0.55); _boxstyle(bp, palette.cat[1]) | ||||
| 38 | + ax.set_xticks(range(1, 5)); ax.set_xticklabels([f"{q}\n(n = {len(d)})" for q, d in zip(QUARTIERS, data)]) | ||||
| 39 | + ax.set_title("Selon le quartier (2 et 3 logements confondus)", loc="left") | ||||
| 40 | + for i, d in enumerate(data, 1): | ||||
| 41 | + ax.text(i + 0.33, d.median(), f"{d.median():.1f}".replace(".", ","), va="center", fontsize=8, color=palette.ink) | ||||
| 42 | + ymin = min(c["mrb"]) - 1; ymax = max(c["mrb"]) + 1 | ||||
| 43 | + for ax in axes: ax.set_ylim(ymin, ymax); ax.grid(axis="x", visible=False) | ||||
| 44 | + fig.tight_layout(); savefig(fig, "ch32/ch32-fig-mrb-taille-quartier.pdf") | ||||
| 45 | + | ||||
| 46 | + | ||||
| 47 | +def fig_taux_rho(R): | ||||
| 48 | + g = R["decomposition"]; reg = R["regression"]; I = R["indications"] | ||||
| 49 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.3)) | ||||
| 50 | + for n, col, lab in ((2, palette.cat[0], "Duplex"), (3, palette.cat[1], "Triplex")): | ||||
| 51 | + s = g[g["nb_logements"] == n] | ||||
| 52 | + ax.scatter(s["loyer_relatif"], 100 * s["taux_global"], s=16, color=col, alpha=0.75, label=lab, edgecolors="none") | ||||
| 53 | + xs = np.linspace(g["loyer_relatif"].min() - 0.01, 1.01, 50) | ||||
| 54 | + ax.plot(xs, 100 * (reg["a"] + reg["b_rho"] * (xs - 1)), color=palette.ink, linewidth=1.4, label="Régression $R = a + b(\\rho - 1)$") | ||||
| 55 | + ax.axhline(100 * R["indicateurs"]["taux_median"], color=palette.ink2, linestyle=":", linewidth=1) | ||||
| 56 | + ax.text(xs[0], 100 * R["indicateurs"]["taux_median"] + 0.04, "médiane des 127 ventes", fontsize=7.5, color=palette.ink2) | ||||
| 57 | + ax.scatter([I["rho_sujet"]], [100 * I["taux_ajuste"]], marker="*", s=140, color=palette.cat[2], zorder=5, label="Sujet : $\\rho$ = 0,848, taux ajusté", edgecolors=palette.ink, linewidths=0.5) | ||||
| 58 | + ax.set_xlabel("Loyer contractuel moyen / loyer de marché estimé ($\\rho$)"); ax.set_ylabel("Taux global extrait (%)") | ||||
| 59 | + ax.yaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:.1f}".replace(".", ","))) | ||||
| 60 | + ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:.2f}".replace(".", ","))) | ||||
| 61 | + ax.legend(loc="upper left", fontsize=7.5, ncol=2); fig.tight_layout() | ||||
| 62 | + savefig(fig, "ch32/ch32-fig-taux-loyer-relatif.pdf") | ||||
| 63 | + | ||||
| 64 | + | ||||
| 65 | +def fig_prix_logement(R): | ||||
| 66 | + c = R["comparables"]; comp5 = R["comparables5"]; I = R["indications"] | ||||
| 67 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.2)) | ||||
| 68 | + data = [c.loc[c["quartier"] == q, "prix_par_logement"] / 1000 for q in QUARTIERS] | ||||
| 69 | + bp = ax.boxplot(data, widths=0.55); _boxstyle(bp, palette.cat[0]) | ||||
| 70 | + rng = np.random.default_rng(20261031) | ||||
| 71 | + for i, q in enumerate(QUARTIERS, 1): | ||||
| 72 | + s = c[c["quartier"] == q] | ||||
| 73 | + ax.scatter(i + rng.uniform(-0.18, 0.18, len(s)), s["prix_par_logement"] / 1000, s=9, color=palette.cat[0], alpha=0.35, edgecolors="none") | ||||
| 74 | + ax.scatter(np.ones(len(comp5)) + 0.0, comp5["prix_par_logement"] / 1000, marker="D", s=30, color=palette.cat[2], zorder=5, label="Cinq comparables retenus (triplex, Limoilou)", edgecolors=palette.ink, linewidths=0.4) | ||||
| 75 | + ax.axhline(I["prix_log_final"] / 1000, color=palette.ink, linestyle="--", linewidth=1.1) | ||||
| 76 | + ax.text(4.45, I["prix_log_final"] / 1000 + 4, "valeur pondérée\ndu sujet, par logement", fontsize=7.5, ha="right", color=palette.ink) | ||||
| 77 | + ax.set_xticks(range(1, 5)); ax.set_xticklabels([f"{q}\n(n = {len(d)})" for q, d in zip(QUARTIERS, data)]) | ||||
| 78 | + ax.set_ylabel("Prix par logement (milliers de $)"); ax.yaxis.set_major_formatter(FuncFormatter(fmt_milliers)) | ||||
| 79 | + ax.grid(axis="x", visible=False); ax.legend(loc="upper right", fontsize=7.5); fig.tight_layout() | ||||
| 80 | + savefig(fig, "ch32/ch32-fig-prix-logement-quartier.pdf") | ||||
| 81 | + | ||||
| 82 | + | ||||
| 83 | +# Médianes APCIQ (Centris) des plex de 2 à 5 logements, T3 2026 — chiffres cités (communiqué du 14 octobre 2026 et baromètres), sources/notes-E.md | ||||
| 84 | +APCIQ_PLEX_T3_2026 = [ | ||||
| 85 | + ("Province de Québec", 699_000), ("RMR de Montréal", 860_500), ("Île de Montréal", 900_000), ("RMR de Québec", 600_000), | ||||
| 86 | + ("Agglomération de Québec", 618_500), ("Secteur La Cité–Basse-Ville et Limoilou", 647_500), ("RMR de Gatineau", 617_500), | ||||
| 87 | + ("RMR de Sherbrooke", 544_500), ("RMR de Trois-Rivières", 420_000), ("RMR de Saguenay", 373_500)] | ||||
| 88 | + | ||||
| 89 | + | ||||
| 90 | +def fig_apciq(): | ||||
| 91 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.1)) | ||||
| 92 | + labels = [l for l, _ in APCIQ_PLEX_T3_2026][::-1]; vals = [v for _, v in APCIQ_PLEX_T3_2026][::-1] | ||||
| 93 | + cols = [palette.cat[1] if ("Québec" in l and "Province" not in l) or "Limoilou" in l else palette.cat[0] for l in labels] | ||||
| 94 | + ax.barh(labels, vals, color=cols, height=0.65) | ||||
| 95 | + for i, v in enumerate(vals): ax.text(v + 8000, i, fmt_dollars(v), va="center", fontsize=7.5, color=palette.ink) | ||||
| 96 | + ax.set_xlim(0, 1_050_000); ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: fmt_milliers(x / 1000) + " k$")) | ||||
| 97 | + ax.set_xlabel("Prix médian des plex (2 à 5 logements), 3ᵉ trimestre 2026"); ax.grid(axis="y", visible=False); fig.tight_layout() | ||||
| 98 | + savefig(fig, "ch32/ch32-fig-plex-apciq-rmr.pdf") | ||||
| 99 | + | ||||
| 100 | + | ||||
| 101 | +def fig_sensibilite(R): | ||||
| 102 | + S = R["sensibilite"]; base = R["base"]; I = R["indications"]; loyers = R["loyers"] | ||||
| 103 | + fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 3.1)) | ||||
| 104 | + ax = axes[0]; L = S["loyers"] | ||||
| 105 | + ax.plot(L["rho"], L["v_mrb"] / 1000, marker="o", color=palette.cat[0], label="MRB des comparables × RBP") | ||||
| 106 | + ax.plot(L["rho"], L["v_cap_taux_median"] / 1000, marker="s", color=palette.cat[1], label="Capitalisation, taux médian fixe") | ||||
| 107 | + ax.plot(L["rho"], L["v_cap_ajuste"] / 1000, marker="^", color=palette.cat[2], label="Capitalisation, taux ajusté à $\\rho$") | ||||
| 108 | + ax.axvline(I["rho_sujet"], color=palette.ink2, linestyle=":", linewidth=1); ax.text(I["rho_sujet"] + 0.004, L["v_mrb"].max() / 1000, "sujet", fontsize=7.5, color=palette.ink2, va="top") | ||||
| 109 | + ax.set_xlabel("Loyers contractuels / loyers de marché ($\\rho$)"); ax.set_ylabel("Indication de valeur (milliers de $)") | ||||
| 110 | + ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:.2f}".replace(".", ","))); ax.yaxis.set_major_formatter(FuncFormatter(fmt_milliers)) | ||||
| 111 | + ax.legend(fontsize=7, loc="upper left"); ax.set_title("Loyers sous le marché", loc="left") | ||||
| 112 | + ax = axes[1] | ||||
| 113 | + prix = I["valeur_arrondie"]; taux = np.arange(0.0312, 0.0713, 0.0025) | ||||
| 114 | + for mf, col, lab in ((C.mise_de_fonds_minimale_assuree(prix), palette.cat[0], "Mise de fonds minimale assurée (7,1 %)"), (0.20 * prix, palette.cat[1], "Mise de fonds de 20 %")): | ||||
| 115 | + y = [C.cout_occupation(C.financement_occupant(prix, mf, t), base, loyers, "RC")["cout_net_mensuel_hors_capital"] for t in taux] | ||||
| 116 | + ax.plot(100 * taux, y, color=col, label=lab) | ||||
| 117 | + ax.axhline(1430, color=palette.ink2, linestyle="--", linewidth=1); ax.text(100 * taux[0] + 0.05, 1430 + 40, "loyer de marché du logement occupé (1 430 $)", fontsize=7.5, color=palette.ink2) | ||||
| 118 | + ax.axvline(100 * C.TAUX_ASSURE, color=palette.ink2, linestyle=":", linewidth=1); ax.text(100 * C.TAUX_ASSURE + 0.05, 3000, "4,12 %\n(assuré,\naoût 2026)", fontsize=7, color=palette.ink2) | ||||
| 119 | + ax.set_xlabel("Taux hypothécaire (%)"); ax.set_ylabel("Coût net mensuel d'occupation, hors capital ($)") | ||||
| 120 | + ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:.1f}".replace(".", ","))); ax.yaxis.set_major_formatter(FuncFormatter(fmt_milliers)) | ||||
| 121 | + ax.legend(fontsize=7, loc="upper left"); ax.set_title("Financement de l'acheteur-occupant", loc="left") | ||||
| 122 | + fig.tight_layout(); savefig(fig, "ch32/ch32-fig-sensibilite-occupant.pdf") | ||||
| 123 | + | ||||
| 124 | + | ||||
| 125 | +if __name__ == "__main__": | ||||
| 126 | + setup(); R = C.calculer() | ||||
| 127 | + fig_mrb(R); fig_taux_rho(R); fig_prix_logement(R); fig_apciq(); fig_sensibilite(R) | ||||
| 128 | + print("figures ch32 écrites dans", ROOT / "assets/figures/ch32") | ||||
modified
analysis/python/tests/test_ch19.py
+11 −0
@@ -234,3 +234,14 @@ def test_solutions_exercices(): | |||||
| 234 | co = np.array([[1, 0], [0, 2], [-3, 0.0]]); z = np.array([0.08, 0.05, -0.01]); p = {"pepite": 0.0045, "palier_partiel": 0.009, "portee": 5.0} | 234 | co = np.array([[1, 0], [0, 2], [-3, 0.0]]); z = np.array([0.08, 0.05, -0.01]); p = {"pepite": 0.0045, "palier_partiel": 0.009, "portee": 5.0} | ||
| 235 | k = h.krigeage_ordinaire(co, z, np.array([[0, 0.0]]), p, None) | 235 | k = h.krigeage_ordinaire(co, z, np.array([[0, 0.0]]), p, None) | ||
| 236 | assert k["prediction"][0] == pytest.approx(0.0489, abs=0.0001) and k["variance"][0] == pytest.approx(0.00869, abs=0.00001) | 236 | assert k["prediction"][0] == pytest.approx(0.0489, abs=0.0001) and k["variance"][0] == pytest.approx(0.00869, abs=0.00001) | ||
| 237 | + # ex. 19.1 (suite) : signes alternés → I = −1 exactement sur cette configuration | ||||
| 238 | + e2 = np.array([0.10, -0.10, 0.10, -0.10, 0.10]); assert h.moran_i(e2, W)["I"] == pytest.approx(-1.0, abs=1e-9) | ||||
| 239 | + # ex. 19.6 : poids du krigeage vs inverse de la distance | ||||
| 240 | + idw = (1 / np.array([1, 2, 3.0])); idw /= idw.sum(); assert idw[2] == pytest.approx(0.182, abs=0.001) and (idw * z).sum() == pytest.approx(0.0555, abs=0.0001) | ||||
| 241 | + | ||||
| 242 | +def test_solution_exercice_7(res): | ||||
| 243 | + r = c19.exercice_7(res["base"]); s = r["sem_par_k"].set_index("k") | ||||
| 244 | + assert s.loc[4, "lambda"] == pytest.approx(0.5066, abs=0.0005) and s.loc[16, "lambda"] == pytest.approx(0.7855, abs=0.0005) | ||||
| 245 | + assert s.loc[4, "u_sujet"] == pytest.approx(-0.0189, abs=0.0005) and s.loc[16, "u_sujet"] == pytest.approx(-0.0554, abs=0.0005) | ||||
| 246 | + assert list(s["indication"].round(0)) == [656724, 633352, 628672] | ||||
| 247 | + assert r["st_24"]["sans_voisin"] == 16 and r["st_24"]["rho_st"] == pytest.approx(0.4149, abs=0.0005) and r["st_12"]["rho_st"] == pytest.approx(0.3696, abs=0.0005) | ||||
added
analysis/python/tests/test_ch24.py
+211 −0
@@ -0,0 +1,211 @@ | |||||
| 1 | +"""Tests du chapitre 24 — dépréciation et désuétudes du sujet du cas 1, extraction de marché, profils et exercices. | ||||
| 2 | +Tolérances : 0,01 $ sur les montants recalculés ; 0,5 $ sur les montants arrondis cités ; 5e-4 sur les taux cités en % à deux | ||||
| 3 | +décimales ; 1e-9 sur les identités.""" | ||||
| 4 | +import sys, pathlib | ||||
| 5 | +import numpy as np, pytest | ||||
| 6 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) | ||||
| 7 | +import ch24_depreciation as cas | ||||
| 8 | +from traite import cout as ct | ||||
| 9 | + | ||||
| 10 | + | ||||
| 11 | +@pytest.fixture(scope="module") | ||||
| 12 | +def data(): | ||||
| 13 | + d = cas.charger() | ||||
| 14 | + var = cas.variantes_physique(d["cn"], d["fiche"], d["travaux"]) | ||||
| 15 | + se = cas.carence_salle_eau(d["devis"], d["cn"]) | ||||
| 16 | + w, vl = cas.extraction(d["ventes"], d["modele"], d["cn"]["total"]) | ||||
| 17 | + aj = cas.ajuster_profil(w); vlr = cas.valeur_terrain_retenue(vl) | ||||
| 18 | + pr = cas.profils(d["modele"], d["sujet"], vlr["retenue"], aj["quadratique"]) | ||||
| 19 | + vs = cas.valeur_sujet(d["cn"], vlr["retenue"], aj["quadratique"], se["desuetude"], var) | ||||
| 20 | + return {**d, "var": var, "se": se, "w": w, "vl": vl, "aj": aj, "vlr": vlr, "pr": pr, "vs": vs, "ages": cas.ages_sujet(d["fiche"])} | ||||
| 21 | + | ||||
| 22 | + | ||||
| 23 | +class TestAges: | ||||
| 24 | + def test_ages_cites(self, data): | ||||
| 25 | + a = data["ages"] | ||||
| 26 | + assert a["chronologique"] == 28 and a["effectif_structure"] == pytest.approx(29.0, abs=1e-9) | ||||
| 27 | + assert a["vie_restante"] == pytest.approx(31.0, abs=1e-9) and a["vie_economique"] == 60 | ||||
| 28 | + assert a["indice_etat"] == pytest.approx(2.809, abs=5e-4) and a["age_effectif_pondere"] == pytest.approx(32.8, abs=0.05) | ||||
| 29 | + | ||||
| 30 | + def test_convention_age_effectif(self): | ||||
| 31 | + # a_eff = (1 − λ) a + λ φ(c) L : structure 28 ans, état 3 (φ = 0,5), L = 60 → 0,5·28 + 0,5·30 = 29 | ||||
| 32 | + import ch11_inspection as c11 | ||||
| 33 | + assert c11.age_effectif(28, 60, 3, 0.5) == 29.0 and c11.age_effectif(28, 60, 1, 0.5) == 15.5 and c11.age_effectif(28, 60, 5, 0.5) == 44.0 | ||||
| 34 | + assert ct.vie_restante(60, 29) == 31.0 and ct.vie_restante(60, 75) == 0.0 | ||||
| 35 | + | ||||
| 36 | + | ||||
| 37 | +class TestPhysique: | ||||
| 38 | + def test_quatre_lectures(self, data): | ||||
| 39 | + v = data["var"]; cn = data["cn"]["total"] | ||||
| 40 | + assert cn == pytest.approx(397567.33, abs=0.01) | ||||
| 41 | + assert v["age_vie_chrono"] == pytest.approx(185531.42, abs=0.01) and v["age_vie_chrono"] / cn == pytest.approx(0.4667, abs=5e-4) | ||||
| 42 | + assert v["age_vie_effectif"] == pytest.approx(192157.55, abs=0.01) | ||||
| 43 | + assert v["modifiee"]["curable"] == pytest.approx(10479.98, abs=0.01) and v["modifiee"]["incurable"] == pytest.approx(187092.22, abs=0.01) | ||||
| 44 | + assert v["modifiee"]["total"] == pytest.approx(197572.20, abs=0.01) and v["modifiee"]["total"] / cn == pytest.approx(0.4970, abs=5e-4) | ||||
| 45 | + ve = v["ventilee"] | ||||
| 46 | + assert sum(ve["courte_duree"].values()) == pytest.approx(130584.55, abs=0.01) and ve["longue_duree"] == pytest.approx(80025.35, abs=0.01) | ||||
| 47 | + assert ve["base_longue"] == pytest.approx(165569.69, abs=0.01) and ve["total"] == pytest.approx(221089.88, abs=0.01) | ||||
| 48 | + assert ve["taux"] == pytest.approx(0.5561, abs=5e-4) and len(ve["courte_duree"]) == 18 | ||||
| 49 | + | ||||
| 50 | + def test_identites_ventilee(self, data): | ||||
| 51 | + v = data["var"]; ve = v["ventilee"]; f = data["fiche"]; cn = data["cn"]["total"] | ||||
| 52 | + courtes = f[(~f["longue_duree"]) & (f["composante"] != "Entrée asphaltée")] | ||||
| 53 | + assert courtes["cout_neuf"].sum() == pytest.approx(221517.66, abs=0.01) | ||||
| 54 | + # base longue = C_N − curable − Σ coût neuf courte durée | ||||
| 55 | + assert ve["base_longue"] == pytest.approx(cn - ve["curable"] - courtes["cout_neuf"].sum(), abs=1e-6) | ||||
| 56 | + assert ve["total"] == pytest.approx(ve["curable"] + sum(ve["courte_duree"].values()) + ve["longue_duree"], abs=1e-9) | ||||
| 57 | + # proposition : D_vent − D_simple = Cur (1 − r_S) + Σ C_k (r_k − r_S) | ||||
| 58 | + rS = 29.0 / 60.0 | ||||
| 59 | + ecart = ve["curable"] * (1 - rS) + sum(ve["courte_duree"].values()) - courtes["cout_neuf"].sum() * rS | ||||
| 60 | + assert ve["total"] - v["age_vie_effectif"] == pytest.approx(ecart, abs=1e-6) and ecart == pytest.approx(28932.33, abs=0.01) | ||||
| 61 | + # chaque composante : dépréciation = coût neuf × min(a_eff / L, 1) | ||||
| 62 | + for r in courtes.itertuples(): | ||||
| 63 | + assert ve["courte_duree"][r.composante] == pytest.approx(r.cout_neuf * min(r.age_effectif / r.vie_utile, 1.0), abs=1e-6) | ||||
| 64 | + assert ve["courte_duree"]["Ventilateur récupérateur de chaleur (VRC)"] == pytest.approx(7501.27, abs=0.01) | ||||
| 65 | + assert ve["courte_duree"]["Revêtement extérieur (brique et fibrociment)"] == pytest.approx(18089.57, abs=0.01) | ||||
| 66 | + | ||||
| 67 | + | ||||
| 68 | +class TestDesuetudes: | ||||
| 69 | + def test_salle_eau(self, data): | ||||
| 70 | + se = data["se"] | ||||
| 71 | + assert se["cout_si_neuf_direct"] == pytest.approx(6309.76, abs=0.01) and se["cout_si_neuf"] == pytest.approx(8394.69, abs=0.01) | ||||
| 72 | + assert se["desuetude"] == pytest.approx(7605.31, abs=0.01) and se["desuetude"] == pytest.approx(16000 - se["cout_si_neuf"], abs=1e-9) | ||||
| 73 | + | ||||
| 74 | + def test_exemples(self): | ||||
| 75 | + ex = cas.exemples_incurables() | ||||
| 76 | + assert ex["mrb_annuel"] == pytest.approx(19.82, abs=1e-9) and ex["perte_loyer_capitalisee"] == pytest.approx(17838.00, abs=0.01) | ||||
| 77 | + assert ex["perte_rne_capitalisee_totale"] == pytest.approx(133333.33, abs=0.01) and ex["perte_rne_batiment"] == pytest.approx(93333.33, abs=0.01) | ||||
| 78 | + assert ex["paires"] == pytest.approx(18000.0, abs=1e-9) | ||||
| 79 | + | ||||
| 80 | + | ||||
| 81 | +class TestExtraction: | ||||
| 82 | + def test_valeur_terrain(self, data): | ||||
| 83 | + vl, vlr = data["vl"], data["vlr"] | ||||
| 84 | + assert int(vl.loc["Hull", "n"]) == 13 and vl.loc["Hull", "vl_mediane"] == pytest.approx(276953.99, abs=0.01) | ||||
| 85 | + assert vl.loc["Hull", "part_mediane"] == pytest.approx(0.505, abs=5e-4) and vlr["retenue"] == 277000.0 | ||||
| 86 | + assert vlr["retenue"] / 620 == pytest.approx(447, abs=0.5) | ||||
| 87 | + assert vl.loc["Aylmer", "vl_mediane"] == pytest.approx(321579, abs=1) and vl.loc["Gatineau", "vl_mediane"] == pytest.approx(270089, abs=1) | ||||
| 88 | + assert vl.loc["Buckingham", "vl_mediane"] == pytest.approx(182678, abs=1) and vl.loc["Masson-Angers", "vl_mediane"] == pytest.approx(175657, abs=1) | ||||
| 89 | + assert int(vl["n"].sum()) == 33 | ||||
| 90 | + | ||||
| 91 | + def test_identite_extraction(self, data): | ||||
| 92 | + w = data["w"] | ||||
| 93 | + assert len(w) == 600 | ||||
| 94 | + assert np.allclose(w["D"], w["cn"] - (w["p_adj"] - w["vl"]), atol=1e-6) and np.allclose(w["taux"], w["D"] / w["cn"], atol=1e-12) | ||||
| 95 | + assert data["modele"]["croissance_mensuelle"] == pytest.approx(0.00364, abs=5e-6) | ||||
| 96 | + | ||||
| 97 | + def test_tranches_et_ajustements(self, data): | ||||
| 98 | + tr = cas.tranches(data["w"]) | ||||
| 99 | + assert list(tr["size"]) == [33, 47, 76, 115, 124, 101, 104] | ||||
| 100 | + assert tr.loc["0-10", "median"] == pytest.approx(0.038, abs=5e-4) and tr.loc["21-30", "median"] == pytest.approx(0.192, abs=5e-4) | ||||
| 101 | + assert tr.loc["61-100", "median"] == pytest.approx(0.403, abs=5e-4) | ||||
| 102 | + aj = data["aj"]; q = aj["quadratique"]["beta"]; l = aj["lineaire"]["beta"] | ||||
| 103 | + assert l[0] == pytest.approx(0.0878, abs=5e-5) and l[1] == pytest.approx(0.00447, abs=5e-6) and aj["lineaire"]["r2"] == pytest.approx(0.228, abs=5e-4) | ||||
| 104 | + assert q[0] == pytest.approx(0.0107, abs=5e-5) and q[1] == pytest.approx(0.00891, abs=5e-6) and q[2] == pytest.approx(-0.0000514, abs=5e-8) | ||||
| 105 | + assert aj["quadratique"]["r2"] == pytest.approx(0.250, abs=5e-4) | ||||
| 106 | + assert aj["taux_annuel_origine"] == pytest.approx(0.00619, abs=5e-6) and aj["vie_implicite"] == pytest.approx(162, abs=0.5) | ||||
| 107 | + assert float(cas.taux_quadratique(aj["quadratique"], 28)) == pytest.approx(0.2200, abs=5e-5) | ||||
| 108 | + assert float(cas.taux_quadratique(aj["quadratique"], 60)) == pytest.approx(0.3605, abs=5e-5) | ||||
| 109 | + | ||||
| 110 | + | ||||
| 111 | +class TestProfils: | ||||
| 112 | + def test_profils_cites(self, data): | ||||
| 113 | + pr = data["pr"] | ||||
| 114 | + assert pr["prix_neuf"] == pytest.approx(720727, abs=1) and pr["alpha"] == pytest.approx(0.384, abs=5e-4) | ||||
| 115 | + assert pr["hedonique_total"][28] == pytest.approx(0.1148, abs=5e-5) and pr["generateur_total"][28] == pytest.approx(0.1223, abs=5e-5) | ||||
| 116 | + assert pr["hedonique_bat"][28] == pytest.approx(0.1865, abs=5e-4) and pr["generateur_bat"][28] == pytest.approx(0.1986, abs=5e-4) | ||||
| 117 | + assert pr["age_vie"][28] == pytest.approx(28 / 60, abs=1e-12) and pr["age_vie"][60] == 1.0 | ||||
| 118 | + # conversion : d_B (1 − α) = d_total | ||||
| 119 | + assert np.allclose(pr["hedonique_bat"] * (1 - pr["alpha"]), pr["hedonique_total"]) | ||||
| 120 | + # profils croissants sur 0-80 ans | ||||
| 121 | + assert np.all(np.diff(pr["extraction"]) > 0) and np.all(np.diff(pr["hedonique_bat"]) > 0) | ||||
| 122 | + | ||||
| 123 | + def test_generateur(self): | ||||
| 124 | + assert cas.profil_total_generateur(28) == pytest.approx(1 - np.exp(-0.0055 * 28 + 0.00003 * 784), abs=1e-12) | ||||
| 125 | + | ||||
| 126 | + | ||||
| 127 | +class TestValeur: | ||||
| 128 | + def test_deux_lectures(self, data): | ||||
| 129 | + vs = data["vs"]; A, B = vs["A_ventilee"], vs["B_extraction"] | ||||
| 130 | + assert vs["d_extraite"] == pytest.approx(87461.05, abs=0.01) | ||||
| 131 | + assert A["valeur"] == pytest.approx(445872.14, abs=0.01) and A["taux_depreciation"] == pytest.approx(0.5752, abs=5e-4) | ||||
| 132 | + assert B["valeur"] == pytest.approx(579500.97, abs=0.01) and B["depreciation_totale"] == pytest.approx(95066.36, abs=0.01) | ||||
| 133 | + assert B["taux_depreciation"] == pytest.approx(0.2391, abs=5e-4) and B["contribution_batiment"] == pytest.approx(302500.97, abs=0.01) | ||||
| 134 | + assert vs["ecart"] == pytest.approx(133628.83, abs=0.01) and vs["valeur_arrondie"] == 580000.0 | ||||
| 135 | + # identité V = V_L + C_N − D_phys − D_fonc − D_ext | ||||
| 136 | + assert B["valeur"] == pytest.approx(277000 + 397567.33 - 87461.05 - 7605.31, abs=0.02) | ||||
| 137 | + | ||||
| 138 | + def test_ordre_externe(self, data): | ||||
| 139 | + cn = data["cn"]["total"]; vs = data["vs"] | ||||
| 140 | + # une désuétude externe de 10 % appliquée sur C_N plutôt que sur la base restante surestime la déduction | ||||
| 141 | + avant = 0.10 * cn; apres = 0.10 * vs["B_extraction"]["contribution_batiment"] | ||||
| 142 | + assert avant == pytest.approx(39756.73, abs=0.01) and apres == pytest.approx(30250.10, abs=0.01) and avant - apres == pytest.approx(9506.64, abs=0.01) | ||||
| 143 | + | ||||
| 144 | + | ||||
| 145 | +class TestSensibilite: | ||||
| 146 | + def test_grille(self, data): | ||||
| 147 | + g = cas.grille_age_vie(data["cn"], data["var"]["curable"]) | ||||
| 148 | + assert g.loc[60, 29] == pytest.approx(0.4970, abs=5e-4) and g.loc[50, 37] == pytest.approx(0.747, abs=5e-4) and g.loc[100, 20] == pytest.approx(0.221, abs=5e-4) | ||||
| 149 | + assert g.loc[80, 29] == pytest.approx(0.379, abs=5e-4) | ||||
| 150 | + | ||||
| 151 | + def test_extraction_variantes(self, data): | ||||
| 152 | + sx = cas.sensibilite_extraction(data["ventes"], data["modele"], data["cn"]["total"]).set_index("variante") | ||||
| 153 | + assert sx.loc["cas de base", "taux_28"] == pytest.approx(0.2200, abs=5e-5) | ||||
| 154 | + assert sx.loc["ventes récentes ≤ 5 ans", "taux_28"] == pytest.approx(0.1968, abs=5e-5) and sx.loc["ventes récentes ≤ 15 ans", "taux_28"] == pytest.approx(0.2720, abs=5e-5) | ||||
| 155 | + assert sx.loc["ventes récentes ≤ 15 ans", "vl_hull"] == pytest.approx(303775, abs=1) | ||||
| 156 | + assert sx["taux_28"].min() == pytest.approx(0.1968, abs=5e-5) and sx["taux_28"].max() == pytest.approx(0.2720, abs=5e-5) | ||||
| 157 | + | ||||
| 158 | + def test_tornade(self, data): | ||||
| 159 | + sx = cas.sensibilite_extraction(data["ventes"], data["modele"], data["cn"]["total"]) | ||||
| 160 | + t = {o["param"]: o for o in cas.tornade(data["cn"], data["vlr"]["retenue"], data["vs"]["d_extraite"], data["se"]["desuetude"], data["var"], sx)} | ||||
| 161 | + assert t["Valeur du terrain ±15 %"]["bas"] == pytest.approx(537951, abs=1) and t["Valeur du terrain ±15 %"]["haut"] == pytest.approx(621051, abs=1) | ||||
| 162 | + assert t["Choix d'extraction (taux à 28 ans)"]["bas"] == pytest.approx(558807, abs=1) and t["Choix d'extraction (taux à 28 ans)"]["haut"] == pytest.approx(588716, abs=1) | ||||
| 163 | + assert t["Coût neuf ±8,6 % (date des prix)"]["bas"] == pytest.approx(554944, abs=1) and t["Coût neuf ±8,6 % (date des prix)"]["haut"] == pytest.approx(606170, abs=1) | ||||
| 164 | + assert t["Lecture ventilée (ch. 11) au lieu de l'extraction"]["bas"] == pytest.approx(445872, abs=1) | ||||
| 165 | + | ||||
| 166 | + | ||||
| 167 | +class TestExercices: | ||||
| 168 | + def test_ex1(self): | ||||
| 169 | + assert ct.depreciation_age_vie(320000, 15, 60) == 80000 and ct.vie_restante(60, 15) == 45 | ||||
| 170 | + m = ct.depreciation_age_vie_modifiee(320000, 9000, 15, 60) | ||||
| 171 | + assert m["incurable"] == 77750 and m["total"] == 86750 and m["total"] / 320000 == pytest.approx(0.2711, abs=5e-5) | ||||
| 172 | + assert ct.depreciation_age_vie_modifiee(320000, 9000, 20, 60)["total"] == pytest.approx(112666.67, abs=0.01) | ||||
| 173 | + | ||||
| 174 | + def test_ex2(self): | ||||
| 175 | + comps = [ct.Composante("toiture", 9000, 12, 20), ct.Composante("fenêtres", 18000, 10, 30), ct.Composante("chauffe-eau", 1500, 8, 12)] | ||||
| 176 | + v = ct.depreciation_ventilee(320000, 9000, comps, 15, 60) | ||||
| 177 | + assert v["courte_duree"] == {"toiture": 5400, "fenêtres": 6000, "chauffe-eau": 1000} and v["base_longue"] == 282500 | ||||
| 178 | + assert v["longue_duree"] == 70625 and v["total"] == 92025 and v["total"] / 320000 == pytest.approx(0.2876, abs=5e-5) | ||||
| 179 | + assert v["total"] - 86750 == 5275 | ||||
| 180 | + | ||||
| 181 | + def test_ex3(self): | ||||
| 182 | + assert ct.desuetude_fonctionnelle_curable(16000, 8394.69, 0) == pytest.approx(7605.31, abs=1e-9) | ||||
| 183 | + assert 22000 * 0.6 + 3000 == 16200 # base de reproduction : solde non déprécié + coût d'enlèvement | ||||
| 184 | + | ||||
| 185 | + def test_ex4(self): | ||||
| 186 | + assert ct.desuetude_incurable_par_loyer(75, 19.82 * 12) == pytest.approx(17838, abs=1e-9) | ||||
| 187 | + assert ct.desuetude_incurable_par_rne(6000, 0.045, 0.7) == pytest.approx(93333.33, abs=0.01) | ||||
| 188 | + assert ct.desuetude_incurable_par_rne(6000, 0.055, 0.7) == pytest.approx(76363.64, abs=0.01) | ||||
| 189 | + | ||||
| 190 | + def test_ex5(self): | ||||
| 191 | + ventes = [(610000, 260000, 420000, 12), (545000, 250000, 390000, 25), (480000, 240000, 380000, 41), (520000, 255000, 410000, 33)] | ||||
| 192 | + e = [ct.extraction_depreciation_marche(p, l, c, a) for p, l, c, a in ventes] | ||||
| 193 | + assert [x["depreciation"] for x in e] == [70000, 95000, 140000, 145000] | ||||
| 194 | + ta = [x["taux_annuel"] for x in e] | ||||
| 195 | + assert ta[0] == pytest.approx(0.01389, abs=5e-6) and ta[2] == pytest.approx(0.00899, abs=5e-6) | ||||
| 196 | + m = float(np.mean(ta)) | ||||
| 197 | + assert m == pytest.approx(0.01083, abs=5e-6) and 1 / m == pytest.approx(92.3, abs=0.05) and 30 * m * 400000 == pytest.approx(130006, abs=1) | ||||
| 198 | + m2 = float(np.mean([ct.extraction_depreciation_marche(p, l + 20000, c, a)["taux_annuel"] for p, l, c, a in ventes])) | ||||
| 199 | + assert m2 == pytest.approx(0.01303, abs=5e-6) and 30 * m2 * 400000 == pytest.approx(156350, abs=1) | ||||
| 200 | + | ||||
| 201 | + def test_ex6(self): | ||||
| 202 | + b1, b2, al = -0.005097, 0.00002642, 0.384 | ||||
| 203 | + t28 = 1 - np.exp(b1 * 28 + b2 * 784) | ||||
| 204 | + assert t28 == pytest.approx(0.1149, abs=5e-5) and t28 / (1 - al) == pytest.approx(0.1865, abs=5e-5) | ||||
| 205 | + assert (1 - np.exp(b1 * 50 + b2 * 2500)) / (1 - al) == pytest.approx(0.2793, abs=5e-5) | ||||
| 206 | + assert -(b1 + 2 * b2 * 28) == pytest.approx(0.003617, abs=5e-7) and -b1 / (2 * b2) == pytest.approx(96.5, abs=0.05) | ||||
| 207 | + assert 1 - t28 / (28 / 60) == pytest.approx(0.754, abs=5e-4) | ||||
| 208 | + | ||||
| 209 | + def test_ex7(self): | ||||
| 210 | + assert 6130.62 + 15989.85 == pytest.approx(22120.47, abs=0.01) | ||||
| 211 | + assert 0.10 * 397567.33 - 0.10 * 302500.97 == pytest.approx(9506.64, abs=0.01) | ||||
added
analysis/python/tests/test_ch27.py
+183 −0
@@ -0,0 +1,183 @@ | |||||
| 1 | +"""Tests du chapitre 27 — revenus potentiels, vacance et pertes (cas 3 et cas 2, données synthétiques ; tableaux SCHL réels). | ||||
| 2 | +Identités (RBE, cascade, modèle frictionnel, concessions, trajectoires) + valeurs citées dans manuscript/chapters/ch27.tex et | ||||
| 3 | +solutions/ch27.tex (tolérances déclarées : 0,01 $ sur les montants, 1e-6 sur les taux, 0,05 point sur les lectures SCHL).""" | ||||
| 4 | +import sys, pathlib | ||||
| 5 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | ||||
| 6 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) | ||||
| 7 | +import numpy as np, pytest | ||||
| 8 | +from traite import cas2, cas3, finance as fin | ||||
| 9 | +import ch27_revenus as c | ||||
| 10 | + | ||||
| 11 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | ||||
| 12 | + | ||||
| 13 | + | ||||
| 14 | +@pytest.fixture(scope="module") | ||||
| 15 | +def res(): | ||||
| 16 | + return c.calculer() | ||||
| 17 | + | ||||
| 18 | + | ||||
| 19 | +# ----------------------------------------------------------------------------- bibliothèque : fonctions ajoutées pour le chapitre | ||||
| 20 | +def test_vacance_frictionnelle_identites(): | ||||
| 21 | + assert fin.taux_vacance_frictionnelle(0.15, 1.0) == pytest.approx(0.0125) | ||||
| 22 | + assert fin.taux_vacance_frictionnelle(0.094, 12.0) == pytest.approx(0.094) # une année entière de vacance = la rotation | ||||
| 23 | + assert fin.taux_vacance_frictionnelle(0.0, 3.0) == 0.0 and fin.taux_vacance_frictionnelle(0.2, 0.0) == 0.0 | ||||
| 24 | + # linéarité : doubler la durée ou la rotation double la provision | ||||
| 25 | + assert fin.taux_vacance_frictionnelle(0.1, 2.0) == pytest.approx(2 * fin.taux_vacance_frictionnelle(0.1, 1.0)) == pytest.approx(2 * fin.taux_vacance_frictionnelle(0.05, 2.0)) | ||||
| 26 | + with pytest.raises(ValueError): | ||||
| 27 | + fin.taux_vacance_frictionnelle(-0.1, 1.0) | ||||
| 28 | + | ||||
| 29 | + | ||||
| 30 | +def test_loyer_effectif_concession(): | ||||
| 31 | + assert fin.loyer_effectif_concession(1300, 1, 12) == pytest.approx(1300 * 11 / 12) | ||||
| 32 | + assert fin.loyer_effectif_concession(1300, 0, 12) == 1300 and fin.loyer_effectif_concession(1300, 12, 12) == 0 | ||||
| 33 | + # un mois sur douze ≡ deux mois sur vingt-quatre | ||||
| 34 | + assert fin.loyer_effectif_concession(1300, 1, 12) == pytest.approx(fin.loyer_effectif_concession(1300, 2, 24)) | ||||
| 35 | + with pytest.raises(ValueError): | ||||
| 36 | + fin.loyer_effectif_concession(1300, 13, 12) | ||||
| 37 | + | ||||
| 38 | + | ||||
| 39 | +# ----------------------------------------------------------------------------- cas 3 : identités et valeurs citées | ||||
| 40 | +def test_cascade_cas3_identites(res): | ||||
| 41 | + r = res["cas3"] | ||||
| 42 | + assert r["rbp_contrat"] == pytest.approx(12 * r["etat"]["loyer_mensuel_contrat"].sum()) | ||||
| 43 | + assert r["rbp_marche"] == pytest.approx(12 * r["etat"]["loyer_mensuel_marche"].sum()) | ||||
| 44 | + assert r["rbe"] == pytest.approx(fin.revenu_brut_effectif(r["rbp_contrat"], r["taux_vacance_pertes"], r["autres_revenus"])) | ||||
| 45 | + assert r["rbe"] == pytest.approx(r["loyers_effectifs"] + r["autres_revenus"]) | ||||
| 46 | + assert r["loyers_effectifs"] == pytest.approx(r["rbp_contrat"] - r["vacance_pertes"]) | ||||
| 47 | + assert r["rbp_marche"] - r["deficit_loyer"] == pytest.approx(r["rbp_contrat"]) | ||||
| 48 | + assert r["taux_occupation_economique"] == pytest.approx((1 - r["taux_vacance_pertes"]) * r["rbp_contrat_sur_marche"]) | ||||
| 49 | + # cohérence avec le module partagé (ch. 29, 30, 47) | ||||
| 50 | + t = cas3.tout() | ||||
| 51 | + assert r["rbe"] == pytest.approx(t["base"]["rbe"], rel=1e-12) and r["rbp_contrat"] == pytest.approx(t["base"]["rbp_contrat"], rel=1e-12) | ||||
| 52 | + | ||||
| 53 | + | ||||
| 54 | +def test_valeurs_citees_cas3(res): | ||||
| 55 | + r = res["cas3"] | ||||
| 56 | + assert r["loyer_mensuel_contrat"] == 13795 and r["loyer_mensuel_marche"] == 15503 | ||||
| 57 | + assert r["rbp_contrat"] == pytest.approx(165_540, abs=0.01) and r["rbp_marche"] == pytest.approx(186_036, abs=0.01) | ||||
| 58 | + assert r["deficit_loyer"] == pytest.approx(20_496, abs=0.01) and r["deficit_pct"] == pytest.approx(0.1102, abs=5e-5) | ||||
| 59 | + assert r["vacance_pertes"] == pytest.approx(6_456.06, abs=0.01) and r["loyers_effectifs"] == pytest.approx(159_083.94, abs=0.01) | ||||
| 60 | + assert r["autres_revenus"] == pytest.approx(9_360, abs=0.01) and r["autres_par_logement_mois"] == pytest.approx(65, abs=0.01) | ||||
| 61 | + assert r["rbe"] == pytest.approx(168_443.94, abs=0.01) and r["rbe_sur_rbp"] == pytest.approx(1.0175, abs=5e-5) | ||||
| 62 | + assert r["rbe_par_logement"] == pytest.approx(14_037.00, abs=0.01) and r["rbe_par_m2"] == pytest.approx(204.17, abs=0.01) | ||||
| 63 | + assert r["taux_occupation_economique"] == pytest.approx(0.8551, abs=5e-5) | ||||
| 64 | + assert r["rbe_marche"] == pytest.approx(188_140.60, abs=0.01) | ||||
| 65 | + assert r["rbe_variante_v_sur_tout"] == pytest.approx(168_078.90, abs=0.01) and r["ecart_variante_v_sur_tout"] == pytest.approx(-365.04, abs=0.01) | ||||
| 66 | + assert r["v_frictionnel_dossier"] == pytest.approx(0.0125, abs=1e-9) and r["v_residuel_dossier"] == pytest.approx(0.0265, abs=1e-9) | ||||
| 67 | + | ||||
| 68 | + | ||||
| 69 | +def test_par_type_cas3(res): | ||||
| 70 | + p = res["par_type"] | ||||
| 71 | + assert p.loc["3½", "n"] == 4 and p.loc["4½", "n"] == 6 and p.loc["5½", "n"] == 2 | ||||
| 72 | + assert p.loc["3½", "ecart_pct"] == pytest.approx(15.76, abs=0.01) and p.loc["4½", "ecart_pct"] == pytest.approx(11.07, abs=0.01) | ||||
| 73 | + assert p.loc["5½", "ecart_pct"] == pytest.approx(4.93, abs=0.01) and p.loc["Total", "ecart_pct"] == pytest.approx(11.02, abs=0.01) | ||||
| 74 | + assert p.loc["3½", "anciennete"] == pytest.approx(9.0) and p.loc["Total", "anciennete"] == pytest.approx(6.33, abs=0.01) | ||||
| 75 | + assert p.loc["Total", "rbp_contrat"] == pytest.approx(165_540) and p.loc["Total", "rbp_marche"] == pytest.approx(186_036) | ||||
| 76 | + | ||||
| 77 | + | ||||
| 78 | +# ----------------------------------------------------------------------------- cas 2 | ||||
| 79 | +def test_cascade_cas2(res): | ||||
| 80 | + r = res["cas2"] | ||||
| 81 | + t = cas2.tout() | ||||
| 82 | + assert r["rbe"] == pytest.approx(t["base"]["rbe"], rel=1e-12) | ||||
| 83 | + assert r["rbp_contrat"] == pytest.approx(41_832) and r["rbp_marche"] == pytest.approx(49_332) and r["ecart_marche"] == pytest.approx(7_500) | ||||
| 84 | + assert r["deficit_pct"] == pytest.approx(0.1520, abs=5e-5) and r["vacance_pertes"] == pytest.approx(1_254.96, abs=0.01) | ||||
| 85 | + assert r["rbe"] == pytest.approx(40_577.04, abs=0.01) and r["rbe_marche"] == pytest.approx(47_852.04, abs=0.01) | ||||
| 86 | + assert r["taux_occupation_economique"] == pytest.approx(0.8225, abs=5e-5) and r["rbe_variante_occupant"] == pytest.approx(40_953.96, abs=0.01) | ||||
| 87 | + assert r["rbe_par_logement"] == pytest.approx(13_525.68, abs=0.01) | ||||
| 88 | + | ||||
| 89 | + | ||||
| 90 | +# ----------------------------------------------------------------------------- SCHL (réel) : lectures des tableaux | ||||
| 91 | +def test_schl_lectures(res): | ||||
| 92 | + s = res["schl_rmr"] | ||||
| 93 | + attendu = {"Montréal": (2.1, 2.9, 11.1, 9.4), "Québec": (0.9, 2.4, 13.9, 13.0), "Gatineau (partie QC)": (1.9, 3.8, 17.3, 16.2), | ||||
| 94 | + "Sherbrooke": (1.4, 2.7, 10.4, 12.0), "Trois-Rivières": (0.9, 2.7, 10.2, 10.1), "Saguenay": (1.6, 1.3, 13.3, 12.6)} | ||||
| 95 | + for k, (i24, i25, r24, r25) in attendu.items(): | ||||
| 96 | + assert (s.loc[k, "inocc_2024"], s.loc[k, "inocc_2025"], s.loc[k, "rotation_2024"], s.loc[k, "rotation_2025"]) == pytest.approx((i24, i25, r24, r25), abs=0.05) | ||||
| 97 | + t = res["schl_taille"].set_index(["zone", "taille"]) | ||||
| 98 | + assert t.loc[("Île de Montréal", "6 à 19 unités"), "inocc_2025"] == pytest.approx(2.2) and t.loc[("Île de Montréal", "3 à 5 unités"), "inocc_2025"] == pytest.approx(2.3) | ||||
| 99 | + assert t.loc[("Île de Montréal", "100 unités et +"), "inocc_2025"] == pytest.approx(5.1) and t.loc[("Île de Montréal", "Tous les immeubles"), "inocc_2025"] == pytest.approx(3.1) | ||||
| 100 | + assert t.loc[("RMR de Montréal", "6 à 19 unités"), "rotation_2025"] == pytest.approx(8.5) and t.loc[("RMR de Montréal", "100 unités et +"), "rotation_2025"] == pytest.approx(14.2) | ||||
| 101 | + assert t.loc[("RMR de Montréal", "Tous les immeubles"), "inocc_2025"] == pytest.approx(2.9) | ||||
| 102 | + q = res["schl_quartiles"] | ||||
| 103 | + assert (q.loc["Q1", "inocc_2025"], q.loc["Q4", "inocc_2025"]) == pytest.approx((1.3, 4.9)) | ||||
| 104 | + io = res["schl_inocc_occ"] | ||||
| 105 | + assert (io.loc["Tous", "inoccupes"], io.loc["Tous", "occupes"]) == (1573, 1275) and io.loc["Tous", "ecart_pct"] == pytest.approx(23.4, abs=0.05) | ||||
| 106 | + assert (io.loc["2 chambres", "inoccupes"], io.loc["2 chambres", "occupes"]) == (1815, 1349) | ||||
| 107 | + a = res["schl_annee"] | ||||
| 108 | + assert a.loc["2015 ou après", "inocc_2025"] == pytest.approx(5.6) and a.loc["Avant 1940", "inocc_2025"] == pytest.approx(2.2) | ||||
| 109 | + | ||||
| 110 | + | ||||
| 111 | +def test_ipc_loyer(res): | ||||
| 112 | + i = res["ipc_loyer"] | ||||
| 113 | + assert (i["sept2026"], i["sept2025"]) == pytest.approx((174.2, 168.6)) | ||||
| 114 | + assert i["var_12m"] == pytest.approx(0.0332, abs=5e-5) and i["cagr_10ans"] == pytest.approx(0.0402, abs=5e-5) | ||||
| 115 | + | ||||
| 116 | + | ||||
| 117 | +# ----------------------------------------------------------------------------- modèle frictionnel, sensibilité, trajectoires | ||||
| 118 | +def test_grille_et_sensibilite(res): | ||||
| 119 | + g = res["grille_frictionnelle"] | ||||
| 120 | + assert g.loc[9.4, 1.0] == pytest.approx(0.78, abs=0.005) and g.loc[13.0, 2.0] == pytest.approx(2.17, abs=0.005) and g.loc[15.0, 1.0] == pytest.approx(1.25) | ||||
| 121 | + s = res["sensibilite_v"]; b = res["cas3"] | ||||
| 122 | + assert s.attrs["R"] == pytest.approx(0.04415, abs=1e-6) | ||||
| 123 | + assert s.attrs["dRBE_dv_pt"] == pytest.approx(-1_655.40, abs=0.01) and s.attrs["dRNE_dv_pt"] == pytest.approx(-1_589.18, abs=0.01) | ||||
| 124 | + assert s.attrs["dV_dv_pt"] == pytest.approx(-35_995.11, abs=0.01) | ||||
| 125 | + row = s.set_index("v") | ||||
| 126 | + assert row.loc[0.039, "valeur"] == pytest.approx(2_037_502.89, abs=0.01) # = ch. 29 | ||||
| 127 | + assert row.loc[0.029, "valeur"] == pytest.approx(2_073_497.99, abs=0.01) and row.loc[0.08, "valeur"] == pytest.approx(1_889_922.94, abs=0.01) | ||||
| 128 | + assert row.loc[0.02, "rbe"] == pytest.approx(171_589.20, abs=0.01) and row.loc[0.06, "rne"] == pytest.approx(86_618.47, abs=0.01) | ||||
| 129 | + # linéarité exacte en v : V(v) = V(0,039) + (v − 0,039)·100·dV/dv | ||||
| 130 | + for v in (0.02, 0.05, 0.08): | ||||
| 131 | + assert row.loc[v, "valeur"] == pytest.approx(row.loc[0.039, "valeur"] + (v - 0.039) * 100 * s.attrs["dV_dv_pt"], abs=0.01) | ||||
| 132 | + | ||||
| 133 | + | ||||
| 134 | +def test_trajectoires(res): | ||||
| 135 | + tr = res["trajectoires"]; t = tr["table"].set_index("annee"); h = tr["hyp"] | ||||
| 136 | + assert t.loc[1, "base_rot15"] == pytest.approx(165_540) and t.loc[1, "marche"] == pytest.approx(186_036) | ||||
| 137 | + # indexation seule = RBP × 1,031^(t−1) | ||||
| 138 | + for k in range(1, 12): | ||||
| 139 | + assert t.loc[k, "tal_seul"] == pytest.approx(165_540 * 1.031 ** (k - 1), rel=1e-9) | ||||
| 140 | + # ordre des trajectoires : TAL seul ≤ rotation 9,4 % ≤ rotation 15 % ≤ marché | ||||
| 141 | + assert ((t["tal_seul"] <= t["rot_schl_094"] + 1e-6) & (t["rot_schl_094"] <= t["base_rot15"] + 1e-6) & (t["base_rot15"] <= t["marche"] + 1e-6)).all() | ||||
| 142 | + # base = traite.cas3.projection_dcf (ch. 30/47) | ||||
| 143 | + p = cas3.tout()["projection"] | ||||
| 144 | + assert np.allclose(t["base_rot15"].values, p["rbp"].values) and np.allclose(t["marche"].values, p["rbp_marche"].values) | ||||
| 145 | + assert tr["rbp_T"]["tal_seul"] == pytest.approx(217_886.81, abs=0.01) and tr["rbp_T"]["base_rot15"] == pytest.approx(251_101.39, abs=0.01) | ||||
| 146 | + assert tr["rbp_T"]["rot_schl_094"] == pytest.approx(247_211.89, abs=0.01) and tr["rbp_T"]["marche"] == pytest.approx(253_547.97, abs=0.01) | ||||
| 147 | + assert tr["cagr"]["base_rot15"] == pytest.approx(0.04738, abs=5e-6) and tr["cagr"]["rot_schl_094"] == pytest.approx(0.04557, abs=5e-6) | ||||
| 148 | + assert t.loc[10, "ecart_base_pct"] == pytest.approx(0.96, abs=0.005) and t.loc[10, "ecart_tal_pct"] == pytest.approx(14.06, abs=0.005) | ||||
| 149 | + assert t.loc[10, "ecart_rot094_pct"] == pytest.approx(2.50, abs=0.005) and t.loc[2, "base_rot15"] == pytest.approx(177_389.21, abs=0.01) | ||||
| 150 | + assert tr["valeur_dcf"]["base_rot15"] == pytest.approx(2_208_565.91, abs=0.01) # = ch. 30/47 | ||||
| 151 | + assert tr["valeur_dcf"]["tal_seul"] == pytest.approx(1_756_414.23, abs=0.01) and tr["valeur_dcf"]["rot_schl_094"] == pytest.approx(2_140_916.43, abs=0.01) | ||||
| 152 | + assert tr["somme_rbp_1_T"]["base_rot15"] == pytest.approx(2_113_053.32, abs=0.01) | ||||
| 153 | + | ||||
| 154 | + | ||||
| 155 | +def test_mini_projection(): | ||||
| 156 | + mp = c.mini_projection([900, 950, 1000, 1100], 1200, 0.031, 0.035, 1, 4) | ||||
| 157 | + assert mp.loc[0, "rbp"] == pytest.approx(47_400) and mp.loc[0, "rbp_marche"] == pytest.approx(57_600) | ||||
| 158 | + assert mp.loc[1, "rbp"] == pytest.approx(52_638.60, abs=0.01) # 900 → 1 242 ; autres × 1,031 | ||||
| 159 | + assert mp.loc[2, "rbp"] == pytest.approx(57_578.28, abs=0.01) and mp.loc[3, "rbp"] == pytest.approx(62_177.79, abs=0.01) | ||||
| 160 | + assert (mp["rbp"] <= mp["rbp_marche"] + 1e-9).all() | ||||
| 161 | + | ||||
| 162 | + | ||||
| 163 | +# ----------------------------------------------------------------------------- exercices (solutions/ch27.tex) | ||||
| 164 | +def test_exercices(res): | ||||
| 165 | + ex = res["exercices"] | ||||
| 166 | + assert ex["1"]["rbp_c"] == 78_360 and ex["1"]["rbp_m"] == 87_000 and ex["1"]["deficit"] == 8_640 and ex["1"]["deficit_pct"] == pytest.approx(0.0993, abs=5e-5) | ||||
| 167 | + assert ex["2"]["rbe_livre"] == pytest.approx(80_025.60, abs=0.01) and ex["2"]["rbe_v_sur_tout"] == pytest.approx(79_833.60, abs=0.01) | ||||
| 168 | + assert ex["2"]["rbe_autres_demi"] == pytest.approx(79_929.60, abs=0.01) and ex["2"]["vacance"] == pytest.approx(3_134.40, abs=0.01) | ||||
| 169 | + assert ex["3"]["vf_1"] == pytest.approx(0.010833, abs=1e-6) and ex["3"]["vf_15"] == pytest.approx(0.01625, abs=1e-9) | ||||
| 170 | + assert ex["3"]["v_1"] == pytest.approx(0.015833, abs=1e-6) and ex["3"]["v_15"] == pytest.approx(0.02125, abs=1e-9) and ex["3"]["duree_implicite_schl"] == pytest.approx(2.215, abs=5e-4) | ||||
| 171 | + assert ex["4"]["L_eff_1_12"] == pytest.approx(1_191.67, abs=0.01) and ex["4"]["rbe_correct"] == pytest.approx(14_300) and ex["4"]["double_compte"] == pytest.approx(13_108.33, abs=0.01) | ||||
| 172 | + assert ex["5"]["rbe"] == pytest.approx(40_577.04, abs=0.01) and ex["5"]["rbe_variante_occupant"] == pytest.approx(40_953.96, abs=0.01) | ||||
| 173 | + assert ex["6"]["rbp"] == pytest.approx([47_400, 52_638.60, 57_578.28, 62_177.79], abs=0.01) | ||||
| 174 | + assert ex["6"]["tal_seul"][3] == pytest.approx(51_946.27, abs=0.01) and ex["6"]["ecart_pct"][3] == pytest.approx(0.0264, abs=5e-5) | ||||
| 175 | + assert ex["7"]["V_029"] == pytest.approx(2_073_497.99, abs=0.01) and ex["7"]["V_059"] == pytest.approx(1_965_512.67, abs=0.01) | ||||
| 176 | + assert ex["7"]["rne_029"] == pytest.approx(91_544.94, abs=0.01) and ex["7"]["rne_059"] == pytest.approx(86_777.38, abs=0.01) | ||||
| 177 | + assert ex["7"]["approx_lineaire_2pts"] == pytest.approx(ex["7"]["V_059"] - ex["7"]["V_039"], abs=0.01) # linéaire en v | ||||
| 178 | + assert ex["8"]["ratio_tous"] == pytest.approx(1.2337, abs=5e-5) and ex["8"]["ratio_2ch"] == pytest.approx(1.3454, abs=5e-5) and ex["8"]["q4_sur_q1"] == pytest.approx(3.77, abs=0.005) | ||||
| 179 | + | ||||
| 180 | + | ||||
| 181 | +def test_figures_presentes(): | ||||
| 182 | + for slug in ("inoccupation-rmr", "cascade", "trajectoires", "sensibilite"): | ||||
| 183 | + assert (ROOT / f"assets/figures/ch27/ch27-fig-{slug}.pdf").exists(), slug | ||||
added
analysis/python/tests/test_ch32.py
+132 −0
@@ -0,0 +1,132 @@ | |||||
| 1 | +"""Tests du chapitre 32 — Duplex à quintuplex : identités, cohérence avec traite.cas2 (partagé avec les ch. 26, 27, 46), | ||||
| 2 | +valeurs citées dans le texte (tolérances déclarées), règles de financement vérifiées, exercices, présence des figures.""" | ||||
| 3 | +import pathlib, sys | ||||
| 4 | +import numpy as np, pytest | ||||
| 5 | + | ||||
| 6 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | ||||
| 7 | +sys.path.insert(0, str(ROOT / "analysis/python/cases")) | ||||
| 8 | +import ch32_plex as C # noqa: E402 | ||||
| 9 | +from traite import cas2, finance as fin # noqa: E402 | ||||
| 10 | + | ||||
| 11 | + | ||||
| 12 | +@pytest.fixture(scope="module") | ||||
| 13 | +def R(): | ||||
| 14 | + return C.calculer() | ||||
| 15 | + | ||||
| 16 | + | ||||
| 17 | +# ----------------------------------------------------------------------------- cohérence avec le module partagé | ||||
| 18 | +def test_coherence_cas2_partage(R): | ||||
| 19 | + r = cas2.tout(); b = R["base"] | ||||
| 20 | + assert b["rbp_contrat"] == pytest.approx(41832.0) and b["rbp_marche"] == pytest.approx(49332.0) | ||||
| 21 | + assert b["rne"] == pytest.approx(22033.9584, abs=0.01) and b["ratio_depenses"] == pytest.approx(0.457, abs=0.001) | ||||
| 22 | + assert R["indicateurs"] == r["indicateurs"] | ||||
| 23 | + assert R["indications"]["v_mrb_127"] == pytest.approx(r["valeur_mrb"], abs=0.01) | ||||
| 24 | + assert R["indications"]["v_cap_127"] == pytest.approx(r["valeur_capitalisation"], abs=0.01) | ||||
| 25 | + assert R["indications"]["v_log_127"] == pytest.approx(r["valeur_par_logement"], abs=0.01) | ||||
| 26 | + | ||||
| 27 | + | ||||
| 28 | +def test_valeurs_citees_indicateurs(R): | ||||
| 29 | + ind = R["indicateurs"] | ||||
| 30 | + assert ind["n"] == 127 and ind["n_triplex"] == 67 | ||||
| 31 | + assert ind["mrb_median_triplex"] == pytest.approx(19.82, abs=0.005) and ind["taux_median"] == pytest.approx(0.0317, abs=5e-5) | ||||
| 32 | + assert ind["prix_par_logement_triplex"] == pytest.approx(281000, abs=1) | ||||
| 33 | + assert R["indications"]["v_mrb_127"] == pytest.approx(829110.24, abs=1) | ||||
| 34 | + assert R["indications"]["v_cap_127"] == pytest.approx(695077.55, abs=1) | ||||
| 35 | + assert R["indications"]["v_log_127"] == pytest.approx(843000, abs=1) | ||||
| 36 | + pt = R["par_taille"]; assert pt.loc[2, "n"] == 60 and pt.loc[3, "n"] == 67 | ||||
| 37 | + assert pt.loc[3, "prix_median"] == pytest.approx(843000, abs=1) and pt.loc[2, "prix_median"] == pytest.approx(583500, abs=1) | ||||
| 38 | + | ||||
| 39 | + | ||||
| 40 | +# ----------------------------------------------------------------------------- identités | ||||
| 41 | +def test_identite_mrb_taux(R): | ||||
| 42 | + """R = (1 − v)(1 − d)/MRB pour chaque vente du jeu (v = 2,4 %, d = ratio de la vente), aux arrondis du fichier près.""" | ||||
| 43 | + g = R["decomposition"] | ||||
| 44 | + lhs = g["taux_global"]; rhs = (1 - 0.024) * (1 - g["ratio_depenses"]) / g["mrb"] | ||||
| 45 | + assert np.max(np.abs(lhs - rhs)) < 2e-4 | ||||
| 46 | + | ||||
| 47 | + | ||||
| 48 | +def test_decomposition_rho(R): | ||||
| 49 | + """R_c = ρ R_m lorsque les dépenses sont proportionnelles au revenu (jeu synthétique).""" | ||||
| 50 | + g = R["decomposition"] | ||||
| 51 | + assert np.max(np.abs(g["taux_contrat_reconstitue"] - g["taux_global"])) < 2e-4 | ||||
| 52 | + assert g["taux_marche"].median() > g["taux_global"].median() | ||||
| 53 | + assert R["regression"]["b_rho"] > 0 and 0.03 < R["regression"]["a"] < 0.04 | ||||
| 54 | + | ||||
| 55 | + | ||||
| 56 | +def test_valeurs_citees_regression_et_indications(R): | ||||
| 57 | + reg = R["regression"]; I = R["indications"] | ||||
| 58 | + assert reg["a"] == pytest.approx(0.0345, abs=1e-4) and reg["b_rho"] == pytest.approx(0.0284, abs=1e-4) | ||||
| 59 | + assert I["rho_sujet"] == pytest.approx(0.848, abs=5e-4) | ||||
| 60 | + assert I["taux_ajuste"] == pytest.approx(0.0302, abs=1e-4) | ||||
| 61 | + assert I["v_cap_ajuste"] == pytest.approx(729549, abs=1) | ||||
| 62 | + assert I["v_log_comp"] == pytest.approx(918000, abs=1) and I["v_mrb_comp"] == pytest.approx(922396, abs=1) | ||||
| 63 | + assert I["v_cap_comp"] == pytest.approx(741884, abs=1) | ||||
| 64 | + assert I["valeur_ponderee"] == pytest.approx(872426, abs=1) and I["valeur_arrondie"] == 870000 | ||||
| 65 | + assert I["valeur_ponderee"] == pytest.approx(sum(C.POIDS_INDICATIONS[k] * v for k, v in I["retenues"].items())) | ||||
| 66 | + assert abs(sum(C.POIDS_INDICATIONS.values()) - 1) < 1e-12 | ||||
| 67 | + assert I["v_cap_ratio_ventes"] == pytest.approx(863880, abs=1) and I["v_log_127_indexe"] == pytest.approx(900926, abs=1) | ||||
| 68 | + | ||||
| 69 | + | ||||
| 70 | +def test_comparables_selection(R): | ||||
| 71 | + comp5 = R["comparables5"] | ||||
| 72 | + assert len(comp5) == 5 and set(comp5["id_vente"]) == {"Q0187", "Q0222", "Q0243", "Q0207", "Q0235"} | ||||
| 73 | + assert (comp5["quartier"] == "Limoilou").all() and (comp5["nb_logements"] == 3).all() | ||||
| 74 | + assert comp5["distance"].is_monotonic_increasing and len(R["candidats"]) == 28 | ||||
| 75 | + assert comp5["prix_par_logement"].median() == pytest.approx(306000, abs=1) and comp5["mrb"].median() == pytest.approx(22.05, abs=0.005) | ||||
| 76 | + | ||||
| 77 | + | ||||
| 78 | +def test_prime_occupant(R): | ||||
| 79 | + p = R["prime"]; I = R["indications"] | ||||
| 80 | + assert p["v_investisseur_contrat"] + p["v_rattrapage"] + p["prime"] == pytest.approx(I["valeur_ponderee"]) | ||||
| 81 | + assert p["v_investisseur_contrat"] == pytest.approx(440679.17, abs=0.01) and p["prime_pct"] == pytest.approx(0.335, abs=0.001) | ||||
| 82 | + | ||||
| 83 | + | ||||
| 84 | +# ----------------------------------------------------------------------------- financement (règles vérifiées, notes-C §4.3, §4.2) | ||||
| 85 | +def test_mise_de_fonds_minimale(): | ||||
| 86 | + assert C.mise_de_fonds_minimale_assuree(450_000) == pytest.approx(22_500) | ||||
| 87 | + assert C.mise_de_fonds_minimale_assuree(843_000) == pytest.approx(59_300) | ||||
| 88 | + assert C.mise_de_fonds_minimale_assuree(870_000) == pytest.approx(62_000) | ||||
| 89 | + assert C.mise_de_fonds_minimale_assuree(1_500_000) == pytest.approx(125_000) | ||||
| 90 | + assert np.isnan(C.mise_de_fonds_minimale_assuree(1_600_000)) | ||||
| 91 | + | ||||
| 92 | + | ||||
| 93 | +def test_financement_occupant(R): | ||||
| 94 | + f = R["financement"] | ||||
| 95 | + assert f["pret"] == pytest.approx(808_000) and f["rpv"] == pytest.approx(0.9287, abs=1e-4) | ||||
| 96 | + assert f["paiement_mensuel"] == pytest.approx(4302.97, abs=0.01) and f["service_dette"] == pytest.approx(51635.62, abs=0.01) | ||||
| 97 | + # composition semestrielle : taux mensuel équivalent (1 + 0,0412/2)^(1/6) − 1 | ||||
| 98 | + i_m = (1 + 0.0412 / 2) ** (1 / 6) - 1 | ||||
| 99 | + assert f["paiement_mensuel"] == pytest.approx(808_000 * i_m / (1 - (1 + i_m) ** -300), abs=0.01) | ||||
| 100 | + assert f["taux_admissible"] == pytest.approx(0.0612) and f["paiement_mensuel_tam"] == pytest.approx(5227.41, abs=0.01) | ||||
| 101 | + h = fin.Hypotheque(f["pret"], f["taux"], f["amort_mois"]); assert h.solde(300) == pytest.approx(0.0, abs=1e-6) | ||||
| 102 | + o = R["occupation"] | ||||
| 103 | + assert o["cout_net_mensuel"] == pytest.approx(3532.11, abs=0.01) and o["cout_net_mensuel_hors_capital"] == pytest.approx(1813.08, abs=0.01) | ||||
| 104 | + assert o["cout_net_annuel"] == pytest.approx(o["sortie_brute"] - o["loyers_percus_nets"]) | ||||
| 105 | + | ||||
| 106 | + | ||||
| 107 | +def test_sensibilite(R): | ||||
| 108 | + S = R["sensibilite"]; L = S["loyers"] | ||||
| 109 | + assert L["v_cap_ajuste"].is_monotonic_increasing and L["v_mrb"].is_monotonic_increasing | ||||
| 110 | + assert L.loc[L["rho"] == 1.0, "rne"].item() == pytest.approx(R["rne_marche"], abs=0.01) | ||||
| 111 | + F = S["financement"] | ||||
| 112 | + assert F["cout_net_mensuel"].iloc[4] > F["cout_net_mensuel"].iloc[0] # +100 pb coûte plus cher | ||||
| 113 | + assert F["service_dette"].iloc[1] < F["service_dette"].iloc[0] # 30 ans < 25 ans | ||||
| 114 | + T = S["taux"]; assert all(T["valeur"] == pytest.approx(R["base"]["rne"] / T["taux"])) | ||||
| 115 | + | ||||
| 116 | + | ||||
| 117 | +# ----------------------------------------------------------------------------- exercices (solutions/ch32.tex) | ||||
| 118 | +def test_exercices(): | ||||
| 119 | + E = C.exercices() | ||||
| 120 | + assert E["1"]["mrb"] == pytest.approx(18.75) and E["1"]["taux"] == pytest.approx(E["1"]["identite"]) | ||||
| 121 | + assert E["1"]["rne"] == pytest.approx(18763.68) | ||||
| 122 | + assert E["2"]["843000.0"]["mise"] == pytest.approx(59300) and E["2"]["450000.0"]["mise"] == pytest.approx(22500) | ||||
| 123 | + e3 = E["3"]; assert e3["R_m"] - e3["R_c"] == pytest.approx(e3["terme"]) and e3["ecart_pb"] == pytest.approx(105.36, abs=0.01) | ||||
| 124 | + assert E["4"]["2e"]["cout_net_mensuel"] > E["4"]["RC"]["cout_net_mensuel"] | ||||
| 125 | + assert E["6"]["ponderations"]["comparaison seule (50/50/0)"] == pytest.approx(920197.8, abs=0.1) | ||||
| 126 | + assert E["6"]["ponderations"]["revenu dominant (20/20/60)"] == pytest.approx(805809, abs=1) | ||||
| 127 | + assert E["7"]["va_1an"] == pytest.approx(4335.85, abs=0.01) and E["7"]["va_10ans"] == pytest.approx(40791.47, abs=0.01) | ||||
| 128 | + | ||||
| 129 | + | ||||
| 130 | +def test_figures_presentes(): | ||||
| 131 | + for slug in ("mrb-taille-quartier", "taux-loyer-relatif", "prix-logement-quartier", "plex-apciq-rmr", "sensibilite-occupant"): | ||||
| 132 | + assert (ROOT / f"assets/figures/ch32/ch32-fig-{slug}.pdf").exists(), slug | ||||
modified
assets/figures/ch25/ch25-fig-carte-terrains.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch25/ch25-fig-lotissement.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch25/ch25-fig-residuelle-sensibilite.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch25/ch25-fig-valeur-unitaire-superficie.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch26/ch26-fig-anciennete.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch26/ch26-fig-contrat-marche.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch26/ch26-fig-normalisation.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch26/ch26-fig-va-ecart.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch27/ch27-fig-cascade.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch27/ch27-fig-inoccupation-rmr.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch27/ch27-fig-sensibilite.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch27/ch27-fig-trajectoires.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch28/ch28-fig-cascade.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch28/ch28-fig-evolution.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch28/ch28-fig-ratio-loyer.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch28/ch28-fig-sensibilite.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch28/ch28-fig-structure.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch32/ch32-fig-mrb-taille-quartier.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch32/ch32-fig-plex-apciq-rmr.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch32/ch32-fig-prix-logement-quartier.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch32/ch32-fig-sensibilite-occupant.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch32/ch32-fig-taux-loyer-relatif.pdf
+0 −0
Binary file not shown.
modified
data/synthetic/README.md
+3 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added
manuscript/_wrap_ch19.tex
+12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
modified
manuscript/chapters/ch11.tex
+2 −2
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified
manuscript/chapters/ch13.tex
+587 −1
Large diff (589 lines) is not loaded by default to keep the page fast.Load diff
modified
manuscript/chapters/ch19.tex
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added
references/lib-ch19.bib
+11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
modified
solutions/ch19.tex
+40 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
modified
verification/calculations.csv
+14 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch11.md
+44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch19-calculations.csv
+15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch19-claims.csv
+11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch19-figures.csv
+9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch19-registry.csv
+1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
modified
verification/claims.csv
+10 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
modified
verification/figures.csv
+8 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff