#!/usr/bin/env python3 """Chapitre 45 — figures du dossier (cas 1, DONNÉES SYNTHÉTIQUES) : carte des comparables, cascade des ajustements, fourchettes par méthode et conclusion, profil de dépréciation par composante, financement de l'acheteur. Tous les chiffres proviennent de analysis/python/cases/ch45_dossier.py (qui importe les scripts des chapitres de méthode). Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch45_figures.py""" from __future__ import annotations import sys, pathlib, math import numpy as np import matplotlib.pyplot as plt from matplotlib.ticker import FuncFormatter sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) from traite.style import setup, palette, savefig, LARGEUR import ch45_dossier as ch import ch17_ajustements as c17 def fmt_k(x, pos=None): return f"{x/1000:,.0f}".replace(",", " ") + " k$" def fmt_d(x): return f"{x:,.0f} $".replace(",", " ") def fig_carte(D): """Sujet, 16 candidats de l'entonnoir du ch. 16 et 5 comparables retenus ; distances en km autour du sujet (projection locale).""" s = D["sujet"]; cand = D["candidats"]; comps = D["comparables"] lat0, lon0 = s["lat"], s["lon"]; kx = 111.32 * math.cos(math.radians(lat0)); ky = 110.57 fig, ax = plt.subplots(figsize=(LARGEUR, 3.6)) for r_km, lab in [(1.5, "1,5 km"), (3.0, "3 km")]: ax.add_patch(plt.Circle((0, 0), r_km, fill=False, color=palette.ink2, lw=0.7, ls="--")) ax.text(r_km * 0.72, r_km * 0.72, lab, fontsize=7, color=palette.ink2) autres = cand[~cand["id_vente"].isin(comps["id_vente"])] ax.scatter((autres["lon"] - lon0) * kx, (autres["lat"] - lat0) * ky, s=22 + (autres["prix_vente"] - 450000) / 4000, color=palette.cat[0], alpha=0.55, label="11 autres candidats (entonnoir du ch. 16)", zorder=3) ax.scatter((comps["lon"] - lon0) * kx, (comps["lat"] - lat0) * ky, s=30 + (comps["prix_vente"] - 450000) / 4000, color=palette.cat[1], edgecolor="white", lw=0.6, label="5 comparables retenus", zorder=4) for _, r in comps.iterrows(): ax.annotate(f"{r['id_vente']}\n{fmt_d(r['prix_vente'])}", ((r["lon"] - lon0) * kx, (r["lat"] - lat0) * ky), textcoords="offset points", xytext=(6, 4), fontsize=6.5, color=palette.ink) ax.scatter([0], [0], marker="*", s=160, color=palette.ink, zorder=5, label="sujet (adresse fictive, secteur Hull)") ax.set_aspect("equal"); ax.set_xlim(-3.4, 3.4); ax.set_ylim(-3.4, 3.4) ax.set_xlabel("Est–ouest (km, à partir du sujet)"); ax.set_ylabel("Nord–sud (km)") ax.legend(loc="upper left", fontsize=7, frameon=False) ax.set_title("Taille des points proportionnelle au prix de vente", loc="left", fontsize=8) fig.tight_layout() savefig(fig, "ch45/ch45-fig-carte-comparables.pdf") def fig_cascade(D): """Prix bruts → ajustements par groupe (empilés) → prix ajustés, pour les 5 comparables (grille du ch. 17), et valeur pondérée.""" a = D["adj17"]; grp = [g for g in c17.GROUPES if (a[g].abs() > 1).any()] fig, ax = plt.subplots(figsize=(LARGEUR, 3.9)) x = np.arange(len(a)); w = 0.27 ax.bar(x - 0.3, a["prix_vente"], width=w, color=palette.cat[0], alpha=0.5, label="prix de vente") ax.bar(x + 0.3, a["prix_ajuste"], width=w, color=palette.cat[1], label="prix ajusté") cols = [palette.cat[(i + 2) % len(palette.cat)] for i in range(len(grp))] for i, (_, r) in enumerate(a.iterrows()): pos = r["prix_vente"]; neg = r["prix_vente"] for g, c in zip(grp, cols): v = r[g] if v >= 0: ax.bar(x[i], v, bottom=pos, width=w, color=c, edgecolor="white", lw=0.3, label=c17.LIBELLES[g] if i == 0 else None); pos += v else: ax.bar(x[i], v, bottom=neg, width=w, color=c, edgecolor="white", lw=0.3, label=c17.LIBELLES[g] if i == 0 else None); neg += v ax.plot([x[i] - 0.3 - w / 2, x[i] + w / 2], [r["prix_vente"]] * 2, color=palette.ink2, lw=0.5, ls=":") ax.text(x[i] + 0.3, r["prix_ajuste"] + 5000, fmt_d(r["prix_ajuste"]), ha="center", fontsize=6.3) ax.text(x[i] - 0.3, r["prix_vente"] + 5000, fmt_d(r["prix_vente"]), ha="center", fontsize=6.3, color=palette.ink2) ax.axhline(D["V_comp"], color=palette.ink, lw=1.0, ls="--"); ax.text(len(a) - 0.55, D["V_comp"] - 11000, f"valeur pondérée {fmt_d(D['V_comp'])}", ha="right", fontsize=7) ax.axhline(D["V_hed"], color=palette.ink2, lw=0.8, ls=":"); ax.text(len(a) - 0.55, D["V_hed"] + 4000, f"prédiction hédonique {fmt_d(D['V_hed'])}", ha="right", fontsize=7, color=palette.ink2) ax.set_xticks(x); ax.set_xticklabels([f"{r['id_vente']}\nbrut {r['ajust_brut_pct']*100:.1f} %\npoids {D['poids17'][i]:.3f}".replace(".", ",") for i, (_, r) in enumerate(a.iterrows())], fontsize=7) ax.set_ylim(440000, 770000); ax.yaxis.set_major_formatter(FuncFormatter(fmt_k)) ax.set_ylabel("Prix ($)"); ax.legend(loc="upper left", fontsize=6.3, frameon=False, ncol=3) fig.tight_layout() savefig(fig, "ch45/ch45-fig-grille-cascade.pdf") def fig_fourchettes(D): """Indications ± 1 écart-type (ch. 20), IC et IP hédoniques (ch. 18), conclusion, fourchette conclue, bande bootstrap 90 %, rôle (fictif) et valeur réelle au 2024-07-01 (ch. 9), prêt assuré maximal.""" fig, ax = plt.subplots(figsize=(LARGEUR, 3.5)) lignes = [("Comparaison (ch. 17)", D["indications"]["comparaison"], D["ecarts_types20"]["comparaison"], None), ("Hédonique (ch. 18)", D["indications"]["hedonique"], D["ecarts_types20"]["hedonique"], (D["ip95"], D["ic95_valeur"])), ("Coût (ch. 24-25)", D["indications"]["cout"], D["ecarts_types20"]["cout"], None), ("Combinaison 0,40/0,45/0,15 (ch. 20)", D["V_raisonnee"], D["sd_conclusion"], None)] for i, (lab, v, sd, extra) in enumerate(lignes): y = len(lignes) - i if extra: ax.plot(extra[0], [y, y], color=palette.cat[0], lw=1.0, alpha=0.5); ax.text(extra[0][1] + 3000, y, "IP 95 % du prix", fontsize=6.5, va="center", color=palette.ink2) ax.plot(extra[1], [y, y], color=palette.cat[0], lw=5, alpha=0.35, solid_capstyle="butt") ax.errorbar([v], [y], xerr=[[sd], [sd]], fmt="o", color=palette.cat[1] if i < 3 else palette.ink, capsize=3, ms=5, lw=1.4) ax.text(v, y + 0.22, fmt_d(v), ha="center", fontsize=7) ax.set_yticks([len(lignes) - i for i in range(len(lignes))]); ax.set_yticklabels([l[0] for l in lignes], fontsize=7.5) ax.axvspan(*D["fourchette"], color=palette.cat[1], alpha=0.10) ax.axvspan(*D["bande90"], color=palette.cat[1], alpha=0.14) ax.axvline(D["conclusion"], color=palette.ink, lw=1.3); ax.text(D["conclusion"], 4.75, f"valeur la plus probable {fmt_d(D['conclusion'])}", ha="center", fontsize=7.5) ax.text(D["fourchette"][0] + 2000, 0.35, f"fourchette {fmt_d(D['fourchette'][0])} – {fmt_d(D['fourchette'][1])}", fontsize=7, ha="left") ax.text(D["bande90"][0], 0.08, f"bande bootstrap 90 % {fmt_d(D['bande90'][0])} – {fmt_d(D['bande90'][1])}", fontsize=6.5, ha="left", color=palette.ink2) ax.axvline(D["role"], color=palette.ink2, lw=0.8, ls=":"); ax.text(D["role"], 0.35, "rôle (fictif)\n" + fmt_d(D["role"]), ha="center", fontsize=6.5, color=palette.ink2) ax.axvline(D["valeur_reelle_2024"], color=palette.ink2, lw=0.8, ls=":"); ax.text(D["valeur_reelle_2024"] - 2500, 0.72, "valeur réelle au\n2024-07-01 (ch. 9)", ha="right", fontsize=6.5, color=palette.ink2) ax.set_ylim(-0.1, 5.1); ax.set_xlim(445000, 775000); ax.xaxis.set_major_formatter(FuncFormatter(fmt_k)) ax.set_xlabel("Valeur ($) — points : indications ; barres : ± 1 écart-type attribué au ch. 20") fig.tight_layout() savefig(fig, "ch45/ch45-fig-fourchettes.pdf") def fig_depreciation(D): """(a) Dépréciation ventilée par composante (ch. 11) contre dépréciation extraite du marché (ch. 24) ; (b) décomposition de la valeur par le coût : terrain + coût neuf − dépréciation.""" f = D["fiche"].copy(); f["dep"] = f["ratio"] * f["cout_neuf"] f = f.sort_values("dep", ascending=True) fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 4.0), gridspec_kw={"width_ratios": [1.45, 1]}) ax = axes[0] y = np.arange(len(f)) ax.barh(y, f["cout_neuf"], color=palette.cat[0], alpha=0.25, label="coût neuf de la composante") ax.barh(y, f["dep"], color=palette.cat[1], label="dépréciation ventilée (âge effectif / vie utile)") ax.set_yticks(y); ax.set_yticklabels([c if len(c) < 34 else c[:32] + "…" for c in f["composante"]], fontsize=6) ax.xaxis.set_major_formatter(FuncFormatter(fmt_k)); ax.legend(loc="lower right", fontsize=6.5, frameon=False) ax.set_title(f"(a) Ventilée : {fmt_d(D['dv11']['total'])} ({D['dv11']['taux']*100:.1f} % du coût neuf)".replace(".", ","), loc="left", fontsize=8) ax = axes[1] cats = ["Terrain", "Coût\nneuf", "Dépréc.\nextraite", "Carence\nsalle d'eau", "Valeur\npar le coût"] vals = [D["V_L"], D["CN"], -D["D_ext"], -D["D_fonc"], D["V_cout"]] bottoms = [0, D["V_L"], D["V_L"] + D["CN"] - D["D_ext"], D["V_L"] + D["CN"] - D["D_ext"] - D["D_fonc"], 0] cols = [palette.cat[2], palette.cat[0], palette.cat[1], palette.cat[1], palette.ink] ax.bar(cats, [abs(v) for v in vals], bottom=bottoms, color=cols, edgecolor="white", lw=0.5) for i, (v, b) in enumerate(zip(vals, bottoms)): if v < 0: ax.text(i, b + abs(v) / 2 if i == 2 else b - 12000, ("−" + fmt_d(abs(v))), ha="center", va="center", fontsize=6.3, color=palette.ink if i == 3 else "white") else: ax.text(i, b + abs(v) + 8000, fmt_d(abs(v)), ha="center", fontsize=6.5) ax.axhline(D["V_L"] + D["CN"], color=palette.ink2, lw=0.7, ls=":"); ax.text(0.0, D["V_L"] + D["CN"] + 30000, f"terrain + coût neuf\n{fmt_d(D['V_L'] + D['CN'])}", fontsize=6.5, ha="left", color=palette.ink2) ax.yaxis.set_major_formatter(FuncFormatter(fmt_k)); ax.tick_params(axis="x", labelsize=6.3); ax.set_ylim(0, 790000) ax.set_title(f"(b) Extraction : {D['taux_tot']*100:.2f} % → {fmt_d(D['V_cout_arrondie'])}".replace(".", ","), loc="left", fontsize=8) fig.tight_layout(w_pad=1.5) savefig(fig, "ch45/ch45-fig-depreciation.pdf") def fig_financement(D): """(a) Paiement mensuel et paiement au taux admissible selon la mise de fonds ; (b) paiement au renouvellement selon la hausse de taux.""" F = D["financement"]; ren = D["renouvellement"] fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 3.3)) ax = axes[0] keys = ["minimale", "10 %", "20 %", "minimale 30 ans"]; labs = ["5,87 %\n(min.)\n25 ans", "10 %\n25 ans", "20 %\n(non assuré)\n25 ans", "5,87 %\n30 ans"] x = np.arange(len(keys)); w = 0.38 ax.bar(x - w / 2, [F[k]["paiement"] for k in keys], width=w, color=palette.cat[1], label="paiement au taux du contrat") ax.bar(x + w / 2, [F[k]["paiement_mqr"] for k in keys], width=w, color=palette.cat[0], alpha=0.6, label="paiement au taux admissible minimal") for i, k in enumerate(keys): ax.text(x[i] - w / 2, F[k]["paiement"] + 40, f"{F[k]['paiement']:,.0f}".replace(",", " "), ha="center", fontsize=6.5) ax.text(x[i] + w / 2, F[k]["paiement_mqr"] + 40, f"{F[k]['paiement_mqr']:,.0f}".replace(",", " "), ha="center", fontsize=6.5, color=palette.ink2) ax.text(x[i], 300, f"prime\n{fmt_d(F[k]['prime'])}" if F[k]["prime"] else "sans\nprime", ha="center", fontsize=6.3, color="white") ax.set_xticks(x); ax.set_xticklabels(labs, fontsize=7); ax.set_ylabel("Paiement mensuel ($)"); ax.set_ylim(0, 4600) ax.legend(loc="upper right", fontsize=6.5, frameon=False); ax.set_title(f"(a) Prix {fmt_d(D['prix'])}, taux {F['minimale']['taux']*100:.2f} % / {F['20 %']['taux']*100:.2f} %".replace(".", ","), loc="left", fontsize=8) ax = axes[1] ax.plot(ren["hausse_pb"], ren["paiement"], marker="o", color=palette.cat[1], lw=1.6, label="paiement mensuel") for _, r in ren.iterrows(): ax.annotate(f"{r['paiement']:,.0f} $\n({r['hausse_paiement_pct']*100:+.1f} %)".replace(",", " ").replace(".", ","), (r["hausse_pb"], r["paiement"]), textcoords="offset points", xytext=(0, 8), ha="center", fontsize=6.5) ax.axhline(F["minimale"]["paiement_mqr"], color=palette.cat[0], lw=0.9, ls="--"); ax.text(0, F["minimale"]["paiement_mqr"] + 25, "paiement au taux admissible initial (6,12 %)", fontsize=6.5, ha="left", color=palette.cat[0]) ax.set_xlabel("Hausse du taux au renouvellement (points de base)"); ax.set_ylabel("Paiement mensuel ($)"); ax.set_ylim(2900, 4450) ax.set_xticks(ren["hausse_pb"]); ax.set_title(f"(b) Solde à 60 mois {fmt_d(F['minimale']['solde_terme'])}, 20 ans restants", loc="left", fontsize=8) fig.tight_layout(w_pad=1.5) savefig(fig, "ch45/ch45-fig-financement.pdf") def main(): setup() D = ch.dossier() fig_carte(D); fig_cascade(D); fig_fourchettes(D); fig_depreciation(D); fig_financement(D) print("figures ch45 écrites dans assets/figures/ch45/") if __name__ == "__main__": main()