SPB Git

Phase 3 : PDF pilote (338 p.), rapport de phase, correction page titre

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Oct 31, 2026) parent 16bdf35 Browse files

48 changed files +2,033 −43

48 changed files
modified analysis/python/cases/ch03_cycles.py +50 −26
@@ -145,12 +145,27 @@ def elasticites_4q(p: Parametres4Q) -> dict:
145 class ParametresCycle: 145 class ParametresCycle:
146 p4q: Parametres4Q = Parametres4Q() 146 p4q: Parametres4Q = Parametres4Q()
147 v_nat: float = 0.03 # taux d'inoccupation naturel (SCHL RMR Québec oct. 2025 : 2,4 % ; Montréal 2,9 %) 147 v_nat: float = 0.03 # taux d'inoccupation naturel (SCHL RMR Québec oct. 2025 : 2,4 % ; Montréal 2,9 %)
148− lam: float = 0.5 # vitesse d'ajustement du loyer : ΔR/R = −λ (v − v*) par trimestre 148+ lam: float = 0.75 # vitesse d'ajustement du loyer : ΔR/R = −λ (v − v*) par trimestre
149 L: int = 6 # délai de livraison (trimestres) entre la mise en chantier et l'ajout au stock 149 L: int = 6 # délai de livraison (trimestres) entre la mise en chantier et l'ajout au stock
150− T: int = 80 # horizon (trimestres) 150+ T: int = 100 # horizon (trimestres)
151− t_choc: int = 8 # trimestre du choc 151+ t_choc: int = 8 # trimestre où le choc commence
152− choc_demande: float = 0.10 # hausse permanente de l'ordonnée de demande (+10 %) 152+ n_choc: int = 12 # durée de montée du choc (trimestres) : la demande croît linéairement puis se stabilise
153− mult_c1: float = 1.0 # multiplicateur de la pente d'offre c1 (1 = référence ; >1 = offre moins élastique) 153+ choc_demande: float = 0.06 # hausse permanente de l'ordonnée de demande au terme de la montée (+6 %)
154+ mult_c1: float = 0.25 # multiplicateur de la pente d'offre c1 (0,25 : offre réactive, κ = 0,48 ; 1 : κ = 0,12)
155+
156+ @property
157+ def c1(self) -> float:
158+ return self.p4q.c1 * self.mult_c1
159+
160+ @property
161+ def c0(self) -> float:
162+ """Seuil de coût recalibré pour que l'état initial (S0, R0, P0, C0 = δ S0) soit stationnaire quelle que soit la pente c1."""
163+ return self.p4q.P0 - self.c1 * self.p4q.C0
164+
165+ @property
166+ def kappa(self) -> float:
167+ """Réactivité annuelle de l'offre à l'écart de stock : κ = 1/(b i c1)."""
168+ return 1.0 / (self.p4q.b * self.p4q.i * self.c1)
154 169
155 170
156 def simuler_cycle(pc: ParametresCycle) -> pd.DataFrame: 171 def simuler_cycle(pc: ParametresCycle) -> pd.DataFrame:
@@ -158,18 +173,19 @@ def simuler_cycle(pc: ParametresCycle) -> pd.DataFrame:
158 prix P = R/i ; mises en chantier C = max(0, (P − c0)/c1)/4 ; stock S_{t+1} = (1 − δ/4) S_t + C_{t−L}. 173 prix P = R/i ; mises en chantier C = max(0, (P − c0)/c1)/4 ; stock S_{t+1} = (1 − δ/4) S_t + C_{t−L}.
159 L'état initial est stationnaire avec v = v* (l'ordonnée a est ajustée pour que D0 = (1 − v*) S0).""" 174 L'état initial est stationnaire avec v = v* (l'ordonnée a est ajustée pour que D0 = (1 − v*) S0)."""
160 p = pc.p4q 175 p = pc.p4q
161− c1 = p.c1 * pc.mult_c1 176+ c1, c0 = pc.c1, pc.c0
162 b = p.b 177 b = p.b
163 a0 = (1.0 - pc.v_nat) * p.S0 + b * p.R0 178 a0 = (1.0 - pc.v_nat) * p.S0 + b * p.R0
164 S, R = p.S0, p.R0 179 S, R = p.S0, p.R0
165 starts = [p.C0 / 4.0] * (pc.L + 1) # chantiers en cours au régime stationnaire 180 starts = [p.C0 / 4.0] * (pc.L + 1) # chantiers en cours au régime stationnaire
166 rows = [] 181 rows = []
167 for t in range(pc.T + 1): 182 for t in range(pc.T + 1):
168− a = a0 * (1.0 + pc.choc_demande) if t >= pc.t_choc else a0 183+ montee = min(1.0, max(0.0, (t - pc.t_choc + 1) / pc.n_choc)) if pc.n_choc > 0 else float(t >= pc.t_choc)
184+ a = a0 * (1.0 + pc.choc_demande * montee)
169 D = min(S, a - b * R) 185 D = min(S, a - b * R)
170 v = 1.0 - D / S 186 v = 1.0 - D / S
171 P = fin.capitalisation_directe(R, p.i) 187 P = fin.capitalisation_directe(R, p.i)
172− C = max(0.0, (P - p.c0) / c1) / 4.0 188+ C = max(0.0, (P - c0) / c1) / 4.0
173 rows.append({"t": t, "annee": t / 4.0, "S": S, "D": D, "v": v, "R": R, "P": P, "C": C, "a": a}) 189 rows.append({"t": t, "annee": t / 4.0, "S": S, "D": D, "v": v, "R": R, "P": P, "C": C, "a": a})
174 starts.append(C) 190 starts.append(C)
175 livraison = starts[-(pc.L + 1)] 191 livraison = starts[-(pc.L + 1)]
@@ -188,9 +204,9 @@ def etat_stationnaire_cycle(pc: ParametresCycle, apres_choc: bool = True) -> dic
188 a0 = (1.0 - pc.v_nat) * p.S0 + b * p.R0 204 a0 = (1.0 - pc.v_nat) * p.S0 + b * p.R0
189 a = a0 * (1.0 + pc.choc_demande) if apres_choc else a0 205 a = a0 * (1.0 + pc.choc_demande) if apres_choc else a0
190 # (1−v*) S = a − bR ; R = i P ; P = c0 + c1 δ S → (1−v*) S = a − b i (c0 + c1 δ S) 206 # (1−v*) S = a − bR ; R = i P ; P = c0 + c1 δ S → (1−v*) S = a − b i (c0 + c1 δ S)
191− c1 = p.c1 * pc.mult_c1 207+ c1, c0 = pc.c1, pc.c0
192− S = (a - b * p.i * p.c0) / ((1.0 - pc.v_nat) + b * p.i * c1 * p.delta) 208+ S = (a - b * p.i * c0) / ((1.0 - pc.v_nat) + b * p.i * c1 * p.delta)
193− P = p.c0 + c1 * p.delta * S 209+ P = c0 + c1 * p.delta * S
194 R = p.i * P 210 R = p.i * P
195 return {"S": S, "R": R, "P": P, "C": p.delta * S, "v": pc.v_nat} 211 return {"S": S, "R": R, "P": P, "C": p.delta * S, "v": pc.v_nat}
196 212
@@ -215,13 +231,13 @@ def resume_cycle(df: pd.DataFrame, pc: ParametresCycle) -> dict:
215 "amortissement": (post2.max() / R_rel.max()) if periode == periode else float("nan")} 231 "amortissement": (post2.max() / R_rel.max()) if periode == periode else float("nan")}
216 232
217 233
218−def sensibilite_cycle(pc: ParametresCycle, delais=(2, 4, 6, 8, 12), mult_c1=(0.5, 1.0, 2.0), lams=(0.25, 0.5, 1.0)) -> pd.DataFrame: 234+def sensibilite_cycle(pc: ParametresCycle, delais=(2, 4, 6, 8, 12), mult_c1=(0.25, 0.5, 1.0), lams=(0.5, 0.75, 1.5)) -> pd.DataFrame:
219 rows = [] 235 rows = []
220 for L in delais: 236 for L in delais:
221 for m in mult_c1: 237 for m in mult_c1:
222 for lam in lams: 238 for lam in lams:
223 r = resume_cycle(simuler_cycle(replace(pc, L=L, mult_c1=m, lam=lam)), replace(pc, L=L, mult_c1=m, lam=lam)) 239 r = resume_cycle(simuler_cycle(replace(pc, L=L, mult_c1=m, lam=lam)), replace(pc, L=L, mult_c1=m, lam=lam))
224− rows.append({"L": L, "mult_c1": m, "lam": lam, **r}) 240+ rows.append({"L": L, "mult_c1": m, "lam": lam, "kappa": replace(pc, mult_c1=m).kappa, **r})
225 return pd.DataFrame(rows) 241 return pd.DataFrame(rows)
226 242
227 243
@@ -287,10 +303,12 @@ def mises_en_chantier_annuelles(df: pd.DataFrame | None = None) -> pd.DataFrame:
287 return piv 303 return piv
288 304
289 305
290−def mises_en_chantier_montreal_annuelles() -> pd.Series: 306+def mises_en_chantier_montreal_annuelles(geo: str = "Montréal, Québec") -> pd.DataFrame:
291− """34-10-0143-01 : RMR de Montréal, mensuel, total d'unités, sommes annuelles.""" 307+ """34-10-0143-01 : RMR de Montréal (centres de 10 000 hab. et plus), mensuel, total d'unités, sommes annuelles.
308+ La colonne « mois » compte les mois disponibles : dans le fichier du 2026-10-31, juillet à septembre 2025 sont ABSENTS
309+ pour Montréal, de sorte que la somme 2025 (21 138) ne couvre que 9 mois et n'est pas un total annuel."""
292 rows = _lire_statcan(RAW / "statcan_34100143/34100143.csv", 310 rows = _lire_statcan(RAW / "statcan_34100143/34100143.csv",
293− lambda r: r["GÉO"] == "Montréal, Québec" and r["Estimations de logements"] == "Logements mis en chantier" 311+ lambda r: r["GÉO"] == geo and r["Estimations de logements"] == "Logements mis en chantier"
294 and r["Type d'unité"] == "Total d'unités") 312 and r["Type d'unité"] == "Total d'unités")
295 s = pd.Series({r["PÉRIODE DE RÉFÉRENCE"]: float(r["VALEUR"]) for r in rows}) 313 s = pd.Series({r["PÉRIODE DE RÉFÉRENCE"]: float(r["VALEUR"]) for r in rows})
296 s.index = pd.to_datetime(s.index) 314 s.index = pd.to_datetime(s.index)
@@ -337,11 +355,13 @@ def taux_effectifs_a4(mois: str = "2026-08-01") -> dict:
337 d = dict(zip(hdr, rec)) 355 d = dict(zip(hdr, rec))
338 # libellés des séries (bloc SERIES du fichier) 356 # libellés des séries (bloc SERIES du fichier)
339 labels = {r[0]: r[1] for r in rows[:i] if len(r) >= 2 and r[0].startswith("V1226677")} 357 labels = {r[0]: r[1] for r in rows[:i] if len(r) >= 2 and r[0].startswith("V1226677")}
340− assure = [k for k, v in labels.items() if "Insured" in v and "Uninsured" not in v and "5 years" in v and "Funds advanced" in v] 358+ desc = {r[0]: (r[1], r[2]) for r in rows[:i] if len(r) >= 3 and r[0].startswith("V1226677")}
341− non_assure = [k for k, v in labels.items() if "Uninsured" in v and "5 years" in v and "Funds advanced" in v] 359+ def _cle(assure: bool):
342− return {"mois": mois, "assure_fixe_5ans_plus": float(d[assure[0]]) if assure else float("nan"), 360+ cible = "Residential mortgages, insured" if assure else "Residential mortgages, uninsured"
343− "non_assure_fixe_5ans_plus": float(d[non_assure[0]]) if non_assure else float("nan"), 361+ return [k for k, (lab, de) in desc.items() if lab == "Fixed rate, 5 years and over" and de.startswith("Mortgages - Funds advanced") and cible in de]
344− "labels_assure": [labels[k] for k in assure], "labels_non_assure": [labels[k] for k in non_assure]} 362+ ka, kn = _cle(True), _cle(False)
363+ return {"mois": mois, "assure_fixe_5ans_plus": float(d[ka[0]]), "non_assure_fixe_5ans_plus": float(d[kn[0]]),
364+ "serie_assure": ka[0], "serie_non_assure": kn[0]}
345 365
346 366
347 def ipc_quebec(produit: str = "Ensemble") -> pd.Series: 367 def ipc_quebec(produit: str = "Ensemble") -> pd.Series:
@@ -480,22 +500,26 @@ def main() -> None:
480 print("\n=== 2. Dynamique stock-flux (SYNTHÉTIQUE) ===") 500 print("\n=== 2. Dynamique stock-flux (SYNTHÉTIQUE) ===")
481 pc = ParametresCycle() 501 pc = ParametresCycle()
482 df = simuler_cycle(pc); ss = etat_stationnaire_cycle(pc); r = resume_cycle(df, pc) 502 df = simuler_cycle(pc); ss = etat_stationnaire_cycle(pc); r = resume_cycle(df, pc)
503+ print(f"kappa (référence dynamique) = {pc.kappa:.3f} ; c1 = {pc.c1:.1f} ; c0 recalibré = {_f(pc.c0)}")
504+ ss0 = etat_stationnaire_cycle(pc, apres_choc=False); print(f"Stationnaire initial : S={_f(ss0['S'])} R={_f(ss0['R'])} P={_f(ss0['P'])}")
483 print(f"Stationnaire après choc : S={_f(ss['S'])} R={_f(ss['R'])} ({ss['R']/12:.0f} $/mois) P={_f(ss['P'])} C={_f(ss['C'])}/an") 505 print(f"Stationnaire après choc : S={_f(ss['S'])} R={_f(ss['R'])} ({ss['R']/12:.0f} $/mois) P={_f(ss['P'])} C={_f(ss['C'])}/an")
484 print({k: (round(v, 2) if isinstance(v, float) else v) for k, v in r.items()}) 506 print({k: (round(v, 2) if isinstance(v, float) else v) for k, v in r.items()})
485− for L in (1, 2, 4): 507+ for kap in (el["kappa"], pc.kappa):
486− rc = racines_caracteristiques(el["kappa"], p.delta, L) 508+ for L in (1, 2, 3):
487− print(f"Racines (kappa={el['kappa']:.3f}, L={L}) : module={rc['module']:.3f} oscille={rc['oscille']} période={rc['periode']:.1f} ans stable={rc['stable']}") 509+ rc = racines_caracteristiques(kap, p.delta, L)
510+ print(f"Racines (kappa={kap:.3f}, L={L}) : module={rc['module']:.3f} oscille={rc['oscille']} période={rc['periode']:.1f} ans stable={rc['stable']}")
488 for k in (0.12, 0.30, 0.60, 1.20): 511 for k in (0.12, 0.30, 0.60, 1.20):
489 c = condition_oscillation_L1(k, p.delta) 512 c = condition_oscillation_L1(k, p.delta)
490 print(f"L=1, kappa={k}: seuil={c['seuil']:.4f} oscille={c['oscille']} module={c['module']:.3f} période={c['periode']:.2f} stable={c['stable']}") 513 print(f"L=1, kappa={k}: seuil={c['seuil']:.4f} oscille={c['oscille']} module={c['module']:.3f} période={c['periode']:.2f} stable={c['stable']}")
491 sens = sensibilite_cycle(pc) 514 sens = sensibilite_cycle(pc)
492− print(sens[(sens["lam"] == 0.5)][["L", "mult_c1", "R_pointe_pct", "R_creux_pct", "v_max_pct", "C_max_annuel", "periode_trim", "amortissement"]].to_string(index=False)) 515+ print(sens[(sens["lam"] == 0.75)][["L", "mult_c1", "kappa", "R_pointe_pct", "R_creux_pct", "v_max_pct", "C_max_annuel", "periode_trim", "amortissement"]].to_string(index=False))
493 516
494 print("\n=== 3. Données réelles ===") 517 print("\n=== 3. Données réelles ===")
495 ann = mises_en_chantier_annuelles() 518 ann = mises_en_chantier_annuelles()
496 cols = ["total", "individuelles", "collectifs", "part_individuelles", "part_collectifs", "trimestres"] 519 cols = ["total", "individuelles", "collectifs", "part_individuelles", "part_collectifs", "trimestres"]
497 print(ann.loc[[1990, 2000, 2005, 2010, 2015, 2020, 2021, 2022, 2023, 2024, 2025, 2026], cols]) 520 print(ann.loc[[1990, 2000, 2005, 2010, 2015, 2020, 2021, 2022, 2023, 2024, 2025, 2026], cols])
498− mtl = mises_en_chantier_montreal_annuelles(); print("Montréal RMR :", mtl.loc[[2024, 2025, 2026]].to_dict()) 521+ mtl = mises_en_chantier_montreal_annuelles(); print("Montréal RMR (34-10-0143) :", mtl.loc[[2024, 2025, 2026]].to_dict())
522+ qc143 = mises_en_chantier_montreal_annuelles("Québec"); print("Québec centres 10 000+ (34-10-0143) :", qc143.loc[[2024, 2025, 2026]].to_dict())
499 ch = changements_taux_directeur(); print(ch) 523 ch = changements_taux_directeur(); print(ch)
500 print(taux_effectifs_a4()) 524 print(taux_effectifs_a4())
501 cu = serie_cout_usage() 525 cu = serie_cout_usage()
added analysis/python/cases/ch05_rente.py +328 −0
@@ -0,0 +1,328 @@
1+#!/usr/bin/env python3
2+"""Chapitre 5 — Économie urbaine et rente foncière : calculs de l'exemple intégral et des encadrés.
3+
4+Imprime tous les chiffres cités dans le chapitre 5 :
5+ * modèle monocentrique (Alonso–Muth–Mills) simulé avec des PARAMÈTRES PÉDAGOGIQUES DÉCLARÉS (section PARAMS) :
6+ gradient du prix du logement p(d) = p0 (1 - t d / y)^(1/beta), gradient exponentiel approché, rente foncière
7+ r(d) = r0 (p(d)/p0)^(1/alpha), densité, frontière urbaine, courbes d'enchère de von Thünen et d'Alonso ;
8+ * gradient prix/distance observé dans le dossier du cas 1 (Gatineau, DONNÉES SYNTHÉTIQUES, graine 20261031) :
9+ tranches de distance, régressions avec et sans indicatrices de secteur (traite.hedonique.mco), coefficient
10+ `dist_centre` du modèle hédonique de référence (traite.cas1.hedonique) ;
11+ * décomposition interrégionale de la valeur par logement du cas 4 (traite.cas4.sommaire) ;
12+ * valeur résiduelle du terrain et effet de levier ;
13+ * capitalisation de la taxe foncière : formule exacte et approchée, taux effectif, taux 2026 de Québec,
14+ Gatineau et Montréal (sources : notes-B/E du dépôt), grille de sensibilité.
15+
16+Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch05_rente.py
17+Les régressions utilisent traite.hedonique.mco ; les données des cas passent par traite.cas1 et traite.cas4."""
18+from __future__ import annotations
19+import sys, pathlib, math
20+import numpy as np, pandas as pd
21+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
22+from traite import cas1, cas4
23+from traite import hedonique as h
24+
25+ROOT = pathlib.Path(__file__).resolve().parents[3]
26+
27+# ----------------------------------------------------------------------------- paramètres pédagogiques (déclarés)
28+PARAMS = {
29+ "y": 80000.0, # revenu annuel du ménage ($/an)
30+ "t": 700.0, # coût annuel de navettage par kilomètre de distance au centre ($/km/an), temps compris
31+ "beta": 0.30, # part du revenu net de transport consacrée au logement
32+ "alpha": 0.30, # part du terrain dans le coût de production du logement (Cobb-Douglas)
33+ "p0": 260.0, # prix annuel du service de logement au centre ($/m² de plancher/an)
34+ "r0": 80.0, # rente foncière annuelle au centre ($/m² de terrain/an)
35+ "ra": 2.0, # rente alternative (agricole, conversion comprise) à la frontière ($/m²/an)
36+ "i": 0.05, # taux de capitalisation des rentes foncières (annuel)
37+}
38+# courbes d'enchère linéaires de trois usages (illustration d'Alonso) : (rente au centre $/m²/an, pente $/m²/an/km)
39+ALONSO = {"Commerces et bureaux": (300.0, 25.0), "Résidentiel dense": (120.0, 6.0), "Résidentiel pavillonnaire": (60.0, 1.8)}
40+# von Thünen (exercice 5.1) : rendement Y (t/ha), prix p ($/t), coût c ($/t), fret f ($/t/km)
41+THUNEN = {"Maraîchage": dict(Y=20.0, p=300.0, c=250.0, f=3.0), "Grandes cultures": dict(Y=4.0, p=500.0, c=350.0, f=5.0)}
42+
43+# taux de taxe foncière 2026 (par 100 $ d'évaluation) — sources : vqtaux2026 ; gatineaureglement994 ; ledevoirmtl2026 (secondaire) ; taxescolaire2026
44+TAUX_2026 = {
45+ "Québec (1 à 5 logements)": 0.7464, "Québec (6 logements et plus)": 0.7697,
46+ "Gatineau (taux de base)": 0.5224, "Montréal (taxe foncière générale, hors arrondissement)": 0.4631,
47+ "Taxe scolaire 2026-2027 (toute la province)": 0.07899,
48+}
49+FACTEUR_2026 = {"Gatineau": 1.02, "Montréal": 1.00} # facteurs comparatifs 2026 publiés (gatmutations ; mtlmutations2026)
50+
51+
52+# ----------------------------------------------------------------------------- von Thünen et Alonso
53+def rente_thunen(d, Y: float, p: float, c: float, f: float) -> np.ndarray:
54+ """R(d) = Y (p - c) - Y f d (rente par hectare et par an)."""
55+ return Y * (p - c) - Y * f * np.asarray(d, float)
56+
57+def distance_limite_thunen(Y: float, p: float, c: float, f: float) -> float:
58+ """Distance où la rente s'annule : d* = (p - c) / f."""
59+ return (p - c) / f
60+
61+def frontiere_usages(R1: float, t1: float, R2: float, t2: float) -> float:
62+ """Distance où deux courbes d'enchère linéaires R1 - t1 d et R2 - t2 d se croisent."""
63+ return (R1 - R2) / (t1 - t2)
64+
65+def enveloppe_alonso(d, usages: dict = ALONSO, ra: float = PARAMS["ra"]) -> tuple[np.ndarray, list]:
66+ d = np.asarray(d, float)
67+ courbes = {k: R0 - t * d for k, (R0, t) in usages.items()}
68+ env = np.maximum.reduce([*courbes.values(), np.full_like(d, ra)])
69+ return env, courbes
70+
71+
72+# ----------------------------------------------------------------------------- modèle monocentrique
73+def prix_logement(d, P: dict = PARAMS) -> np.ndarray:
74+ """p(d) = p0 (1 - t d / y)^(1/beta) : solution exacte de la condition de Muth avec part budgétaire constante."""
75+ d = np.asarray(d, float)
76+ base = np.clip(1.0 - P["t"] * d / P["y"], 1e-12, None)
77+ return P["p0"] * base ** (1.0 / P["beta"])
78+
79+def gradient_exponentiel(P: dict = PARAMS) -> float:
80+ """Pente logarithmique du prix au centre : gamma = t / (beta y) (approximation exponentielle)."""
81+ return P["t"] / (P["beta"] * P["y"])
82+
83+def prix_exponentiel(d, P: dict = PARAMS) -> np.ndarray:
84+ return P["p0"] * np.exp(-gradient_exponentiel(P) * np.asarray(d, float))
85+
86+def consommation_logement(d, P: dict = PARAMS) -> np.ndarray:
87+ """q(d) = beta (y - t d) / p(d) : surface de plancher consommée par ménage (m²)."""
88+ d = np.asarray(d, float)
89+ return P["beta"] * (P["y"] - P["t"] * d) / prix_logement(d, P)
90+
91+def rente_fonciere(d, P: dict = PARAMS) -> np.ndarray:
92+ """r(d) = r0 (p(d)/p0)^(1/alpha) : profit nul des producteurs de logement (Cobb-Douglas)."""
93+ return P["r0"] * (prix_logement(d, P) / P["p0"]) ** (1.0 / P["alpha"])
94+
95+def intensite_capital(d, P: dict = PARAMS) -> np.ndarray:
96+ """Plancher par m² de terrain, normalisé à 1 au centre : h(d)/h(0) = (p(d)/p0)^((1-alpha)/alpha)."""
97+ return (prix_logement(d, P) / P["p0"]) ** ((1.0 - P["alpha"]) / P["alpha"])
98+
99+def densite(d, P: dict = PARAMS) -> np.ndarray:
100+ """Ménages par m² de terrain, normalisée à 1 au centre : D(d) = h(d)/q(d)."""
101+ d = np.asarray(d, float)
102+ D = intensite_capital(d, P) / consommation_logement(d, P)
103+ return D / (intensite_capital(0.0, P) / consommation_logement(0.0, P))
104+
105+def frontiere_urbaine(P: dict = PARAMS) -> float:
106+ """Distance d_bar où r(d_bar) = ra (solution analytique)."""
107+ ratio_p = (P["ra"] / P["r0"]) ** P["alpha"] # p(d_bar)/p0
108+ return (1.0 - ratio_p ** P["beta"]) * P["y"] / P["t"]
109+
110+def demi_distance(gamma: float) -> float:
111+ """Distance qui divise le prix par deux sous un gradient exponentiel : ln 2 / gamma."""
112+ return math.log(2.0) / gamma
113+
114+def valeur_terrain(rente: float, i: float = PARAMS["i"], g: float = 0.0) -> float:
115+ """Valeur du terrain = rente / (i - g) (rente perpétuelle, croissance g)."""
116+ return rente / (i - g)
117+
118+def decomposition_capozza(ra: float, i: float, cout_conversion: float, rente_d: float, prime_croissance: float) -> dict:
119+ """Capozza–Helsley (1989) : P_L = ra/i + C + (r(d) - ra)/i + prime de croissance."""
120+ comp = {"valeur_agricole": ra / i, "cout_conversion": cout_conversion, "accessibilite": (rente_d - ra) / i, "prime_croissance": prime_croissance}
121+ comp["total"] = sum(comp.values()); return comp
122+
123+
124+# ----------------------------------------------------------------------------- rente résiduelle et levier
125+def valeur_residuelle(V: float, CN: float, depreciation: float) -> float:
126+ """V_L = V - (C_N - D) : valeur du terrain par résidu."""
127+ return V - (CN - depreciation)
128+
129+def levier_residuel(V: float, VL: float) -> float:
130+ """Élasticité de V_L par rapport à V : V / V_L."""
131+ return V / VL
132+
133+def elasticite_rente_prix(alpha: float) -> float:
134+ return 1.0 / alpha
135+
136+
137+# ----------------------------------------------------------------------------- capitalisation fiscale
138+def valeur_avec_taxe(R: float, i: float, tau: float) -> float:
139+ """V = R / (i + tau) : rente nette de taxe capitalisée (taxe proportionnelle à la valeur)."""
140+ return R / (i + tau)
141+
142+def capitalisation_taxe(V0: float, tau0: float, tau1: float, i: float, kappa: float = 1.0) -> dict:
143+ """Effet d'un changement de taux de taxe tau0 -> tau1 sur une valeur V0 établie sous tau0.
144+ exact : V1 = R/(i+tau1) avec R = V0 (i+tau0) ; approché : dV = -kappa * dT / i avec dT = (tau1-tau0) V0."""
145+ R = V0 * (i + tau0)
146+ V1 = R / (i + tau1)
147+ dT = (tau1 - tau0) * V0
148+ return {"R": R, "V1_exact": V1, "dV_exact": V1 - V0, "dT": dT, "dV_approx": -kappa * dT / i,
149+ "dV_exact_kappa": kappa * (V1 - V0), "dV_pct_exact": V1 / V0 - 1}
150+
151+def taux_effectif(tau_role: float, facteur: float) -> float:
152+ """Taux sur la valeur marchande : tau_eff = tau_role / F, où F est le facteur comparatif (V_marché = F × V_rôle)."""
153+ return tau_role / facteur
154+
155+def grille_capitalisation(V0: float, d_taux_pb: list, taux_cap: list, kappa: float = 1.0) -> pd.DataFrame:
156+ """Perte de valeur (en $) pour un écart de taux de taxe (points de base de la valeur) selon le taux de capitalisation."""
157+ rows = []
158+ for pb in d_taux_pb:
159+ row = {"ecart_pb": pb}
160+ for r in taux_cap:
161+ row[f"r={r:.0%}"] = -kappa * (pb / 10000.0) * V0 / r
162+ rows.append(row)
163+ return pd.DataFrame(rows).set_index("ecart_pb")
164+
165+
166+# ----------------------------------------------------------------------------- cas 1 : gradient observé
167+TRANCHES = [0, 2, 4, 6, 8, 10, 12, 20, 35]
168+ETIQUETTES = ["0-2", "2-4", "4-6", "6-8", "8-10", "10-12", "12-20", "20 et plus"]
169+
170+def tranches_distance(v: pd.DataFrame) -> pd.DataFrame:
171+ v = v.copy(); v["prix_m2"] = v["prix_vente"] / v["superficie_habitable_m2"]
172+ v["tranche"] = pd.cut(v["dist_centre_km"], TRANCHES, labels=ETIQUETTES, right=True)
173+ g = v.groupby("tranche", observed=True).agg(n=("prix_vente", "size"), dist_mediane=("dist_centre_km", "median"),
174+ prix_median=("prix_vente", "median"), prix_m2_median=("prix_m2", "median"),
175+ superficie_mediane=("superficie_habitable_m2", "median"),
176+ terrain_median=("superficie_terrain_m2", "median"))
177+ g["secteur_dominant"] = v.groupby("tranche", observed=True)["secteur"].agg(lambda s: s.value_counts().index[0])
178+ return g
179+
180+def gradients_cas1(v: pd.DataFrame, modele: dict | None = None) -> dict:
181+ """Quatre spécifications du gradient prix/distance (coefficients en log par km)."""
182+ y = np.log(v["prix_vente"].astype(float).values)
183+ d = v["dist_centre_km"].astype(float).values
184+ out = {}
185+ # (a) brut : ln(P/S) = a - gamma d
186+ r = h.mco(np.column_stack([np.ones_like(d), d]), np.log(v["prix_vente"] / v["superficie_habitable_m2"]).values)
187+ out["brut_prix_m2"] = {"gamma": -r["beta"][1], "se": r["se_hc1"][1], "r2": r["r2"], "n": r["n"]}
188+ # (b) contrôlé : caractéristiques du modèle de référence sans secteurs, distance linéaire
189+ X = cas1.matrice(v).drop(columns=["dist_centre"] + [f"sect_{s}" for s in cas1.SECTEURS[1:]])
190+ X["dist"] = d
191+ r = h.mco(X.values, y)
192+ out["controle_lineaire"] = {"gamma": -r["beta"][-1], "se": r["se_hc1"][-1], "r2": r["r2"], "n": r["n"]}
193+ # (c) contrôlé, quadratique
194+ Xq = X.copy(); Xq["dist2"] = d ** 2 / 100.0
195+ r = h.mco(Xq.values, y)
196+ out["controle_quadratique"] = {"b1": r["beta"][-2], "b2_100": r["beta"][-1], "se1": r["se_hc1"][-2], "se2": r["se_hc1"][-1], "r2": r["r2"],
197+ "gamma_0": -r["beta"][-2], "gamma_10": -(r["beta"][-2] + 2 * r["beta"][-1] * 10 / 100.0)}
198+ # (d) contrôlé, aire urbaine contiguë (d <= 16 km : Hull, Gatineau, Aylmer)
199+ m = d <= 16.0
200+ r = h.mco(X.values[m], y[m])
201+ out["controle_urbain_16km"] = {"gamma": -r["beta"][-1], "se": r["se_hc1"][-1], "r2": r["r2"], "n": int(m.sum())}
202+ # (e) modèle de référence (secteurs + max(d-3, 0))
203+ modele = modele or cas1.hedonique(v)
204+ c = modele["coef"]
205+ out["reference"] = {"gamma_au_dela_3km": -float(c.loc["dist_centre", "coef"]), "se": float(c.loc["dist_centre", "se_hc1"]),
206+ "t": float(c.loc["dist_centre", "t"]), "r2": modele["r2"],
207+ "secteurs": {s: float(c.loc[f"sect_{s}", "coef"]) for s in cas1.SECTEURS[1:]},
208+ "secteurs_pct": {s: float(c.loc[f"sect_{s}", "effet_pct"]) for s in cas1.SECTEURS[1:]}}
209+ # (f) gradient implicite entre secteurs : médianes par secteur vs distance médiane
210+ gs = v.groupby("secteur").agg(dist=("dist_centre_km", "median"), prix=("prix_vente", "median"), n=("prix_vente", "size"))
211+ out["secteurs_medianes"] = gs
212+ return out
213+
214+def gradient_exact_vs_exponentiel(P: dict = PARAMS) -> pd.DataFrame:
215+ d = np.array([0.0, 5.0, 10.0, 15.0, 20.0, 25.0])
216+ pe, pa = prix_logement(d, P), prix_exponentiel(d, P)
217+ return pd.DataFrame({"d_km": d, "p_exact": pe, "p_exponentiel": pa, "ecart_pct": pa / pe - 1, "q_m2": consommation_logement(d, P),
218+ "rente_fonciere": rente_fonciere(d, P), "densite_relative": densite(d, P)})
219+
220+
221+# ----------------------------------------------------------------------------- cas 4 : décomposition interrégionale
222+def decomposition_rmr(pf: pd.DataFrame, reference: str = "Montréal") -> pd.DataFrame:
223+ """ln(V/n) = ln(RNE/n) - ln(R) par RMR, et écarts à la RMR de référence."""
224+ s = cas4.sommaire(pf)["par_rmr"].copy()
225+ s["rne_par_logement"] = s["rne"] / s["logements"]
226+ s["ln_V_n"] = np.log(s["valeur_par_logement"]); s["ln_RNE_n"] = np.log(s["rne_par_logement"]); s["ln_R"] = np.log(s["taux_pondere"])
227+ ref = s.loc[reference]
228+ s["ecart_ln_V_n"] = s["ln_V_n"] - ref["ln_V_n"]
229+ s["contrib_rne"] = s["ln_RNE_n"] - ref["ln_RNE_n"]
230+ s["contrib_taux"] = -(s["ln_R"] - ref["ln_R"])
231+ s["ratio_valeur"] = s["valeur_par_logement"] / ref["valeur_par_logement"]
232+ return s.sort_values("valeur_par_logement", ascending=False)
233+
234+
235+# ----------------------------------------------------------------------------- exemple intégral (impression)
236+def fmt(x, dec=0):
237+ s = f"{x:,.{dec}f}".replace(",", " ").replace(".", ",")
238+ return s
239+
240+def main():
241+ P = PARAMS
242+ print("=== 5.A Modèle monocentrique simulé (paramètres pédagogiques déclarés) ===")
243+ print({k: v for k, v in P.items()})
244+ g = gradient_exponentiel(P); dbar = frontiere_urbaine(P)
245+ print(f"gradient exponentiel au centre gamma = t/(beta y) = {g:.5f} par km ({g*100:.2f} %/km) ; demi-distance ln2/gamma = {demi_distance(g):.1f} km")
246+ print(f"frontière urbaine d_bar (r = ra) = {dbar:.2f} km ; p(d_bar) = {prix_logement(dbar, P):.2f} $/m²/an ; élasticité rente/prix 1/alpha = {elasticite_rente_prix(P['alpha']):.3f}")
247+ print(f"valeur du terrain au centre r0/i = {fmt(valeur_terrain(P['r0'], P['i']))} $/m² ; à la frontière ra/i = {valeur_terrain(P['ra'], P['i']):.0f} $/m²")
248+ tab = gradient_exact_vs_exponentiel(P)
249+ print(tab.round(3).to_string(index=False))
250+ print(f"q(0) = {consommation_logement(0.0, P):.1f} m² ; q(10) = {consommation_logement(10.0, P):.1f} m² ; q(d_bar) = {consommation_logement(dbar, P):.1f} m²")
251+ print("\n--- Alonso : frontières entre usages (km) ---")
252+ (R1, t1), (R2, t2), (R3, t3) = ALONSO.values()
253+ f12, f23 = frontiere_usages(R1, t1, R2, t2), frontiere_usages(R2, t2, R3, t3)
254+ f3a = (R3 - P["ra"]) / t3
255+ print(f"commerces/dense : {f12:.2f} km ; dense/pavillonnaire : {f23:.2f} km ; pavillonnaire/agricole : {f3a:.2f} km")
256+ print("\n--- von Thünen (exercice 5.1) ---")
257+ for k, q in THUNEN.items():
258+ print(f"{k}: R0 = {q['Y']*(q['p']-q['c']):.0f} $/ha/an ; pente = {q['Y']*q['f']:.0f} $/ha/an/km ; distance limite = {distance_limite_thunen(**q):.2f} km")
259+ a, b = THUNEN.values()
260+ print(f"frontière maraîchage/grandes cultures : {frontiere_usages(a['Y']*(a['p']-a['c']), a['Y']*a['f'], b['Y']*(b['p']-b['c']), b['Y']*b['f']):.2f} km")
261+
262+ print("\n=== 5.B Cas 1 (Gatineau, DONNÉES SYNTHÉTIQUES) : gradient prix/distance ===")
263+ d1 = cas1.charger(); v = d1["ventes"]; m = cas1.hedonique(v)
264+ tr = tranches_distance(v)
265+ print(tr.round(2).to_string())
266+ gr = gradients_cas1(v, m)
267+ for k in ["brut_prix_m2", "controle_lineaire", "controle_urbain_16km"]:
268+ r = gr[k]; print(f"{k}: gamma = {r['gamma']*100:.3f} %/km (e.-t. {r['se']*100:.3f}) ; R² = {r['r2']:.3f} ; n = {r['n']} ; demi-distance = {demi_distance(r['gamma']):.1f} km")
269+ q = gr["controle_quadratique"]
270+ print(f"controle_quadratique: b1 = {q['b1']*100:.3f} %/km (e.-t. {q['se1']*100:.3f}), b2/100 = {q['b2_100']*100:.4f} (e.-t. {q['se2']*100:.4f}) ; gamma(0) = {q['gamma_0']*100:.3f}, gamma(10 km) = {q['gamma_10']*100:.3f} %/km ; R² = {q['r2']:.3f}")
271+ rf = gr["reference"]
272+ print(f"reference (traite.cas1.hedonique) : dist_centre = -{rf['gamma_au_dela_3km']*100:.3f} %/km au-delà de 3 km (e.-t. {rf['se']*100:.3f}, t = {rf['t']:.2f}) ; R² = {rf['r2']:.3f}")
273+ print(" secteurs (réf. Hull), coef. et effet % :", {k: (round(v_, 4), round(rf['secteurs_pct'][k] * 100, 1)) for k, v_ in rf["secteurs"].items()})
274+ print(gr["secteurs_medianes"].round(2).to_string())
275+ gs = gr["secteurs_medianes"]
276+ pente = (math.log(gs.loc["Gatineau", "prix"]) - math.log(gs.loc["Hull", "prix"])) / (gs.loc["Gatineau", "dist"] - gs.loc["Hull", "dist"])
277+ print(f"gradient implicite Hull -> Gatineau (médianes) : {pente*100:.3f} %/km ; Hull -> Buckingham : {(math.log(gs.loc['Buckingham','prix'])-math.log(gs.loc['Hull','prix']))/(gs.loc['Buckingham','dist']-gs.loc['Hull','dist'])*100:.3f} %/km")
278+ print(f"sujet du cas 1 : dist_centre = {d1['sujet']['dist_centre_km']} km ; effet distance du modèle de référence = {max(d1['sujet']['dist_centre_km']-3,0)*rf['gamma_au_dela_3km']*100:.2f} %")
279+ # ce que vaut 1 km de plus pour le sujet selon la spécification contrôlée
280+ V_ind = 551029.0 # indication brute du ch. 16
281+ print(f"à partir de l'indication brute du ch. 16 ({fmt(V_ind)} $), 1 km de plus vaut {fmt(-V_ind*gr['controle_lineaire']['gamma'])} $ (spécification contrôlée) ; 5 km : {fmt(V_ind*(math.exp(-5*gr['controle_lineaire']['gamma'])-1))} $")
282+
283+ print("\n=== 5.C Cas 4 (portefeuille, DONNÉES SYNTHÉTIQUES) : valeur par logement par RMR ===")
284+ d4 = cas4.charger(); dec = decomposition_rmr(d4["portefeuille"])
285+ cols = ["n", "logements", "valeur_par_logement", "rne_par_logement", "taux_pondere", "ratio_valeur", "ecart_ln_V_n", "contrib_rne", "contrib_taux"]
286+ print(dec[cols].round(4).to_string())
287+ for rmr in dec.index:
288+ if rmr != "Montréal":
289+ e = dec.loc[rmr]; part = e["contrib_rne"] / e["ecart_ln_V_n"] if e["ecart_ln_V_n"] != 0 else float("nan")
290+ print(f" {rmr}: écart ln = {e['ecart_ln_V_n']:.3f} dont RNE/log. {e['contrib_rne']:.3f} ({part*100:.0f} %) et taux {e['contrib_taux']:.3f} ({(1-part)*100:.0f} %)")
291+
292+ print("\n=== 5.D Rente résiduelle et levier ===")
293+ CN = 397567.33; dep = 0.30 * CN; V = 551029.0
294+ VL = valeur_residuelle(V, CN, dep)
295+ print(f"C_N (ch. 21) = {fmt(CN,2)} $ ; dépréciation hypothétique 30 % = {fmt(dep,2)} $ ; bâtiment déprécié = {fmt(CN-dep,2)} $")
296+ print(f"V (indication brute ch. 16) = {fmt(V)} $ -> V_L = {fmt(VL)} $ ({VL/V*100:.1f} % de V) ; levier V/V_L = {levier_residuel(V, VL):.2f}")
297+ for dv in (-0.05, 0.05, -0.10):
298+ VL2 = valeur_residuelle(V * (1 + dv), CN, dep)
299+ print(f" V {dv:+.0%} -> V_L = {fmt(VL2)} $ ({VL2/VL-1:+.1%})")
300+ print(f"élasticité de la rente foncière au prix du logement (alpha = {P['alpha']}) : {elasticite_rente_prix(P['alpha']):.2f} ; +10 % de prix -> rente x {1.1**elasticite_rente_prix(P['alpha']):.3f}")
301+ cz = decomposition_capozza(P["ra"], P["i"], 25.0, rente_fonciere(10.0, P), 60.0)
302+ print("Capozza-Helsley à 10 km ($/m²) :", {k: round(v_, 1) for k, v_ in cz.items()})
303+
304+ print("\n=== 5.E Capitalisation de la taxe foncière ===")
305+ for k, t_ in TAUX_2026.items():
306+ print(f" {k}: {t_:.5f} $/100 $ = {t_/100*10000:.1f} pb de la valeur")
307+ V0 = 500000.0; i = 0.05
308+ for pb in (10, 22.4, 50):
309+ c = capitalisation_taxe(V0, 0.0070, 0.0070 + pb / 10000, i)
310+ print(f" écart de {pb} pb sur {fmt(V0)} $ : dT = {fmt(c['dT'])} $/an ; dV approché = {fmt(c['dV_approx'])} $ ; dV exact = {fmt(c['dV_exact'])} $ ({c['dV_pct_exact']*100:.2f} %) ; à 60 % : {fmt(0.6*c['dV_approx'])} $")
311+ ecart_qc_gat = (TAUX_2026["Québec (1 à 5 logements)"] - TAUX_2026["Gatineau (taux de base)"]) / 100
312+ print(f"écart nominal Québec (1-5 log.) - Gatineau (base) = {ecart_qc_gat*10000:.1f} pb ; sur {fmt(V0)} $ : {fmt(ecart_qc_gat*V0)} $/an ; capitalisé à 5 % : {fmt(ecart_qc_gat*V0/i)} $ ; à 60 % : {fmt(0.6*ecart_qc_gat*V0/i)} $")
313+ for ville, F in FACTEUR_2026.items():
314+ key = "Gatineau (taux de base)" if ville == "Gatineau" else "Montréal (taxe foncière générale, hors arrondissement)"
315+ print(f" taux effectif {ville} : {TAUX_2026[key]:.4f}/{F} = {taux_effectif(TAUX_2026[key], F):.4f} $/100 $ de valeur marchande")
316+ print(f" Québec avec un facteur hypothétique de 1,10 : {taux_effectif(TAUX_2026['Québec (1 à 5 logements)'], 1.10):.4f} $/100 $")
317+ sujet_role = d1["sujet"]["valeur_role_2025"]
318+ T_gat = sujet_role * TAUX_2026["Gatineau (taux de base)"] / 100; T_sco = sujet_role * TAUX_2026["Taxe scolaire 2026-2027 (toute la province)"] / 100
319+ print(f"sujet du cas 1 (rôle fictif {fmt(sujet_role)} $) : taxe générale au taux de base Gatineau = {fmt(T_gat)} $ ; scolaire (sans l'exemption de 25 000 $) = {fmt(T_sco)} $")
320+ print(f" écart 6+ vs 1-5 logements à Québec : {(TAUX_2026['Québec (6 logements et plus)']-TAUX_2026['Québec (1 à 5 logements)'])*100:.1f} pb ; sur un immeuble de 2 000 000 $ : {fmt((TAUX_2026['Québec (6 logements et plus)']-TAUX_2026['Québec (1 à 5 logements)'])/100*2e6)} $/an ; capitalisé à 5,5 % : {fmt((TAUX_2026['Québec (6 logements et plus)']-TAUX_2026['Québec (1 à 5 logements)'])/100*2e6/0.055)} $")
321+ G = grille_capitalisation(V0, [5, 10, 25, 50, 100], [0.04, 0.05, 0.06, 0.07])
322+ print("grille (kappa = 1, V0 = 500 000 $) :\n", G.round(0).to_string())
323+ # coût d'usage : part de la taxe
324+ tau_eff = taux_effectif(TAUX_2026["Gatineau (taux de base)"], 1.02) / 100 + TAUX_2026["Taxe scolaire 2026-2027 (toute la province)"] / 100
325+ print(f"taux effectif total Gatineau (base + scolaire) ≈ {tau_eff*100:.4f} $/100 $ ; part de la taxe dans un coût d'usage de (i=4,12 %, tau, delta=1,5 %, g=2 %) : {tau_eff/(0.0412+tau_eff+0.015-0.02)*100:.1f} %")
326+
327+if __name__ == "__main__":
328+ main()
added analysis/python/cases/ch09_ratios.py +345 −0
@@ -0,0 +1,345 @@
1+#!/usr/bin/env python3
2+"""Chapitre 9 — Évaluation foncière municipale et contestation (DONNÉES SYNTHÉTIQUES du cas 1, graine 20261031).
3+
4+Étude de ratios (proportion médiane, facteur comparatif, COD, PRD, PRB, écart type relatif de l'annexe III du
5+Règlement sur le rôle), dossier de contestation du sujet du cas 1 (valeur au rôle fictive 468 000 $) et d'un acheteur
6+récent (vente G0258), compte de taxes 2026 de Gatineau (règlement 994-2025), droits de mutation 2026, échéancier des
7+recours (LFM art. 70, 81, 130, 138.3, 138.4, 138.5), sensibilité et exercices. Imprime tous les chiffres cités.
8+
9+Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch09_ratios.py
10+Bibliothèque : traite.cas1 (modèle hédonique partagé), traite.hedonique.statistiques_ratio, traite.comparables."""
11+from __future__ import annotations
12+import sys, pathlib, datetime as dt
13+import numpy as np, pandas as pd
14+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
15+from traite import cas1, hedonique as h, comparables as cp
16+
17+ROOT = pathlib.Path(__file__).resolve().parents[3]
18+REF = pd.Timestamp("2024-07-01") # date de référence au marché du rôle fictif (LFM art. 46 al. 1)
19+ROLE_FICTIF = (2026, 2027, 2028) # exercices couverts par un rôle dont la référence est le 1er juillet 2024
20+GRAINE = 20261031
21+
22+# ----------------------------------------------------------------------------- paramètres fiscaux vérifiés (Gatineau 2026)
23+# Règlement 994-2025 (adopté le 18 décembre 2025), par 1 000 $ d'évaluation ; voir data/raw/gatineau_r994_2025/README.md
24+TAUX_GATINEAU_2026 = {
25+ "base": 5.224, # art. 1 (catégorie résiduelle)
26+ "non_residentiel": 29.372, # art. 2
27+ "industriel": 26.120, # art. 3
28+ "terrains_vagues": 20.896, # art. 4
29+ "agricole": 5.090, # art. 5
30+ "six_logements_plus": 6.964, # art. 8
31+ "sto": 1.417, # art. 11 taxe spéciale transport en commun
32+ "dette": 0.830, # art. 12 taxe spéciale service de la dette
33+ "ex_hull": 0.036, # art. 15, 1° (secteur de l'ex-Ville de Hull)
34+ "credit_aqueduc": 0.386, "credit_egout": 0.523, # art. 13-14 (immeubles non raccordés)
35+}
36+COMPENSATION_MATIERES_RESIDUELLES = 178.83 # $ par logement, art. 16
37+TAUX_SCOLAIRE_2026_2027 = 0.07899 / 100 # $ par $ d'évaluation uniformisée ajustée (LIP art. 303.4 ; avis 2026)
38+EXEMPTION_SCOLAIRE = 25_000 # LIP art. 303.1 : tranche exemptée
39+FRAIS_REVISION_GATINEAU = [(500_000, 80.00), (2_000_000, 325.00), (5_000_000, 574.50), (float("inf"), 1_149.25)] # règl. 943-2023
40+FRAIS_REVISION_MONTREAL = [(500_000, 90.00), (2_000_000, 360.00), (5_000_000, 605.00), (float("inf"), 1_215.00)]
41+FRAIS_TAQ_2026 = [(500_000, 93.20), (2_000_000, 372.60), (5_000_000, 621.10), (float("inf"), 1_242.50)] # Tarif J-3, r. 3.2
42+# Droits sur les mutations immobilières 2026 (LDMI art. 2 ; barèmes municipaux)
43+BAREME_MUTATION_QC = [(62_900, 0.005), (315_000, 0.01), (float("inf"), 0.015)]
44+BAREME_MUTATION_MTL = [(62_900, 0.005), (315_000, 0.01), (552_300, 0.015), (1_104_700, 0.02), (2_136_500, 0.025), (3_113_000, 0.035), (float("inf"), 0.04)]
45+BAREME_MUTATION_GAT = [(62_900, 0.005), (315_000, 0.01), (500_000, 0.015), (float("inf"), 0.03)] # à compter du 25 février 2026
46+FACTEUR_GATINEAU_2026 = 1.02 # facteur comparatif publié par la Ville (page « Base d'imposition »)
47+FACTEUR_MONTREAL_2026 = 1.00
48+# Seuils IAAO (Standard on Ratio Studies, 2013, table 1-3 et § 9.2.7)
49+IAAO = {"COD_unifamilial_heterogene": (5.0, 15.0), "COD_unifamilial_homogene": (5.0, 10.0), "PRD": (0.98, 1.03), "PRB": (-0.05, 0.05), "niveau": (0.90, 1.10)}
50+# Règlement sur le rôle (F-2.1, r. 13), art. 13 al. 2 et art. 14 al. 1
51+R13_PROPORTION = (0.95, 1.05)
52+R13_ECART_TYPE_RELATIF = [(0.20, 0.36), (0.40, 0.31), (0.60, 0.25), (0.80, 0.19), (1.01, 0.14)] # (indice de concentration unifamiliale < borne, plafond)
53+
54+
55+# ----------------------------------------------------------------------------- outils
56+def charger() -> dict:
57+ d = cas1.charger()
58+ d["modele"] = cas1.hedonique(d["ventes"])
59+ return d
60+
61+def mois_depuis_ref(dates) -> np.ndarray:
62+ return ((pd.to_datetime(dates) - REF).dt.days / 30.4375).to_numpy()
63+
64+def ratios(v: pd.DataFrame, modele: dict) -> pd.DataFrame:
65+ """Ratios bruts (rôle / prix) et ratios après rajustement des prix aux conditions du marché de la date de référence
66+ (r. 10, art. 11-12) : P_ref = P / (1+g)^mois, g = croissance mensuelle du modèle hédonique partagé."""
67+ g = modele["croissance_mensuelle"]
68+ out = v.copy()
69+ out["mois_ref"] = mois_depuis_ref(out["date_vente"])
70+ out["prix_ref"] = out["prix_vente"] / (1.0 + g) ** out["mois_ref"]
71+ out["r_brut"] = out["valeur_role_2025"] / out["prix_vente"]
72+ out["r_aj"] = out["valeur_role_2025"] / out["prix_ref"]
73+ return out
74+
75+def epuration_r10(r: np.ndarray) -> dict:
76+ """Épuration du r. 10 (art. 15-16) : retrait des ratios hors [médiane − 2σ ; médiane + 2σ], σ sur n − 1."""
77+ r = np.asarray(r, float); med = np.median(r); sd = r.std(ddof=1)
78+ lo, hi = med - 2 * sd, med + 2 * sd
79+ garde = (r >= lo) & (r <= hi)
80+ return {"mediane_initiale": med, "sigma": sd, "borne_basse": lo, "borne_haute": hi, "n": len(r), "n_retires": int((~garde).sum()), "garde": garde,
81+ "proportion_mediane": float(np.median(r[garde]))}
82+
83+def ecart_type_relatif(r: np.ndarray) -> float:
84+ """Annexe III du r. 13 : écart type des ratios (n − 1) divisé par le ratio médian."""
85+ r = np.asarray(r, float)
86+ return float(r.std(ddof=1) / np.median(r))
87+
88+def plafond_r13(indice_concentration: float) -> float:
89+ for borne, plafond in R13_ECART_TYPE_RELATIF:
90+ if indice_concentration < borne:
91+ return plafond
92+ return 0.14
93+
94+def liste_de_base(N: int) -> int:
95+ """r. 10, art. 5 : nombre de ventes de la liste de base = max(30 ; N / (15,5 + 0,001 N))."""
96+ return int(np.ceil(max(30.0, N / (15.5 + 0.001 * N))))
97+
98+def prb_proxy_prix(valeurs, prix) -> dict:
99+ """PRB calculé avec le seul prix (log2 P) comme variable explicative — variante de contrôle (hors norme IAAO)."""
100+ V = np.asarray(valeurs, float); P = np.asarray(prix, float); r = V / P; med = np.median(r)
101+ x = np.log2(P); yv = (r - med) / med
102+ X = np.column_stack([np.ones_like(x), x]); b, *_ = np.linalg.lstsq(X, yv, rcond=None)
103+ res = yv - X @ b; se = np.sqrt((res @ res) / (len(x) - 2) / ((x - x.mean()) ** 2).sum())
104+ return {"PRB": float(b[1]), "PRB_se": float(se)}
105+
106+def frais(valeur_role: float, bareme) -> float:
107+ for borne, montant in bareme:
108+ if valeur_role <= borne:
109+ return montant
110+ return bareme[-1][1]
111+
112+def droits_mutation(base: float, bareme=BAREME_MUTATION_QC) -> float:
113+ """Droit sur les mutations immobilières par tranches (LDMI art. 2) ; base = max(prix, contrepartie, rôle × facteur)."""
114+ total, bas = 0.0, 0.0
115+ for haut, taux in bareme:
116+ if base > bas:
117+ total += (min(base, haut) - bas) * taux
118+ bas = haut
119+ return total
120+
121+def compte_taxes_gatineau(valeur_role: float, facteur: float, taux_voisinage: float, secteur_hull: bool = True, raccorde: bool = True, logements: int = 1) -> dict:
122+ """Compte de taxes d'une résidence (catégorie résiduelle, moins de six logements) selon le règlement 994-2025 et la LIP.
123+ Taxes ad valorem par 1 000 $ : taux de l'unité de voisinage (art. 7) + STO (art. 11) + dette (art. 12) [+ ex-Hull 0,036] ;
124+ crédits aqueduc/égout si non raccordé ; taxe scolaire sur (rôle × facteur − 25 000 $) ; compensation matières résiduelles."""
125+ t = TAUX_GATINEAU_2026
126+ taux_municipal = taux_voisinage + t["sto"] + t["dette"] + (t["ex_hull"] if secteur_hull else 0.0)
127+ if not raccorde:
128+ taux_municipal -= t["credit_aqueduc"] + t["credit_egout"]
129+ municipal = valeur_role * taux_municipal / 1000.0
130+ uniformisee = valeur_role * facteur
131+ scolaire = max(uniformisee - EXEMPTION_SCOLAIRE, 0.0) * TAUX_SCOLAIRE_2026_2027
132+ compensations = COMPENSATION_MATIERES_RESIDUELLES * logements
133+ return {"taux_municipal_pour_1000": taux_municipal, "municipal": municipal, "evaluation_uniformisee": uniformisee, "scolaire": scolaire,
134+ "compensations": compensations, "total": municipal + scolaire + compensations,
135+ "taux_marginal_pour_1000": taux_municipal + facteur * TAUX_SCOLAIRE_2026_2027 * 1000.0}
136+
137+def taux_voisinage_gatineau() -> dict:
138+ """Statistiques des 1 092 taux de l'annexe I du règlement 994-2025 (data/raw/gatineau_r994_2025)."""
139+ a = pd.read_csv(ROOT / "data/raw/gatineau_r994_2025/annexe1_taux_voisinage.csv")
140+ t = a["taux_2026_par_1000"].astype(float)
141+ return {"n": len(t), "min": float(t.min()), "max": float(t.max()), "mediane": float(t.median()), "moyenne": float(t.mean())}
142+
143+
144+# ----------------------------------------------------------------------------- étude de ratios
145+def etude(d: dict) -> dict:
146+ v = ratios(d["ventes"], d["modele"])
147+ brut = h.statistiques_ratio(v["valeur_role_2025"], v["prix_vente"])
148+ aj = h.statistiques_ratio(v["valeur_role_2025"], v["prix_ref"])
149+ ep = epuration_r10(v["r_aj"].to_numpy())
150+ k = v[ep["garde"]]
151+ PM = ep["proportion_mediane"]; F = 1.0 / PM
152+ apres = h.statistiques_ratio(k["valeur_role_2025"], k["prix_ref"])
153+ secteurs = v.groupby("secteur")["r_aj"].agg(mediane="median", n="count").sort_values("n", ascending=False)
154+ types = v.groupby("type")["r_aj"].agg(mediane="median", n="count")
155+ bornes = [0, 350_000, 450_000, 550_000, 700_000, np.inf]
156+ v["tranche"] = pd.cut(v["prix_ref"], bornes, labels=["< 350 k$", "350-450 k$", "450-550 k$", "550-700 k$", "≥ 700 k$"])
157+ tranches = v.groupby("tranche", observed=True)["r_aj"].agg(mediane="median", n="count")
158+ v["trimestre"] = v["date_vente"].dt.to_period("Q").astype(str)
159+ trimestres = v.groupby("trimestre").agg(r_brut=("r_brut", "median"), r_aj=("r_aj", "median"), n=("r_brut", "count"))
160+ annees = v.groupby(v["date_vente"].dt.year).agg(r_brut=("r_brut", "median"), r_aj=("r_aj", "median"), n=("r_brut", "count"))
161+ return {"v": v, "brut": brut, "aj": aj, "epuration": ep, "apres_epuration": apres, "PM": PM, "F": F,
162+ "ecart_type_relatif_brut": ecart_type_relatif(v["r_brut"]), "ecart_type_relatif_aj": ecart_type_relatif(v["r_aj"]),
163+ "ecart_type_relatif_epure": ecart_type_relatif(k["r_aj"]), "plafond_r13": plafond_r13(1.0),
164+ "secteurs": secteurs, "types": types, "tranches": tranches, "trimestres": trimestres, "annees": annees,
165+ "prb_prix_aj": prb_proxy_prix(v["valeur_role_2025"], v["prix_ref"]), "prb_prix_brut": prb_proxy_prix(v["valeur_role_2025"], v["prix_vente"]),
166+ "croissance_mensuelle": d["modele"]["croissance_mensuelle"], "croissance_annuelle": (1 + d["modele"]["croissance_mensuelle"]) ** 12 - 1}
167+
168+
169+# ----------------------------------------------------------------------------- dossier du sujet
170+def comparables_reference(d: dict, k: int = 5, fenetre_mois: float = 12.0, rayon_km: float = 3.0) -> dict:
171+ """Comparables du sujet autour de la date de référence (± 12 mois, art. 46 al. 5), mêmes filtres et même distance d'attributs
172+ que le chapitre 16 (ch16_selection), puis ajustements dérivés du modèle hédonique vers la date de référence."""
173+ import ch16_selection as c16
174+ v = c16.preparer(d["ventes"].copy(), d["sujet"]); s = d["sujet"]
175+ v["mois_ref"] = mois_depuis_ref(v["date_vente"])
176+ # la distance d'attributs du chapitre 16 mesure l'ancienneté par rapport à la date d'évaluation ; ici, par rapport à la date de référence
177+ v["mois"] = v["mois_ref"].abs()
178+ suj = dict(s); suj["mois"] = 0.0; suj["dist_km"] = 0.0
179+ v["distance"] = [cp.distance_attributs(suj, r, c16.ECHELLES, c16.POIDS) for r in v.to_dict("records")]
180+ f = v[(v["mois_ref"].abs() <= fenetre_mois) & (v["dist_km"] <= rayon_km) & (v["type"] == s["type"])
181+ & v["superficie_habitable_m2"].between(0.75 * s["superficie_habitable_m2"], 1.25 * s["superficie_habitable_m2"])].sort_values("distance")
182+ top = f.head(k)
183+ adj = cas1.ajustements_hedoniques(d["modele"], top, s, REF.date().isoformat())
184+ desc = top[["id_vente", "date_vente", "superficie_habitable_m2", "superficie_terrain_m2", "age", "qualite_1a5", "garage_places", "sous_sol_fini", "dist_km", "valeur_role_2025"]].rename(columns={"age": "age_bat"})
185+ adj = adj.merge(desc, on="id_vente")
186+ adj["ratio_comparable"] = adj["valeur_role_2025"] / adj["prix_ajuste"]
187+ return {"candidats": f, "comparables": top, "ajustements": adj, "indication_mediane": float(adj["prix_ajuste"].median()),
188+ "indication_moyenne": float(adj["prix_ajuste"].mean())}
189+
190+def dossier_sujet(d: dict, et: dict) -> dict:
191+ s = d["sujet"]; m = d["modele"]
192+ sd = cas1.sujet_df(s, REF.date().isoformat())
193+ hed = float(cas1.predire(m, sd)[0]); ip = cas1.intervalle_prediction(m, sd).iloc[0]
194+ comps = comparables_reference(d)
195+ # réconciliation : moyenne simple des deux indications (même poids), arrondie au millier
196+ v_reelle = float(np.round((hed + comps["indication_mediane"]) / 2.0, -3))
197+ role = float(s["valeur_role_2025"])
198+ ratio_sujet = role / v_reelle
199+ v_inscrire = v_reelle / et["F"] # art. 147 : valeur réelle ÷ facteur du rôle
200+ tv = taux_voisinage_gatineau()
201+ taxes_role = compte_taxes_gatineau(role, et["F"], tv["mediane"])
202+ taxes_inscrire = compte_taxes_gatineau(v_inscrire, et["F"], tv["mediane"])
203+ return {"hedonique": hed, "ic_bas": float(ip["bas"]), "ic_haut": float(ip["haut"]), "comparables": comps, "valeur_reelle": v_reelle,
204+ "valeur_role": role, "ratio_sujet": ratio_sujet, "ecart_ratio_pct": (ratio_sujet / et["PM"] - 1) * 100,
205+ "valeur_a_inscrire": v_inscrire, "variation_si_conteste": v_inscrire - role, "taxes_role": taxes_role, "taxes_inscrire": taxes_inscrire,
206+ "variation_taxes_annuelle": taxes_inscrire["total"] - taxes_role["total"], "frais_revision": frais(role, FRAIS_REVISION_GATINEAU),
207+ "frais_taq": frais(role, FRAIS_TAQ_2026), "taux_voisinage": tv}
208+
209+def dossier_acheteur(d: dict, et: dict, id_vente: str = "G0258") -> dict:
210+ """Second dossier : l'acquéreur d'une vente du jeu (G0258, Hull) dont le prix, rajusté à la date de référence,
211+ est inférieur à la valeur inscrite."""
212+ v = et["v"]; r = v[v["id_vente"] == id_vente].iloc[0]
213+ prix_ref = float(r["prix_ref"]); role = float(r["valeur_role_2025"])
214+ v_inscrire = prix_ref / et["F"]
215+ tv = et.get("taux_voisinage") or taux_voisinage_gatineau()
216+ t_role = compte_taxes_gatineau(role, et["F"], tv["mediane"]); t_new = compte_taxes_gatineau(v_inscrire, et["F"], tv["mediane"])
217+ gain_annuel = t_role["total"] - t_new["total"]
218+ fr = frais(role, FRAIS_REVISION_GATINEAU)
219+ gain_cycle = gain_annuel * len(ROLE_FICTIF)
220+ # écart de valeur minimal pour couvrir les frais de révision sur un cycle (taux marginal par 1 000 $)
221+ seuil_valeur = fr / (t_role["taux_marginal_pour_1000"] / 1000.0) / len(ROLE_FICTIF)
222+ return {"vente": r, "prix_vente": float(r["prix_vente"]), "date_vente": r["date_vente"], "mois_ref": float(r["mois_ref"]), "prix_ref": prix_ref,
223+ "valeur_role": role, "ratio_brut": float(r["r_brut"]), "ratio_aj": float(r["r_aj"]), "valeur_a_inscrire": v_inscrire, "reduction": role - v_inscrire,
224+ "taxes_role": t_role, "taxes_nouvelle": t_new, "gain_annuel": gain_annuel, "gain_cycle": gain_cycle, "frais_revision": fr,
225+ "frais_taq": frais(role, FRAIS_TAQ_2026), "gain_net_cycle": gain_cycle - fr, "seuil_valeur": seuil_valeur}
226+
227+def sensibilite_acheteur(d: dict, et: dict, da: dict) -> pd.DataFrame:
228+ """Gain net sur le cycle triennal selon la valeur réelle conclue (−10 % à +10 % autour du prix rajusté) et le taux de voisinage
229+ (minimum, médiane, maximum de l'annexe I)."""
230+ tv = taux_voisinage_gatineau(); rows = []
231+ for ecart in np.arange(-0.10, 0.1001, 0.025):
232+ v_reelle = da["prix_ref"] * (1 + ecart); v_insc = v_reelle / et["F"]
233+ row = {"ecart_pct": ecart * 100, "valeur_reelle": v_reelle, "valeur_a_inscrire": v_insc}
234+ for nom, taux in [("min", tv["min"]), ("mediane", tv["mediane"]), ("max", tv["max"])]:
235+ g = (compte_taxes_gatineau(da["valeur_role"], et["F"], taux)["total"] - compte_taxes_gatineau(v_insc, et["F"], taux)["total"]) * len(ROLE_FICTIF)
236+ row[f"gain_net_{nom}"] = g - da["frais_revision"]
237+ rows.append(row)
238+ return pd.DataFrame(rows)
239+
240+def mutations_sujet(ds: dict, et: dict) -> dict:
241+ """Droits de mutation sur une vente hypothétique du sujet au prix de sa valeur réelle à la date de référence :
242+ base = max(prix ; rôle × facteur) (LDMI art. 1.1 et 2 al. 2)."""
243+ prix = ds["valeur_reelle"]; role = ds["valeur_role"]
244+ out = {}
245+ for nom, bareme, F in [("Québec (barème général)", BAREME_MUTATION_QC, et["F"]), ("Montréal 2026", BAREME_MUTATION_MTL, FACTEUR_MONTREAL_2026), ("Gatineau 2026 (dès le 25 février)", BAREME_MUTATION_GAT, et["F"])]:
246+ base = max(prix, role * F)
247+ out[nom] = {"base": base, "droit": droits_mutation(base, bareme), "droit_sur_prix": droits_mutation(prix, bareme)}
248+ # effet du facteur : un prix de 450 000 $ pour le sujet à Gatineau, facteur 1,02 réel vs fictif
249+ out["exemple_facteur"] = {"prix": 450_000.0, "base_F_reel": max(450_000.0, role * FACTEUR_GATINEAU_2026), "base_F_fictif": max(450_000.0, role * et["F"]),
250+ "droit_F_reel": droits_mutation(max(450_000.0, role * FACTEUR_GATINEAU_2026), BAREME_MUTATION_GAT),
251+ "droit_F_fictif": droits_mutation(max(450_000.0, role * et["F"]), BAREME_MUTATION_GAT), "droit_prix": droits_mutation(450_000.0, BAREME_MUTATION_GAT)}
252+ return out
253+
254+def echeancier() -> list[dict]:
255+ """Échéancier légal du rôle fictif 2026-2028 (référence 2024-07-01) et du rôle réel 2027-2029 de Gatineau (référence 2025-07-01)."""
256+ def jalons(ref: dt.date, premier: int, depot: dt.date):
257+ return [
258+ {"jalon": "Date de référence au marché (art. 46 al. 1)", "date": ref},
259+ {"jalon": "Dépôt du rôle (art. 70-71 : 15 août – 15 sept., report possible au 1er nov.)", "date": depot},
260+ {"jalon": "Entrée en vigueur (premier exercice)", "date": dt.date(premier, 1, 1)},
261+ {"jalon": "Expédition de l'avis d'évaluation (art. 81 : avant le 1er mars)", "date": dt.date(premier, 3, 1)},
262+ {"jalon": "Date limite de la demande de révision (art. 130 : avant le 1er mai)", "date": dt.date(premier, 4, 30)},
263+ {"jalon": "Réponse écrite de l'évaluateur (art. 138.3 : au plus tard le 1er sept., report possible au 1er nov. ou au 1er avril suivant)", "date": dt.date(premier, 9, 1)},
264+ {"jalon": "Fin du délai d'entente (art. 138.4 : 30e jour après la réponse du 1er sept.)", "date": dt.date(premier, 10, 1)},
265+ {"jalon": "Date limite du recours au TAQ (art. 138.5 : avant le 31e jour suivant l'expiration du délai d'entente)", "date": dt.date(premier, 10, 31)},
266+ {"jalon": "Fin du cycle triennal (art. 14)", "date": dt.date(premier + 2, 12, 31)},
267+ ]
268+ return {"fictif": jalons(REF.date(), ROLE_FICTIF[0], dt.date(2025, 9, 15)), "gatineau_2027": jalons(dt.date(2025, 7, 1), 2027, dt.date(2026, 10, 27))}
269+
270+
271+# ----------------------------------------------------------------------------- exercices
272+def exercices(et: dict) -> dict:
273+ ex = {}
274+ # ex 1 : facteur comparatif et évaluation uniformisée
275+ pm = 0.92; F = 1 / pm; ex["ex1"] = {"PM": pm, "F": F, "uniformisee_468000": 468_000 * F, "taxe_scolaire": max(468_000 * F - 25_000, 0) * TAUX_SCOLAIRE_2026_2027}
276+ # ex 2 : compte de taxes Gatineau 2026 du sujet (rôle 468 000, voisinage médian, Hull, raccordé) et scénario non raccordé
277+ tv = taux_voisinage_gatineau()
278+ ex["ex2"] = {"raccorde": compte_taxes_gatineau(468_000, 1.02, tv["mediane"]), "non_raccorde": compte_taxes_gatineau(468_000, 1.02, tv["mediane"], raccorde=False),
279+ "six_logements": None}
280+ # ex 3 : petite étude de ratios à la main (8 ventes)
281+ V = np.array([412_000, 455_000, 398_000, 520_000, 610_000, 470_000, 388_000, 545_000.0]); P = np.array([450_000, 480_000, 445_000, 560_000, 690_000, 500_000, 400_000, 600_000.0])
282+ st = h.statistiques_ratio(V, P); ep = epuration_r10(V / P)
283+ ex["ex3"] = {"ratios": V / P, **st, "epuration": ep, "ecart_type_relatif": ecart_type_relatif(V / P)}
284+ # ex 4 : droits de mutation
285+ ex["ex4"] = {"qc_575000": droits_mutation(575_000, BAREME_MUTATION_QC), "gat_575000": droits_mutation(575_000, BAREME_MUTATION_GAT), "mtl_575000": droits_mutation(575_000, BAREME_MUTATION_MTL),
286+ "gat_base_role": max(575_000, 620_000 * 1.02), "gat_droit_base_role": droits_mutation(max(575_000, 620_000 * 1.02), BAREME_MUTATION_GAT)}
287+ # ex 5 : liste de base
288+ ex["ex5"] = {N: liste_de_base(N) for N in [1_000, 10_000, 103_355, 514_614]}
289+ # ex 6 : échéancier — calculé dans la solution à partir de echeancier()
290+ # ex 7 : seuil de rentabilité d'une contestation (voir dossier_acheteur) et régressivité : PRD d'un jeu construit régressif
291+ rng = np.random.default_rng(GRAINE); Vt = np.exp(rng.normal(13.0, 0.35, 400)); Pt = Vt * np.exp(rng.normal(0, 0.08, 400))
292+ A = Vt ** 0.9 * np.exp(0.1 * 13.0) * 0.93 * np.exp(rng.normal(0, 0.06, 400)) # rôle régressif : élasticité 0,9 par rapport à la valeur
293+ ex["ex7"] = h.statistiques_ratio(A, Pt)
294+ return ex
295+
296+
297+# ----------------------------------------------------------------------------- impression
298+def main():
299+ pd.set_option("display.width", 200)
300+ d = charger(); et = etude(d)
301+ print("== Modèle hédonique partagé : croissance mensuelle %.4f %% (%.2f %% par an), σ = %.4f" % (et["croissance_mensuelle"] * 100, et["croissance_annuelle"] * 100, d["modele"]["sigma"]))
302+ print("== Ratios bruts (rôle / prix de vente), n = %d : médiane %.4f ; moyenne %.4f ; pondéré %.4f ; COD %.2f ; PRD %.4f ; PRB %.4f (e.-t. %.4f)" %
303+ (et["brut"]["n"], et["brut"]["ratio_median"], et["brut"]["ratio_moyen"], et["brut"]["ratio_pondere"], et["brut"]["COD"], et["brut"]["PRD"], et["brut"]["PRB"], et["brut"]["PRB_se"]))
304+ print("== Ratios rajustés au 1er juillet 2024 : médiane %.4f ; moyenne %.4f ; pondéré %.4f ; COD %.2f ; PRD %.4f ; PRB %.4f (e.-t. %.4f)" %
305+ (et["aj"]["ratio_median"], et["aj"]["ratio_moyen"], et["aj"]["ratio_pondere"], et["aj"]["COD"], et["aj"]["PRD"], et["aj"]["PRB"], et["aj"]["PRB_se"]))
306+ print("== PRB de contrôle (log2 du seul prix) : brut %.4f (e.-t. %.4f) ; rajusté %.4f (e.-t. %.4f)" % (et["prb_prix_brut"]["PRB"], et["prb_prix_brut"]["PRB_se"], et["prb_prix_aj"]["PRB"], et["prb_prix_aj"]["PRB_se"]))
307+ ep = et["epuration"]
308+ print("== Épuration r. 10 : médiane %.4f, σ %.4f, bornes [%.4f ; %.4f], %d ventes retirées sur %d ; proportion médiane %.4f ; facteur comparatif %.4f" %
309+ (ep["mediane_initiale"], ep["sigma"], ep["borne_basse"], ep["borne_haute"], ep["n_retires"], ep["n"], et["PM"], et["F"]))
310+ print("== Après épuration : COD %.2f ; PRD %.4f ; PRB %.4f" % (et["apres_epuration"]["COD"], et["apres_epuration"]["PRD"], et["apres_epuration"]["PRB"]))
311+ print("== Écart type relatif (annexe III) : brut %.4f ; rajusté %.4f ; épuré %.4f ; plafond r. 13 (100 %% unifamilial) %.2f" % (et["ecart_type_relatif_brut"], et["ecart_type_relatif_aj"], et["ecart_type_relatif_epure"], et["plafond_r13"]))
312+ print("== Par secteur (ratio rajusté) :\n", et["secteurs"].round(4).to_string())
313+ print("== Par type :\n", et["types"].round(4).to_string())
314+ print("== Par tranche de prix rajusté :\n", et["tranches"].round(4).to_string())
315+ print("== Par année de vente (médianes) :\n", et["annees"].round(4).to_string())
316+ print("== Liste de base r. 10 : Gatineau (103 355 unités) → %d ventes ; Montréal (514 614) → %d ; 10 000 → %d ; 1 000 → %d" % (liste_de_base(103_355), liste_de_base(514_614), liste_de_base(10_000), liste_de_base(1_000)))
317+ tv = taux_voisinage_gatineau(); print("== Taux de voisinage Gatineau 2026 (annexe I, n = %d) : min %.3f, médiane %.3f, moyenne %.3f, max %.3f $/1 000 $" % (tv["n"], tv["min"], tv["mediane"], tv["moyenne"], tv["max"]))
318+ ds = dossier_sujet(d, et)
319+ print("== Sujet au 1er juillet 2024 : hédonique %.0f $ (IC 95 %% %.0f – %.0f) ; comparables (médiane %.0f, moyenne %.0f) ; valeur réelle retenue %.0f $" %
320+ (ds["hedonique"], ds["ic_bas"], ds["ic_haut"], ds["comparables"]["indication_mediane"], ds["comparables"]["indication_moyenne"], ds["valeur_reelle"]))
321+ a = ds["comparables"]["ajustements"].copy(); a["date_vente"] = a["date_vente"].dt.date
322+ cols = ["id_vente", "date_vente", "superficie_habitable_m2", "superficie_terrain_m2", "age_bat", "qualite_1a5", "garage_places", "sous_sol_fini", "dist_km", "prix_vente", "conditions_marche", "superficie", "terrain", "age", "qualite", "garage", "sous_sol", "piscine", "prix_ajuste", "ajust_brut_pct", "ajust_net_pct"]
323+ with pd.option_context("display.float_format", lambda x: f"{x:,.2f}" if abs(x) < 10 else f"{x:,.0f}"):
324+ print(a[cols].to_string(index=False))
325+ print("== Ratio du sujet %.4f (écart à la proportion médiane %.1f %%) ; valeur à inscrire (art. 147) %.0f $ ; variation si contestation %.0f $" % (ds["ratio_sujet"], ds["ecart_ratio_pct"], ds["valeur_a_inscrire"], ds["variation_si_conteste"]))
326+ tr = ds["taxes_role"]; print("== Compte de taxes 2026 du sujet (rôle 468 000 $, voisinage médian, Hull) : taux municipal %.3f /1 000 ; municipal %.2f ; uniformisée %.0f ; scolaire %.2f ; compensations %.2f ; total %.2f ; taux marginal %.3f /1 000" %
327+ (tr["taux_municipal_pour_1000"], tr["municipal"], tr["evaluation_uniformisee"], tr["scolaire"], tr["compensations"], tr["total"], tr["taux_marginal_pour_1000"]))
328+ print("== Si la valeur à inscrire était retenue : total %.2f ; variation annuelle %.2f $ ; frais de révision %.2f $ ; frais TAQ %.2f $" % (ds["taxes_inscrire"]["total"], ds["variation_taxes_annuelle"], ds["frais_revision"], ds["frais_taq"]))
329+ da = dossier_acheteur(d, et)
330+ print("== Dossier acheteur %s (%s, %.1f mois après la référence) : prix %.0f ; prix rajusté %.0f ; rôle %.0f ; ratio brut %.4f, rajusté %.4f" % (da["vente"]["id_vente"], da["date_vente"].date(), da["mois_ref"], da["prix_vente"], da["prix_ref"], da["valeur_role"], da["ratio_brut"], da["ratio_aj"]))
331+ print(" valeur à inscrire %.0f (réduction %.0f) ; taxes rôle %.2f → %.2f ; gain annuel %.2f ; gain sur 3 ans %.2f ; frais %.2f ; gain net %.2f ; seuil de valeur %.0f $" %
332+ (da["valeur_a_inscrire"], da["reduction"], da["taxes_role"]["total"], da["taxes_nouvelle"]["total"], da["gain_annuel"], da["gain_cycle"], da["frais_revision"], da["gain_net_cycle"], da["seuil_valeur"]))
333+ print("== Sensibilité (gain net sur le cycle, $) :\n", sensibilite_acheteur(d, et, da).round(0).to_string(index=False))
334+ mu = mutations_sujet(ds, et)
335+ for k_, x in mu.items():
336+ print("== Mutation", k_, {a: round(b, 2) for a, b in x.items()})
337+ for nom, lst in echeancier().items():
338+ print("== Échéancier", nom); [print(" %s : %s" % (j["date"].isoformat(), j["jalon"])) for j in lst]
339+ ex = exercices(et)
340+ print("== Exercices :"); [print(" ", k_, (v_ if not isinstance(v_, dict) else {a: (round(b, 4) if isinstance(b, (int, float, np.floating)) else "…") for a, b in v_.items()})) for k_, v_ in ex.items()]
341+ return {"d": d, "etude": et, "sujet": ds, "acheteur": da, "mutations": mu, "exercices": ex}
342+
343+
344+if __name__ == "__main__":
345+ main()
added analysis/python/figures/ch03_quadrants.py +91 −0
@@ -0,0 +1,91 @@
1+#!/usr/bin/env python3
2+"""Figure ch03 — Modèle des quatre quadrants (DiPasquale–Wheaton) tracé à partir des paramètres pédagogiques du marché type
3+(analysis/python/cases/ch03_cycles.py, SYNTHÉTIQUE). Équilibre de référence (trait plein) et nouvel équilibre après une baisse
4+du taux de capitalisation de 5 % à 4 % (tirets), avec la réaction de court terme (stock fixe) en pointillé.
5+Sortie : assets/figures/ch03/ch03-fig-quatre-quadrants.pdf"""
6+import sys, pathlib
7+ROOT = pathlib.Path(__file__).resolve().parents[3]
8+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
9+import numpy as np, matplotlib.pyplot as plt
10+from traite.style import setup, palette, savefig, LARGEUR
11+import ch03_cycles as cas
12+
13+
14+def main():
15+ setup()
16+ p = cas.Parametres4Q()
17+ base = cas.equilibre_4q(p)
18+ i2 = 0.04
19+ nouv = cas.equilibre_4q(p, i=i2)
20+ ct = cas.court_terme_4q(p, i=i2)
21+ k = 1000.0 # affichage en milliers
22+ c0, c1 = palette.cat[0], palette.cat[1]
23+ fig, ax = plt.subplots(figsize=(LARGEUR, LARGEUR * 0.92))
24+ # bornes
25+ Smax, Pmax, Rmax, Cmax = 150, 420, 25, 3.2
26+ ax.set_xlim(-Pmax, Smax); ax.set_ylim(-Cmax, Rmax)
27+ ax.axhline(0, color=palette.ink, lw=0.9); ax.axvline(0, color=palette.ink, lw=0.9)
28+ ax.grid(False)
29+ for s in ("left", "bottom"):
30+ ax.spines[s].set_visible(False)
31+ ax.set_xticks([]); ax.set_yticks([])
32+ # --- NE : marché de l'espace, D(R) = S → R = (a − S)/b
33+ S = np.linspace(60, 150, 50) * k
34+ ax.plot(S / k, (p.a - S) / p.b / k, color=c0, lw=1.8)
35+ ax.plot(S / k, (1.0 * p.a - S) / p.b / k, color=c0, lw=0, label="_")
36+ # --- NW : marché des actifs, P = R / i (P à gauche, R en haut)
37+ R = np.linspace(0, Rmax, 50) * k
38+ ax.plot(-R / p.i / k, R / k, color=c0, lw=1.8)
39+ ax.plot(-R / i2 / k, R / k, color=c1, lw=1.8, ls="--")
40+ # --- SW : construction, C = (P − c0)/c1 (P à gauche, C en bas)
41+ P = np.linspace(p.c0, Pmax * k, 50)
42+ ax.plot(-P / k, -(P - p.c0) / p.c1 / k, color=c0, lw=1.8)
43+ # --- SE : ajustement du stock, S = C/δ (C en bas, S à droite)
44+ C = np.linspace(0, Cmax, 50) * k
45+ ax.plot(C / p.delta / k, -C / k, color=c0, lw=1.8)
46+
47+ def rectangle(eq, color, ls, lw=1.0):
48+ Sx, Rx, Px, Cx = eq["S"] / k, eq["R"] / k, eq["P"] / k, eq["C"] / k
49+ ax.plot([Sx, Sx], [0, Rx], color=color, lw=lw, ls=ls)
50+ ax.plot([-Px, Sx], [Rx, Rx], color=color, lw=lw, ls=ls)
51+ ax.plot([-Px, -Px], [Rx, -Cx], color=color, lw=lw, ls=ls)
52+ ax.plot([-Px, Sx], [-Cx, -Cx], color=color, lw=lw, ls=ls)
53+ ax.plot([Sx, Sx], [-Cx, 0], color=color, lw=lw, ls=ls)
54+
55+ rectangle(base, c0, "-")
56+ rectangle(nouv, c1, "--")
57+ # court terme : stock fixe, prix bondit à R0/i2
58+ ax.plot([-ct["P"] / k, base["S"] / k], [base["R"] / k, base["R"] / k], color=c1, lw=1.0, ls=":")
59+ ax.plot([-ct["P"] / k, -ct["P"] / k], [base["R"] / k, -ct["C"] / k], color=c1, lw=1.0, ls=":")
60+ ax.plot([-ct["P"] / k, base["S"] / k], [-ct["C"] / k, -ct["C"] / k], color=c1, lw=1.0, ls=":")
61+ # points
62+ ax.plot(base["S"] / k, base["R"] / k, "o", color=c0, ms=4); ax.plot(nouv["S"] / k, nouv["R"] / k, "o", color=c1, ms=4)
63+ ax.plot(-base["P"] / k, base["R"] / k, "o", color=c0, ms=4); ax.plot(-nouv["P"] / k, nouv["R"] / k, "o", color=c1, ms=4)
64+ ax.plot(-ct["P"] / k, base["R"] / k, "o", color=c1, ms=4, mfc="white")
65+ # étiquettes d'axes et de quadrants
66+ ax.text(Smax, 0.6, "Stock $S$ (milliers de log.)", ha="right", va="bottom", fontsize=8)
67+ ax.text(-Pmax, 0.6, "Prix $P$ (k\\$/log.)", ha="left", va="bottom", fontsize=8)
68+ ax.text(1.5, Rmax, "Loyer $R$ (k\\$/log./an)", ha="left", va="top", fontsize=8)
69+ ax.text(1.5, -Cmax, "Construction $C$ (milliers de log./an)", ha="left", va="bottom", fontsize=8)
70+ ax.text(Smax * 0.98, Rmax * 0.93, "Marché de l'espace\n$D(R) = S$", ha="right", va="top", fontsize=8, color=palette.ink2)
71+ ax.text(-Pmax * 0.98, Rmax * 0.93, "Marché des actifs\n$P = R/i$", ha="left", va="top", fontsize=8, color=palette.ink2)
72+ ax.text(-Pmax * 0.98, -Cmax * 0.93, "Construction\n$C = (P - c_0)/c_1$", ha="left", va="bottom", fontsize=8, color=palette.ink2)
73+ ax.text(Smax * 0.98, -Cmax * 0.93, "Ajustement du stock\n$\\Delta S = C - \\delta S = 0$", ha="right", va="bottom", fontsize=8, color=palette.ink2)
74+ # annotations numériques
75+ ax.annotate(f"$S^*$ = {base['S']/k:.0f}", xy=(base["S"] / k, 0), xytext=(base["S"] / k + 2, 1.2), fontsize=7.5, color=c0)
76+ ax.annotate(f"{nouv['S']/k:.1f}", xy=(nouv["S"] / k, 0), xytext=(nouv["S"] / k + 2, 3.4), fontsize=7.5, color=c1)
77+ ax.annotate(f"$R^*$ = {base['R']/k:.1f}", xy=(0, base["R"] / k), xytext=(3, base["R"] / k + 0.6), fontsize=7.5, color=c0)
78+ ax.annotate(f"{nouv['R']/k:.1f}", xy=(0, nouv["R"] / k), xytext=(3, nouv["R"] / k - 1.9), fontsize=7.5, color=c1)
79+ ax.annotate(f"$P^*$ = {base['P']/k:.0f}", xy=(-base["P"] / k, 0), xytext=(-base["P"] / k - 6, 1.2), fontsize=7.5, color=c0, ha="right")
80+ ax.annotate(f"court terme : {ct['P']/k:.0f}", xy=(-ct["P"] / k, 0), xytext=(-ct["P"] / k - 4, -0.9), fontsize=7.5, color=c1, ha="right", va="top")
81+ ax.annotate(f"$C^*$ = {base['C']/k:.1f}", xy=(0, -base["C"] / k), xytext=(3, -base["C"] / k - 0.1), fontsize=7.5, color=c0, va="top")
82+ from matplotlib.lines import Line2D
83+ h = [Line2D([], [], color=c0, lw=1.8, label="équilibre de référence ($i$ = 5 %)"),
84+ Line2D([], [], color=c1, lw=1.8, ls="--", label="nouvel équilibre de long terme ($i$ = 4 %)"),
85+ Line2D([], [], color=c1, lw=1.0, ls=":", label="court terme (stock fixe)")]
86+ ax.legend(handles=h, loc="lower center", bbox_to_anchor=(0.5, -0.06), ncol=3, fontsize=7.5, frameon=False)
87+ out = savefig(fig, "ch03/ch03-fig-quatre-quadrants.pdf"); print("OK", out)
88+
89+
90+if __name__ == "__main__":
91+ main()
added analysis/python/figures/ch04_carte.py +45 −0
@@ -0,0 +1,45 @@
1+#!/usr/bin/env python3
2+"""Figure ch04 — carte schématique des six RMR du Québec : position des centres (coordonnées de la Base de données toponymiques du Canada,
3+Ressources naturelles Canada, service geonames, consulté le 2026-10-31), surface des cercles proportionnelle à la population au 1er juillet 2025
4+(Statistique Canada, tableau 17-10-0148-01). Aucun fond de carte ni tuile : schéma en coordonnées géographiques (projection équirectangulaire,
5+facteur cos(47°) sur la longitude). Sortie : assets/figures/ch04/ch04-fig-carte-rmr.pdf"""
6+import sys, pathlib, math
7+ROOT = pathlib.Path(__file__).resolve().parents[3]
8+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
9+import matplotlib.pyplot as plt
10+from traite.style import setup, palette, savefig, LARGEUR
11+import ch04_marches as cas
12+
13+# Coordonnées (lat, lon) des toponymes « CITY » de la BDTC (geogratis.gc.ca/services/geoname, 2026-10-31)
14+CENTRES = {"Montréal": (45.508822, -73.554077), "Québec": (46.813819, -71.207997), "Ottawa-Gatineau (partie du Québec)": (45.427929, -75.710938),
15+ "Sherbrooke": (45.404047, -71.893171), "Trois-Rivières": (46.343077, -72.543383), "Saguenay": (48.428221, -71.062202)}
16+ETIQ = {"Ottawa-Gatineau (partie du Québec)": "Gatineau\n(RMR Ottawa-Gatineau,\npartie du Québec)"}
17+DECAL = {"Montréal": (0.0, -0.42), "Québec": (0.35, 0.18), "Ottawa-Gatineau (partie du Québec)": (0.0, 0.36), "Sherbrooke": (0.0, -0.28),
18+ "Trois-Rivières": (0.0, 0.25), "Saguenay": (0.0, 0.25)}
19+
20+def main():
21+ setup()
22+ pop = cas.population_rmr()[2025]
23+ k = math.cos(math.radians(47.0))
24+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.9))
25+ ref = pop["Montréal"]
26+ for nom, (lat, lon) in CENTRES.items():
27+ x, y = lon * k, lat
28+ r = 1400 * pop[nom] / ref # surface ∝ population
29+ ax.scatter([x], [y], s=max(r, 18), color=palette.cat[0], alpha=0.35, edgecolor=palette.cat[0], lw=1.0, zorder=3)
30+ ax.scatter([x], [y], s=10, color=palette.ink, zorder=4)
31+ dx, dy = DECAL[nom]
32+ ax.text(x + dx, y + dy, f"{ETIQ.get(nom, nom)}\n{pop[nom]:,.0f} hab.".replace(",", " "), ha="center", va="center", fontsize=7.5, color=palette.ink, zorder=5)
33+ # repères : fleuve Saint-Laurent schématisé par la droite reliant Montréal, Trois-Rivières et Québec (segments)
34+ pts = [CENTRES["Montréal"], CENTRES["Trois-Rivières"], CENTRES["Québec"]]
35+ ax.plot([p[1] * k for p in pts], [p[0] for p in pts], color=palette.grid, lw=5, alpha=0.9, zorder=1, solid_capstyle="round")
36+ ax.text((CENTRES["Montréal"][1] * k + CENTRES["Trois-Rivières"][1] * k) / 2 + 0.05, 46.05, "axe du Saint-Laurent (schématique)", fontsize=7, color=palette.ink2, rotation=24)
37+ ax.set_xlim(-75.9 * k - 0.6, -70.6 * k + 0.8); ax.set_ylim(44.7, 49.2)
38+ ax.set_xticks([lon * k for lon in (-76, -75, -74, -73, -72, -71)]); ax.set_xticklabels([f"{abs(l)}° O" for l in (-76, -75, -74, -73, -72, -71)])
39+ ax.set_yticks([45, 46, 47, 48, 49]); ax.set_yticklabels([f"{v}° N" for v in (45, 46, 47, 48, 49)])
40+ ax.set_aspect("equal"); ax.grid(True, lw=0.4)
41+ ax.text(-75.9 * k - 0.45, 49.0, "Surface des cercles proportionnelle à la population au 1er juillet 2025", fontsize=7.5, color=palette.ink2)
42+ out = savefig(fig, "ch04/ch04-fig-carte-rmr.pdf"); print("OK", out)
43+
44+if __name__ == "__main__":
45+ main()
added analysis/python/figures/ch04_ipln.py +33 −0
@@ -0,0 +1,33 @@
1+#!/usr/bin/env python3
2+"""Figure ch04 — Indice des prix des logements neufs (IPLN), total maison et terrain, déc. 2016 = 100, janvier 2010 → septembre 2026 :
3+Montréal, Québec, Gatineau (partie du Québec), Sherbrooke, Trois-Rivières, Canada. DONNÉES RÉELLES : Statistique Canada, tableau 18-10-0205-01.
4+Sortie : assets/figures/ch04/ch04-fig-ipln-rmr.pdf"""
5+import sys, pathlib
6+ROOT = pathlib.Path(__file__).resolve().parents[3]
7+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
8+import pandas as pd, matplotlib.pyplot as plt, matplotlib.dates as mdates
9+from traite.style import setup, palette, savefig, LARGEUR
10+import ch04_marches as cas
11+
12+ORDRE = ["Montréal", "Québec", "Gatineau (partie du Québec)", "Sherbrooke", "Trois-Rivières", "Canada"]
13+
14+def main():
15+ setup()
16+ df = cas.ipln(); df = df[(df["composante"] == "Total") & (df["date"] >= "2010-01-01")]
17+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.6))
18+ for i, geo in enumerate(ORDRE):
19+ s = df[df["geo"] == geo]
20+ ls = "--" if geo == "Canada" else "-"; lw = 2.0 if geo == "Montréal" else 1.4
21+ ax.plot(s["date"], s["indice"], color=palette.cat[i], ls=ls, lw=lw, label=geo.replace(" (partie du Québec)", " (partie QC)"))
22+ dy = {"Canada": 2.3, "Trois-Rivières": -2.0}.get(geo, 0.0)
23+ ax.text(s["date"].iloc[-1] + pd.Timedelta(days=60), s["indice"].iloc[-1] + dy, f"{s['indice'].iloc[-1]:.1f}".replace(".", ","), fontsize=7, va="center", color=palette.ink)
24+ ax.axhline(100, color=palette.ink2, lw=0.6, ls=":"); ax.text(pd.Timestamp("2010-02-01"), 101.5, "déc. 2016 = 100", fontsize=7.5, color=palette.ink2)
25+ ax.axvspan(pd.Timestamp("2020-03-01"), pd.Timestamp("2022-12-31"), color=palette.grid, alpha=0.5, lw=0)
26+ ax.text(pd.Timestamp("2020-04-15"), 72, "mars 2020 –\ndéc. 2022", fontsize=7, color=palette.ink2)
27+ ax.set_ylabel("Indice (déc. 2016 = 100)"); ax.set_xlim(pd.Timestamp("2010-01-01"), pd.Timestamp("2027-06-01")); ax.set_ylim(70, 170)
28+ ax.xaxis.set_major_locator(mdates.YearLocator(2)); ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
29+ ax.legend(loc="upper left", ncol=2, fontsize=7.5)
30+ out = savefig(fig, "ch04/ch04-fig-ipln-rmr.pdf"); print("OK", out)
31+
32+if __name__ == "__main__":
33+ main()
added analysis/python/figures/ch04_mutations.py +29 −0
@@ -0,0 +1,29 @@
1+#!/usr/bin/env python3
2+"""Figure ch04 — taux effectif des droits sur les mutations immobilières (droit / base d'imposition) selon la base, barèmes 2026 :
3+barème légal provincial (LDMI art. 2), Montréal, Gatineau (dès le 25 février 2026), Ville de Québec. Calcul de l'auteur (ch04_marches.droits_mutation)
4+à partir des tranches publiées (sources : LégisQuébec, montreal.ca, gatineau.ca, ville.quebec.qc.ca, consultées le 2026-10-31).
5+Sortie : assets/figures/ch04/ch04-fig-mutations.pdf"""
6+import sys, pathlib
7+ROOT = pathlib.Path(__file__).resolve().parents[3]
8+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
9+import numpy as np, matplotlib.pyplot as plt
10+from matplotlib.ticker import FuncFormatter
11+from traite.style import setup, palette, savefig, LARGEUR
12+import ch04_marches as cas
13+
14+def main():
15+ setup()
16+ prix = np.linspace(100000, 3500000, 681)
17+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.1))
18+ for i, (nom, bar) in enumerate(cas.BAREMES_2026.items()):
19+ te = np.array([cas.droits_mutation(p, bar) / p for p in prix]) * 100
20+ ax.plot(prix / 1000, te, color=palette.cat[i], lw=1.6 if i else 1.2, ls="--" if i == 0 else "-", label=nom)
21+ for p, lab, y in [(500000, "500 000 $ : médiane unifamiliale,\nprovince, T3 2026", 0.12), (860500, "860 500 $ : médiane plex,\nRMR Montréal, T3 2026", 0.5)]:
22+ ax.axvline(p / 1000, color=palette.ink2, lw=0.6, ls=":"); ax.text(p / 1000 + 25, y, lab, fontsize=6.8, color=palette.ink2)
23+ ax.set_xlabel("Base d'imposition (milliers de dollars)"); ax.set_ylabel("Taux effectif (% de la base)")
24+ ax.xaxis.set_major_formatter(FuncFormatter(lambda v, p: f"{v:,.0f}".replace(",", " "))); ax.yaxis.set_major_formatter(FuncFormatter(lambda v, p: f"{v:.1f}".replace(".", ",")))
25+ ax.set_ylim(0, 3.2); ax.legend(loc="upper left", fontsize=7.5)
26+ out = savefig(fig, "ch04/ch04-fig-mutations.pdf"); print("OK", out)
27+
28+if __name__ == "__main__":
29+ main()
added analysis/python/figures/ch04_parc.py +37 −0
@@ -0,0 +1,37 @@
1+#!/usr/bin/env python3
2+"""Figure ch04 — structure du parc de logements : ménages privés selon le type de construction, Québec et Canada (parts), et
3+mode d'occupation par type au Québec. DONNÉES RÉELLES : Statistique Canada, Recensement de 2021, tableau 98-10-0240-01 (WDS, data/raw).
4+Sortie : assets/figures/ch04/ch04-fig-parc-types.pdf"""
5+import sys, pathlib
6+ROOT = pathlib.Path(__file__).resolve().parents[3]
7+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
8+import numpy as np, matplotlib.pyplot as plt
9+from matplotlib.ticker import FuncFormatter
10+from traite.style import setup, palette, savefig, LARGEUR
11+import ch04_marches as cas
12+
13+def main():
14+ setup()
15+ sp = cas.structure_parc().sort_values("qc_menages", ascending=True)
16+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.3), gridspec_kw={"width_ratios": [1.15, 1]})
17+ y = np.arange(len(sp)); h = 0.38
18+ ax1.barh(y + h / 2, sp["qc_part"] * 100, h, color=palette.cat[0], label="Québec")
19+ ax1.barh(y - h / 2, sp["ca_part"] * 100, h, color=palette.cat[1], label="Canada")
20+ ax1.set_yticks(y); ax1.set_yticklabels(sp["court"]); ax1.set_xlabel("Part des ménages privés (%)")
21+ ax1.xaxis.set_major_formatter(FuncFormatter(lambda v, p: f"{v:.0f}")); ax1.legend(loc="lower right")
22+ for yi, (q, c) in enumerate(zip(sp["qc_part"], sp["ca_part"])):
23+ ax1.text(q * 100 + 0.6, yi + h / 2, f"{q*100:.1f}".replace(".", ","), va="center", fontsize=7, color=palette.ink)
24+ ax1.set_title("Type de construction", loc="left")
25+ prop = sp["qc_proprietaires"] / sp["qc_menages"] * 100; loc = sp["qc_locataires"] / sp["qc_menages"] * 100
26+ ax2.barh(y, prop, 0.6, color=palette.cat[0], label="Propriétaires")
27+ ax2.barh(y, loc, 0.6, left=prop, color=palette.cat[2], label="Locataires")
28+ ax2.set_yticks(y); ax2.set_yticklabels([]); ax2.set_xlim(0, 100); ax2.set_xlabel("Ménages du type (%), Québec")
29+ ax2.axvline(59.9, color=palette.ink2, lw=0.7, ls=":"); ax2.text(60.8, len(sp) - 0.55, "59,9 % (ensemble)", fontsize=7, color=palette.ink2)
30+ for yi, p in enumerate(prop):
31+ ax2.text(min(p, 93) - 1.5, yi, f"{p:.0f}", va="center", ha="right", fontsize=7, color="white" if p > 12 else palette.ink)
32+ ax2.legend(loc="upper center", bbox_to_anchor=(0.5, -0.16), ncol=2, fontsize=7); ax2.set_title("Mode d'occupation, Québec", loc="left")
33+ fig.tight_layout(w_pad=1.0)
34+ out = savefig(fig, "ch04/ch04-fig-parc-types.pdf"); print("OK", out)
35+
36+if __name__ == "__main__":
37+ main()
added analysis/python/figures/ch04_schl.py +33 −0
@@ -0,0 +1,33 @@
1+#!/usr/bin/env python3
2+"""Figure ch04 — marché locatif par RMR : taux d'inoccupation (octobre 2024 et 2025) et loyer moyen des logements de deux chambres (octobre 2025),
3+appartements d'initiative privée de trois logements et plus. DONNÉES RÉELLES (citées avec attribution, conditions SCHL) : SCHL, Enquête sur les
4+logements locatifs, octobre 2025, tableaux 1.1.1 et 1.1.2 (data/raw/schl_rml_2025/rmr-quebec-2025-fr.xlsx).
5+Sortie : assets/figures/ch04/ch04-fig-schl-rmr.pdf"""
6+import sys, pathlib
7+ROOT = pathlib.Path(__file__).resolve().parents[3]
8+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
9+import numpy as np, matplotlib.pyplot as plt
10+from matplotlib.ticker import FuncFormatter
11+from traite.style import setup, palette, savefig, LARGEUR, fmt_dollars
12+import ch04_marches as cas
13+
14+def main():
15+ setup()
16+ sc = cas.schl_rmr(); sc.index = [{"Montréal": "Montréal", "Québec": "Québec", "Gatineau (partie du Québec)": "Gatineau\n(partie QC)", "Sherbrooke": "Sher-\nbrooke", "Trois-Rivières": "Trois-\nRivières", "Saguenay": "Sague-\nnay"}[i] for i in sc.index]
17+ x = np.arange(len(sc)); w = 0.38
18+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.2))
19+ ax1.bar(x - w / 2, sc["inocc_tous_2024"], w, color=palette.cat[1], label="Octobre 2024")
20+ ax1.bar(x + w / 2, sc["inocc_tous_2025"], w, color=palette.cat[0], label="Octobre 2025")
21+ ax1.axhline(3.0, color=palette.ink2, lw=0.7, ls=":"); ax1.text(-0.45, 3.1, "3 % (seuil de l'art. 11, L.Q. 2024, c. 23)", ha="left", fontsize=6.8, color=palette.ink2)
22+ for xi, v in zip(x + w / 2, sc["inocc_tous_2025"]): ax1.text(xi, v + 0.05, f"{v:.1f}".replace(".", ","), ha="center", fontsize=7)
23+ ax1.set_xticks(x); ax1.set_xticklabels(sc.index, fontsize=6.8); ax1.set_ylabel("Taux d'inoccupation, tous les logements (%)"); ax1.set_ylim(0, 4.6)
24+ ax1.yaxis.set_major_formatter(FuncFormatter(lambda v, p: f"{v:.0f}")); ax1.legend(loc="upper left", fontsize=7, bbox_to_anchor=(0.0, 0.97))
25+ ax2.bar(x, sc["loyer_2_chambres_2025"], 0.6, color=palette.cat[0])
26+ for xi, v in zip(x, sc["loyer_2_chambres_2025"]): ax2.text(xi, v + 15, fmt_dollars(v), ha="center", fontsize=7)
27+ ax2.set_xticks(x); ax2.set_xticklabels(sc.index, fontsize=6.8); ax2.set_ylabel("Loyer moyen, deux chambres, octobre 2025 ($/mois)"); ax2.set_ylim(0, 1650)
28+ ax2.yaxis.set_major_formatter(FuncFormatter(lambda v, p: f"{v:,.0f}".replace(",", " ")))
29+ fig.tight_layout(w_pad=1.2)
30+ out = savefig(fig, "ch04/ch04-fig-schl-rmr.pdf"); print("OK", out)
31+
32+if __name__ == "__main__":
33+ main()
added analysis/python/figures/ch04_taux.py +30 −0
@@ -0,0 +1,30 @@
1+#!/usr/bin/env python3
2+"""Figure ch04 — taux d'intérêt 2000-2026 : taux hypothécaire conventionnel 5 ans affiché (V80691335, hebdomadaire, 2000→), rendement de
3+l'obligation du Canada 5 ans (BD.CDN.5YR.DQ.YLD, quotidien, 2000→) et cible du taux du financement à un jour (V39079, disponible à partir
4+d'avril 2009 dans l'extraction Valet). DONNÉES RÉELLES : Banque du Canada, API Valet (data/raw/bdc_valet). Sortie : assets/figures/ch04/ch04-fig-taux-2000-2026.pdf"""
5+import sys, pathlib
6+ROOT = pathlib.Path(__file__).resolve().parents[3]
7+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
8+import pandas as pd, matplotlib.pyplot as plt, matplotlib.dates as mdates
9+from traite.style import setup, palette, savefig, LARGEUR
10+import ch04_marches as cas
11+
12+def main():
13+ setup()
14+ b = cas.bdc()["series"]
15+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.9))
16+ ax.plot(b["hyp5"].index, b["hyp5"].values, color=palette.cat[0], lw=1.6, label="Taux hypothécaire affiché, 5 ans (conventionnel)")
17+ obl = b["obl5"].resample("W").mean()
18+ ax.plot(obl.index, obl.values, color=palette.cat[1], lw=1.2, label="Obligation du Canada, 5 ans (moyenne hebdomadaire)")
19+ ax.step(b["directeur"].index, b["directeur"].values, color=palette.cat[2], lw=1.4, where="post", label="Taux directeur (cible du financement à un jour, dès 2009)")
20+ for t, lab, y in [("2008-09-15", "crise\nfinancière", 9.3), ("2020-03-11", "pandémie", 9.3), ("2022-03-02", "resserrement\n2022-2023", 9.3)]:
21+ ax.axvline(pd.Timestamp(t), color=palette.ink2, lw=0.6, ls=":"); ax.text(pd.Timestamp(t) + pd.Timedelta(days=40), y, lab, fontsize=7, color=palette.ink2, va="top")
22+ ax.set_ylabel("Taux (% annuel)"); ax.set_ylim(0, 9.6); ax.set_xlim(pd.Timestamp("2000-01-01"), pd.Timestamp("2027-01-01"))
23+ ax.xaxis.set_major_locator(mdates.YearLocator(2)); ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
24+ ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.1), ncol=1, fontsize=7.5)
25+ ax.annotate(f"{b['hyp5'].iloc[-1]:.2f} %".replace(".", ","), xy=(b["hyp5"].index[-1], b["hyp5"].iloc[-1]), xytext=(-40, 10), textcoords="offset points", fontsize=7.5, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6))
26+ ax.annotate(f"{b['directeur'].iloc[-1]:.2f} %".replace(".", ","), xy=(b["directeur"].index[-1], b["directeur"].iloc[-1]), xytext=(-40, -14), textcoords="offset points", fontsize=7.5, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6))
27+ out = savefig(fig, "ch04/ch04-fig-taux-2000-2026.pdf"); print("OK", out)
28+
29+if __name__ == "__main__":
30+ main()
added analysis/python/figures/ch05_capitalisation.py +33 −0
@@ -0,0 +1,33 @@
1+#!/usr/bin/env python3
2+"""Figure ch05 — sensibilité de la capitalisation d'un écart de taxe foncière : perte de valeur (en % de la valeur) selon
3+l'écart de taux (points de base de la valeur) pour quatre taux de capitalisation et deux degrés de capitalisation.
4+Calculs : ch05_rente.capitalisation_taxe (formule exacte) ; aucune donnée observée.
5+Sortie : assets/figures/ch05/ch05-fig-capitalisation-taxe.pdf"""
6+import sys, pathlib
7+ROOT = pathlib.Path(__file__).resolve().parents[3]
8+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
9+import numpy as np
10+import matplotlib.pyplot as plt
11+from traite.style import setup, palette, savefig, LARGEUR
12+import ch05_rente as c5
13+
14+def main():
15+ setup()
16+ pb = np.linspace(0, 100, 101); tau0 = 0.0070
17+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.2))
18+ for k, i in enumerate([0.04, 0.05, 0.06, 0.07]):
19+ exact = np.array([c5.capitalisation_taxe(1.0, tau0, tau0 + p / 1e4, i)["dV_pct_exact"] for p in pb]) * 100
20+ ax.plot(pb, exact, color=palette.cat[k], lw=1.7, label=f"$i$ = {i*100:.0f} %, capitalisation complète (exacte)")
21+ if i == 0.05:
22+ ax.plot(pb, 0.6 * exact, color=palette.cat[k], lw=1.2, ls="--", label="$i$ = 5 %, capitalisation à 60 %")
23+ ax.plot(pb, -pb / 1e4 / i * 100, color=palette.cat[k], lw=0.9, ls=":", label="$i$ = 5 %, approximation $-\\Delta T / i$")
24+ for x, lab in [(10, "10 pb"), (22.4, "écart nominal\nQuébec–Gatineau 2026"), (50, "50 pb")]:
25+ ax.axvline(x, color=palette.grid, lw=0.8, ls="--"); ax.text(x + 1, -0.6, lab, fontsize=6.8, color=palette.ink2, va="top")
26+ ax.set_xlim(0, 100); ax.set_ylim(-25, 0)
27+ ax.set_xlabel("Écart de taux de taxe foncière (points de base de la valeur marchande)")
28+ ax.set_ylabel("Variation de la valeur (%)")
29+ ax.legend(fontsize=6.8, loc="lower left")
30+ out = savefig(fig, "ch05/ch05-fig-capitalisation-taxe.pdf"); print("OK", out)
31+
32+if __name__ == "__main__":
33+ main()
added analysis/python/figures/ch05_cas1_distance.py +37 −0
@@ -0,0 +1,37 @@
1+#!/usr/bin/env python3
2+"""Figure ch05 — prix par mètre carré habitable et distance au centre, cas 1 (Gatineau, DONNÉES SYNTHÉTIQUES, graine 20261031).
3+Nuage des 600 ventes, médianes par tranche de distance et gradient exponentiel ajusté (ln(P/S) sur d).
4+Sortie : assets/figures/ch05/ch05-fig-prix-distance-cas1.pdf"""
5+import sys, pathlib
6+ROOT = pathlib.Path(__file__).resolve().parents[3]
7+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
8+import numpy as np
9+import matplotlib.pyplot as plt
10+from traite.style import setup, palette, savefig, LARGEUR, fmt_milliers
11+from traite import cas1
12+from traite import hedonique as h
13+import ch05_rente as c5
14+
15+def main():
16+ setup()
17+ v = cas1.charger()["ventes"]; v = v.assign(prix_m2=v["prix_vente"] / v["superficie_habitable_m2"])
18+ tr = c5.tranches_distance(v)
19+ d = v["dist_centre_km"].values; r = h.mco(np.column_stack([np.ones_like(d), d]), np.log(v["prix_m2"].values))
20+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.4))
21+ ax.scatter(d, v["prix_m2"], s=9, color=palette.cat[0], alpha=0.35, lw=0, label="Ventes (n = 600)")
22+ ax.plot(tr["dist_mediane"], tr["prix_m2_median"], color=palette.ink, marker="o", ms=5, lw=1.6, label="Médiane par tranche de distance")
23+ xx = np.linspace(0, 33, 200)
24+ ax.plot(xx, np.exp(r["beta"][0] + r["beta"][1] * xx), color=palette.cat[3], lw=1.6, ls="--", label=f"Gradient exponentiel ajusté ({-r['beta'][1]*100:.2f} %/km)")
25+ for sct, xpos in [("Hull", 1.6), ("Gatineau", 7.4), ("Aylmer", 10.9), ("Buckingham et\nMasson-Angers", 28.0)]:
26+ ax.text(xpos, 5150, sct, ha="center", fontsize=7, color=palette.ink2)
27+ s = cas1.charger()["sujet"]
28+ ax.axvline(s["dist_centre_km"], color=palette.ink2, lw=0.8, ls=":")
29+ ax.text(s["dist_centre_km"] + 0.3, 1600, "sujet (2,1 km)", fontsize=7, color=palette.ink2)
30+ ax.set_xlim(-0.5, 33.5); ax.set_ylim(1500, 5400)
31+ ax.yaxis.set_major_formatter(plt.FuncFormatter(fmt_milliers))
32+ ax.set_xlabel("Distance au centre de référence (km)"); ax.set_ylabel("Prix par m² habitable ($/m²)")
33+ ax.legend(loc="lower center", bbox_to_anchor=(0.52, 0.0), fontsize=7.5)
34+ out = savefig(fig, "ch05/ch05-fig-prix-distance-cas1.pdf"); print("OK", out)
35+
36+if __name__ == "__main__":
37+ main()
added analysis/python/figures/ch05_gradient.py +49 −0
@@ -0,0 +1,49 @@
1+#!/usr/bin/env python3
2+"""Figure ch05 — gradients du modèle monocentrique simulé (PARAMÈTRES PÉDAGOGIQUES DÉCLARÉS dans ch05_rente.PARAMS).
3+Panneau gauche : courbes d'enchère linéaires de trois usages et leur enveloppe (Alonso) ; panneau droit : prix du
4+logement, rente foncière et densité relative du modèle de Muth–Mills, avec l'approximation exponentielle.
5+Sortie : assets/figures/ch05/ch05-fig-gradient-monocentrique.pdf"""
6+import sys, pathlib
7+ROOT = pathlib.Path(__file__).resolve().parents[3]
8+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
9+import numpy as np
10+import matplotlib.pyplot as plt
11+from traite.style import setup, palette, savefig, LARGEUR
12+import ch05_rente as c5
13+
14+def main():
15+ setup()
16+ P = c5.PARAMS
17+ fig, (a1, a2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.1), gridspec_kw={"wspace": 0.32})
18+ # --- Alonso
19+ d = np.linspace(0, 36, 400)
20+ env, courbes = c5.enveloppe_alonso(d)
21+ for k, (nom, y) in enumerate(courbes.items()):
22+ a1.plot(d, np.clip(y, 0, None), color=palette.cat[k], lw=1.1, alpha=0.85, label=nom)
23+ a1.plot(d, env, color=palette.ink, lw=2.0, label="Enveloppe (rente de marché)")
24+ a1.axhline(P["ra"], color=palette.ink2, lw=0.8, ls=":")
25+ a1.text(0.5, P["ra"] + 4, "rente agricole", fontsize=7, color=palette.ink2)
26+ (R1, t1), (R2, t2), (R3, t3) = c5.ALONSO.values()
27+ for x in (c5.frontiere_usages(R1, t1, R2, t2), c5.frontiere_usages(R2, t2, R3, t3), (R3 - P["ra"]) / t3):
28+ a1.axvline(x, color=palette.grid, lw=0.8, ls="--")
29+ a1.set_ylim(0, 140); a1.set_xlim(0, 36)
30+ a1.set_xlabel("Distance au centre (km)"); a1.set_ylabel("Rente foncière ($/m² de terrain/an)")
31+ a1.set_title("A. Courbes d'enchère et enveloppe", loc="left", fontsize=8.5)
32+ a1.legend(fontsize=6.8, loc="upper right")
33+ # --- Muth–Mills
34+ dbar = c5.frontiere_urbaine(P)
35+ d2 = np.linspace(0, dbar, 400)
36+ a2.plot(d2, c5.prix_logement(d2) / P["p0"], color=palette.cat[0], lw=1.8, label="Prix du logement $p(d)/p_0$")
37+ a2.plot(d2, c5.prix_exponentiel(d2) / P["p0"], color=palette.cat[0], lw=1.0, ls="--", label="Approximation exponentielle")
38+ a2.plot(d2, c5.rente_fonciere(d2) / P["r0"], color=palette.cat[1], lw=1.8, label="Rente foncière $r(d)/r_0$")
39+ a2.plot(d2, c5.densite(d2), color=palette.cat[2], lw=1.8, label="Densité $D(d)/D_0$")
40+ a2.axvline(dbar, color=palette.ink2, lw=0.8, ls=":")
41+ a2.text(dbar - 0.6, 0.47, f"frontière\nurbaine\n{dbar:.1f} km", fontsize=7, color=palette.ink2, ha="right")
42+ a2.set_xlim(0, dbar + 1); a2.set_ylim(0, 1.05)
43+ a2.set_xlabel("Distance au centre (km)"); a2.set_ylabel("Niveau relatif au centre (= 1)")
44+ a2.set_title("B. Gradients du modèle de Muth–Mills", loc="left", fontsize=8.5)
45+ a2.legend(fontsize=6.8, loc="upper right")
46+ out = savefig(fig, "ch05/ch05-fig-gradient-monocentrique.pdf"); print("OK", out)
47+
48+if __name__ == "__main__":
49+ main()
added analysis/python/figures/ch05_rmr.py +39 −0
@@ -0,0 +1,39 @@
1+#!/usr/bin/env python3
2+"""Figure ch05 — valeur par logement par RMR du portefeuille du cas 4 (DONNÉES SYNTHÉTIQUES, graine 20261031) et
3+décomposition de l'écart logarithmique avec Montréal entre le RNE par logement et le taux de capitalisation.
4+Sortie : assets/figures/ch05/ch05-fig-valeur-logement-rmr.pdf"""
5+import sys, pathlib
6+ROOT = pathlib.Path(__file__).resolve().parents[3]
7+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
8+import numpy as np
9+import matplotlib.pyplot as plt
10+from traite.style import setup, palette, savefig, LARGEUR, fmt_dollars
11+from traite import cas4
12+import ch05_rente as c5
13+
14+def main():
15+ setup()
16+ dec = c5.decomposition_rmr(cas4.charger()["portefeuille"])
17+ fig, (a1, a2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.0), gridspec_kw={"wspace": 0.45, "width_ratios": [1, 1.15]})
18+ y = np.arange(len(dec))[::-1]
19+ a1.barh(y, dec["valeur_par_logement"], color=palette.cat[0], height=0.62)
20+ for yi, (rmr, row) in zip(y, dec.iterrows()):
21+ a1.text(row["valeur_par_logement"] + 3000, yi, f"{fmt_dollars(row['valeur_par_logement'])} ({row['taux_pondere']*100:.2f} %)", va="center", fontsize=7, color=palette.ink)
22+ a1.set_yticks(y); a1.set_yticklabels(dec.index); a1.set_xlim(0, 330000)
23+ a1.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f"{x/1000:.0f} k$"))
24+ a1.set_xlabel("Valeur par logement (taux global pondéré)"); a1.grid(axis="y", visible=False)
25+ a1.set_title("A. Valeur par logement, 2026", loc="left", fontsize=8.5)
26+ sub = dec.drop(index="Montréal"); y2 = np.arange(len(sub))[::-1]
27+ a2.barh(y2, sub["contrib_rne"], color=palette.cat[1], height=0.62, label="Effet du RNE par logement")
28+ a2.barh(y2, sub["contrib_taux"], left=np.where(sub["contrib_rne"] < 0, sub["contrib_rne"], 0), color=palette.cat[2], height=0.62, label="Effet du taux de capitalisation")
29+ for yi, (rmr, row) in zip(y2, sub.iterrows()):
30+ a2.plot(row["ecart_ln_V_n"], yi, marker="D", ms=5, color=palette.ink, mec=palette.surface)
31+ a2.axvline(0, color=palette.ink2, lw=0.8)
32+ a2.set_yticks(y2); a2.set_yticklabels(sub.index); a2.set_xlim(-0.8, 0.15)
33+ a2.set_xlabel("Écart logarithmique avec Montréal"); a2.grid(axis="y", visible=False)
34+ a2.set_title("B. Décomposition de l'écart (losange = total)", loc="left", fontsize=8.5)
35+ a2.legend(fontsize=6.8, loc="upper left")
36+ out = savefig(fig, "ch05/ch05-fig-valeur-logement-rmr.pdf"); print("OK", out)
37+
38+if __name__ == "__main__":
39+ main()
added analysis/python/figures/ch05_surface.py +44 −0
@@ -0,0 +1,44 @@
1+#!/usr/bin/env python3
2+"""Figure ch05 — surface de rente bicentrique simulée (courbes de niveau) : enveloppe de deux surfaces d'enchère
3+exponentielles (centre principal et sous-centre) au-dessus d'une rente agricole ; PARAMÈTRES PÉDAGOGIQUES DÉCLARÉS.
4+Sortie : assets/figures/ch05/ch05-fig-surface-rente.pdf"""
5+import sys, pathlib
6+ROOT = pathlib.Path(__file__).resolve().parents[3]
7+sys.path.insert(0, str(ROOT / "analysis/python"))
8+import numpy as np
9+import matplotlib.pyplot as plt
10+from traite.style import setup, palette, savefig, LARGEUR
11+
12+CENTRES = [ # (x, y, rente au centre $/m²/an, gradient exponentiel 1/km, nom)
13+ (0.0, 0.0, 80.0, 0.085, "Centre principal"),
14+ (13.0, 5.0, 36.0, 0.13, "Sous-centre"),
15+]
16+RA = 2.0
17+
18+def surface(X, Y):
19+ r = np.full_like(X, RA)
20+ for cx, cy, r0, g, _ in CENTRES:
21+ d = np.hypot(X - cx, Y - cy)
22+ r = np.maximum(r, r0 * np.exp(-g * d))
23+ return r
24+
25+def main():
26+ setup()
27+ x = np.linspace(-22, 30, 420); y = np.linspace(-18, 22, 340)
28+ X, Y = np.meshgrid(x, y); R = surface(X, Y)
29+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.9))
30+ niveaux = [2, 4, 6, 10, 15, 20, 30, 40, 55, 70]
31+ cf = ax.contourf(X, Y, R, levels=niveaux + [85], cmap=palette.cmap_seq(), alpha=0.95)
32+ cs = ax.contour(X, Y, R, levels=niveaux, colors=palette.ink, linewidths=0.5, alpha=0.6)
33+ ax.clabel(cs, fmt=lambda v: f"{v:.0f}", fontsize=6.5, colors=palette.ink, inline=True)
34+ for cx, cy, r0, g, nom in CENTRES:
35+ ax.plot(cx, cy, marker="*", ms=9, color=palette.ink, mec=palette.surface)
36+ ax.annotate(f"{nom}\n$r_0$ = {r0:.0f} \\$/m²/an, $\\gamma$ = {g:.3f}/km", (cx, cy), xytext=(8, -26) if cx == 0 else (-118, 14), textcoords="offset points", fontsize=7, color=palette.ink)
37+ cb = fig.colorbar(cf, ax=ax, pad=0.02, shrink=0.9); cb.set_label("Rente foncière ($/m² de terrain/an)")
38+ ax.set_aspect("equal"); ax.set_xlabel("km (est-ouest)"); ax.set_ylabel("km (nord-sud)")
39+ ax.grid(False)
40+ ax.set_title("Enveloppe de deux surfaces d'enchère exponentielles au-dessus de la rente agricole (2 $/m²/an)", loc="left", fontsize=8.5)
41+ out = savefig(fig, "ch05/ch05-fig-surface-rente.pdf"); print("OK", out)
42+
43+if __name__ == "__main__":
44+ main()
modified analysis/python/figures/ch06_emphyteose.py +4 −4
@@ -18,14 +18,14 @@ def main():
18 res = [cas.emphyteose(noi, cas.RENTE_EMPH, int(k)) for k in n] 18 res = [cas.emphyteose(noi, cas.RENTE_EMPH, int(k)) for k in n]
19 emph = np.array([r["V_emph"] for r in res]) / 1e6; rentes = np.array([r["V_rentes"] for r in res]) / 1e6 19 emph = np.array([r["V_emph"] for r in res]) / 1e6; rentes = np.array([r["V_rentes"] for r in res]) / 1e6
20 reprise = np.array([r["V_reprise"] for r in res]) / 1e6; V = res[0]["V_pleine"] / 1e6 20 reprise = np.array([r["V_reprise"] for r in res]) / 1e6; V = res[0]["V_pleine"] / 1e6
21− fig, ax = plt.subplots(figsize=(LARGEUR, 3.2)) 21+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.5))
22 ax.stackplot(n, emph, rentes, reprise, colors=[palette.cat[0], palette.cat[1], palette.cat[2]], alpha=0.85, 22 ax.stackplot(n, emph, rentes, reprise, colors=[palette.cat[0], palette.cat[1], palette.cat[2]], alpha=0.85,
23 labels=["droit de l'emphytéote (RNE − rente)", "propriétaire : rentes actualisées", "propriétaire : reprise de l'immeuble au terme"]) 23 labels=["droit de l'emphytéote (RNE − rente)", "propriétaire : rentes actualisées", "propriétaire : reprise de l'immeuble au terme"])
24 ax.axhline(V, color=palette.ink, lw=0.8); ax.text(99, V + 0.03, f"pleine propriété {fmt_dollars(V*1e6)}", ha="right", va="bottom", fontsize=7.5) 24 ax.axhline(V, color=palette.ink, lw=0.8); ax.text(99, V + 0.03, f"pleine propriété {fmt_dollars(V*1e6)}", ha="right", va="bottom", fontsize=7.5)
25− for seuil, txt in ((40, "40 ans : bail assimilé à un transfert\n(D-15.1, art. 1)"), (50, "50 ans : copropriété divise\npossible (art. 1040 C.c.Q.)")): 25+ ax.axvline(40, color=palette.ink2, ls=":", lw=1); ax.text(39, 0.42, "40 ans : bail assimilé\nà un transfert (D-15.1, art. 1)", fontsize=7, color=palette.ink, ha="right", va="bottom")
26− ax.axvline(seuil, color=palette.ink2, ls=":", lw=1); ax.text(seuil + 1, 0.12, txt, fontsize=7, color=palette.ink2, va="bottom") 26+ ax.axvline(50, color=palette.ink2, ls=":", lw=1); ax.text(51, 0.12, "50 ans : copropriété divise\npossible (art. 1040 C.c.Q.)", fontsize=7, color=palette.ink, ha="left", va="bottom")
27 ax.set_xlabel("Durée restante de l'emphytéose (années)"); ax.set_ylabel("Valeur (millions de $)"); ax.set_xlim(1, 100); ax.set_ylim(0, V * 1.12) 27 ax.set_xlabel("Durée restante de l'emphytéose (années)"); ax.set_ylabel("Valeur (millions de $)"); ax.set_xlim(1, 100); ax.set_ylim(0, V * 1.12)
28− ax.legend(loc="upper left", fontsize=7.5, ncol=1, bbox_to_anchor=(0.0, 0.98)); ax.grid(axis="x", visible=False) 28+ ax.legend(loc="upper center", fontsize=7.5, ncol=3, bbox_to_anchor=(0.5, -0.2)); ax.grid(axis="x", visible=False)
29 fig.tight_layout() 29 fig.tight_layout()
30 out = savefig(fig, "ch06/ch06-fig-emphyteose.pdf"); print("OK", out) 30 out = savefig(fig, "ch06/ch06-fig-emphyteose.pdf"); print("OK", out)
31 31
modified analysis/python/figures/ch07_charges.py +1 −1
@@ -11,7 +11,7 @@ import ch07_servitudes_copro as cas
11 def main(): 11 def main():
12 setup() 12 setup()
13 qp = cas.quotes_parts(); A = cas.cotisation_seuil(); rep = cas.repartition_charges(A, qp) 13 qp = cas.quotes_parts(); A = cas.cotisation_seuil(); rep = cas.repartition_charges(A, qp)
14− noms = [f"{n} ({s:.0f} m², q = {q:.4f})" for n, s, q in zip(qp["noms"], qp["superficies"], qp["q"])] 14+ noms = [f"{n} ({s:.0f} m², q = {q:.4f})".replace(".", ",") for n, s, q in zip(qp["noms"], qp["superficies"], qp["q"])]
15 blocs = [("Charges courantes (q_i × budget)", rep["par_fraction_courantes"], palette.cat[0]), 15 blocs = [("Charges courantes (q_i × budget)", rep["par_fraction_courantes"], palette.cat[0]),
16 ("Fonds de prévoyance (q_i × A*)", rep["par_fraction_fonds"], palette.cat[1]), 16 ("Fonds de prévoyance (q_i × A*)", rep["par_fraction_fonds"], palette.cat[1]),
17 ("Fonds d'auto assurance (q_i)", rep["par_fraction_auto"], palette.cat[2]), 17 ("Fonds d'auto assurance (q_i)", rep["par_fraction_auto"], palette.cat[2]),
modified analysis/python/figures/ch07_fonds.py +4 −4
@@ -13,17 +13,17 @@ def main():
13 A = cas.cotisation_seuil(); regle = 0.005 * cas.VALEUR_RECONSTRUCTION 13 A = cas.cotisation_seuil(); regle = 0.005 * cas.VALEUR_RECONSTRUCTION
14 t_act, t_regle, t_star = cas.trajectoire(cas.COTISATION_ACTUELLE), cas.trajectoire(regle), cas.trajectoire(A) 14 t_act, t_regle, t_star = cas.trajectoire(cas.COTISATION_ACTUELLE), cas.trajectoire(regle), cas.trajectoire(A)
15 ans = np.arange(0, cas.HORIZON + 1) + 2026 15 ans = np.arange(0, cas.HORIZON + 1) + 2026
16− fig, ax = plt.subplots(figsize=(LARGEUR, 3.6)) 16+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.8))
17 ax.bar(ans[1:], -t_star["D"][1:], color=palette.grid, width=0.7, label="Réparations majeures prévues (dépenses, indexées à 2,5 %)") 17 ax.bar(ans[1:], -t_star["D"][1:], color=palette.grid, width=0.7, label="Réparations majeures prévues (dépenses, indexées à 2,5 %)")
18 ax.plot(ans, t_act["F"], color=palette.cat[3], lw=1.8, label=f"Cotisation actuelle : {cas.COTISATION_ACTUELLE:,.0f} $ (indexée)".replace(",", " ")) 18 ax.plot(ans, t_act["F"], color=palette.cat[3], lw=1.8, label=f"Cotisation actuelle : {cas.COTISATION_ACTUELLE:,.0f} $ (indexée)".replace(",", " "))
19− ax.plot(ans, t_regle["F"], color=palette.cat[2], lw=1.8, ls="--", label=f"Règle du 0,5 % de la valeur de reconstruction : {regle:,.0f} $".replace(",", " ")) 19+ ax.plot(ans, t_regle["F"], color=palette.cat[2], lw=1.8, ls="--", label=f"Règle du 0@5 % de la valeur de reconstruction : {regle:,.0f} $".replace(",", " ").replace("@", ","))
20 ax.plot(ans, t_star["F"], color=palette.cat[0], lw=2.0, label=f"Cotisation de seuil A* : {A:,.0f} $ (indexée)".replace(",", " ")) 20 ax.plot(ans, t_star["F"], color=palette.cat[0], lw=2.0, label=f"Cotisation de seuil A* : {A:,.0f} $ (indexée)".replace(",", " "))
21 ax.axhline(0, color=palette.ink2, lw=0.8) 21 ax.axhline(0, color=palette.ink2, lw=0.8)
22 ax.yaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars)) 22 ax.yaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars))
23 ax.set_xlabel("Année"); ax.set_ylabel("Solde du fonds en fin d'année") 23 ax.set_xlabel("Année"); ax.set_ylabel("Solde du fonds en fin d'année")
24 ax.set_xlim(2025.5, 2051.5) 24 ax.set_xlim(2025.5, 2051.5)
25− ax.legend(loc="upper left", fontsize=7.2) 25+ ax.legend(loc="upper center", bbox_to_anchor=(0.45, -0.17), ncol=2, fontsize=7.2)
26− ax.annotate("an 14 : fenêtres\n(solde A* = 0)", xy=(2039, 0), xytext=(2041.5, 120000), fontsize=7.5, color=palette.ink2, 26+ ax.annotate("an 14 : fenêtres\n(solde A* = 0)", xy=(2039, 0), xytext=(2043.5, -80000), fontsize=7.5, color=palette.ink2,
27 arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.7)) 27 arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.7))
28 out = savefig(fig, "ch07/ch07-fig-fonds-prevoyance.pdf"); print("OK", out) 28 out = savefig(fig, "ch07/ch07-fig-fonds-prevoyance.pdf"); print("OK", out)
29 29
modified analysis/python/figures/ch07_paires.py +1 −1
@@ -26,7 +26,7 @@ def main():
26 ax.set_xlim(avec.min() - 15000, sans.max() + 60000) 26 ax.set_xlim(avec.min() - 15000, sans.max() + 60000)
27 ax.set_xlabel("Prix de vente (dollars de 2026, données synthétiques)") 27 ax.set_xlabel("Prix de vente (dollars de 2026, données synthétiques)")
28 ax.grid(axis="y", visible=False) 28 ax.grid(axis="y", visible=False)
29− ax.set_title((f"Différence moyenne {p['moyenne']:,.0f} \\$ (IC 95 % : {p['ic95'][0]:,.0f} à {p['ic95'][1]:,.0f} \\$), soit {p['effet_relatif_moyen']*100:.1f} % du prix").replace(",", " ").replace(".", ","), 29+ ax.set_title((f"Différence moyenne {p['moyenne']:,.0f} \\$ (IC 95 % : {p['ic95'][0]:,.0f} à {p['ic95'][1]:,.0f} \\$)@ soit {p['effet_relatif_moyen']*100:.1f} % du prix").replace(",", " ").replace(".", ",").replace("@", ","),
30 loc="left", fontsize=8.5) 30 loc="left", fontsize=8.5)
31 ax.legend(loc="lower right", fontsize=7.5) 31 ax.legend(loc="lower right", fontsize=7.5)
32 out = savefig(fig, "ch07/ch07-fig-paires-servitude.pdf"); print("OK", out) 32 out = savefig(fig, "ch07/ch07-fig-paires-servitude.pdf"); print("OK", out)
modified analysis/python/figures/ch07_sensibilite.py +5 −6
@@ -11,16 +11,15 @@ import ch07_servitudes_copro as cas
11 def main(): 11 def main():
12 setup() 12 setup()
13 s = cas.sensibilite_cotisation(); regle = 0.005 * cas.VALEUR_RECONSTRUCTION 13 s = cas.sensibilite_cotisation(); regle = 0.005 * cas.VALEUR_RECONSTRUCTION
14− fig, ax = plt.subplots(figsize=(LARGEUR, 3.2)) 14+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.4))
15 for i, (r, row) in enumerate(s["grille"].items()): 15 for i, (r, row) in enumerate(s["grille"].items()):
16 ax.plot([p * 100 for p in s["inflations"]], row, marker="o", color=palette.cat[i], label=f"rendement du fonds r = {r*100:.0f} %") 16 ax.plot([p * 100 for p in s["inflations"]], row, marker="o", color=palette.cat[i], label=f"rendement du fonds r = {r*100:.0f} %")
17− ax.axhline(regle, color=palette.ink2, lw=0.9, ls=":") 17+ ax.axhline(regle, color=palette.ink2, lw=0.9, ls=":", label="règle du 0,5 % de la valeur de reconstruction (21 000 $)")
18− ax.text(1.52, regle + 250, "règle du 0,5 % de la valeur de reconstruction (21 000 $)", fontsize=7.5, color=palette.ink2) 18+ ax.axhline(cas.COTISATION_ACTUELLE, color=palette.cat[3], lw=0.9, ls=":", label="cotisation actuelle (12 000 $)")
19− ax.axhline(cas.COTISATION_ACTUELLE, color=palette.cat[3], lw=0.9, ls=":") 19+ ax.set_xticks([p * 100 for p in s["inflations"]]); ax.set_xticklabels([f"{p*100:.1f}".replace(".", ",") for p in s["inflations"]])
20− ax.text(1.52, cas.COTISATION_ACTUELLE + 250, "cotisation actuelle (12 000 $)", fontsize=7.5, color=palette.cat[3])
21 ax.set_xlabel("Hausse annuelle des coûts de construction π (%)"); ax.set_ylabel("Cotisation de seuil A* (an 1)") 20 ax.set_xlabel("Hausse annuelle des coûts de construction π (%)"); ax.set_ylabel("Cotisation de seuil A* (an 1)")
22 ax.yaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars)); ax.set_ylim(10000, 26000) 21 ax.yaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars)); ax.set_ylim(10000, 26000)
23− ax.legend(loc="lower right", fontsize=7.5) 22+ ax.legend(loc="upper center", bbox_to_anchor=(0.45, -0.2), ncol=2, fontsize=7.5)
24 out = savefig(fig, "ch07/ch07-fig-sensibilite-cotisation.pdf"); print("OK", out) 23 out = savefig(fig, "ch07/ch07-fig-sensibilite-cotisation.pdf"); print("OK", out)
25 24
26 if __name__ == "__main__": 25 if __name__ == "__main__":
added analysis/python/figures/ch09_ratios.py +95 −0
@@ -0,0 +1,95 @@
1+#!/usr/bin/env python3
2+"""Figures du chapitre 9 (DONNÉES SYNTHÉTIQUES du cas 1, graine 20261031) :
3+ ch09-fig-ratios-distribution.pdf — distribution des ratios rôle/prix, bruts et rajustés à la date de référence, bornes d'épuration du r. 10
4+ ch09-fig-ratios-temps.pdf — ratio médian par trimestre de vente : dérive des ratios bruts, stabilité après rajustement
5+ ch09-fig-prb.pdf — nuage (r − médiane)/médiane contre log2 de la valeur proxy et droite du PRB
6+ ch09-fig-sensibilite-contestation.pdf — gain net d'une contestation sur le cycle triennal selon la valeur réelle conclue et le taux de voisinage
7+Usage (racine) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch09_ratios.py"""
8+from __future__ import annotations
9+import sys, pathlib
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+import numpy as np, pandas as pd
13+import matplotlib.pyplot as plt
14+from matplotlib.ticker import FuncFormatter, PercentFormatter
15+from traite.style import setup, palette, savefig, LARGEUR, fmt_dollars
16+import ch09_ratios as cas
17+
18+
19+def fig_distribution(et):
20+ v = et["v"]; ep = et["epuration"]
21+ bins = np.arange(0.64, 1.24, 0.02)
22+ fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 2.9), sharey=True)
23+ for ax, col, titre, c in [(axes[0], "r_brut", "Ratios bruts : rôle / prix de vente", palette.cat[0]), (axes[1], "r_aj", "Ratios rajustés au 1er juillet 2024", palette.cat[1])]:
24+ r = v[col]; ax.hist(r, bins=bins, color=c, alpha=0.85, edgecolor=palette.surface, linewidth=0.4)
25+ med = r.median(); ax.axvline(med, color=palette.ink, lw=1.2)
26+ ax.text(med + 0.01, ax.get_ylim()[1] * 0.93, f"médiane {med:.3f}".replace(".", ","), fontsize=8, color=palette.ink)
27+ ax.set_title(titre, loc="left", fontsize=8.5); ax.set_xlabel("Ratio valeur au rôle / prix")
28+ ax.set_xlim(0.64, 1.24)
29+ axes[0].set_ylabel("Nombre de ventes")
30+ ax = axes[1]
31+ for b in (ep["borne_basse"], ep["borne_haute"]):
32+ ax.axvline(b, color=palette.cat[3], lw=1.0, ls="--")
33+ ax.text(ep["borne_haute"] + 0.01, ax.get_ylim()[1] * 0.55, f"médiane ± 2σ\n{ep['n_retires']} ventes\nretirées", fontsize=7.5, color=palette.cat[3])
34+ for ax in axes:
35+ ax.axvspan(0.90, 1.10, color=palette.grid, alpha=0.35, lw=0, zorder=0)
36+ axes[0].text(0.905, axes[0].get_ylim()[1] * 0.05, "niveau IAAO 0,90–1,10", fontsize=7, color=palette.ink2)
37+ fig.suptitle(f"COD {et['brut']['COD']:.1f} → {et['aj']['COD']:.1f} ; PRD {et['brut']['PRD']:.3f} → {et['aj']['PRD']:.3f} ; proportion médiane après épuration {et['PM']:.3f}".replace(".", ","),
38+ fontsize=8, color=palette.ink2, x=0.02, ha="left", y=1.02)
39+ print("OK", savefig(fig, "ch09/ch09-fig-ratios-distribution.pdf"))
40+
41+
42+def fig_temps(et):
43+ t = et["trimestres"].copy(); t.index = pd.PeriodIndex(t.index, freq="Q").to_timestamp()
44+ fig, ax = plt.subplots(figsize=(LARGEUR, 2.8))
45+ ax.plot(t.index, t["r_brut"], marker="o", ms=3.5, color=palette.cat[0], label="Ratio médian brut (rôle / prix)")
46+ ax.plot(t.index, t["r_aj"], marker="s", ms=3.5, color=palette.cat[1], label="Ratio médian après rajustement au 1er juillet 2024")
47+ ax.axhline(et["PM"], color=palette.ink2, lw=0.9, ls=":")
48+ ax.text(t.index[0], et["PM"] + 0.004, f"proportion médiane {et['PM']:.3f}".replace(".", ","), fontsize=7.5, color=palette.ink2)
49+ ax.axvline(cas.REF, color=palette.cat[3], lw=0.9, ls="--"); ax.text(cas.REF, 0.995, " date de référence", fontsize=7.5, color=palette.cat[3], va="top")
50+ ax.set_ylim(0.80, 1.0); ax.set_ylabel("Ratio médian du trimestre"); ax.set_xlabel("Trimestre de la vente")
51+ ax.legend(loc="lower left", fontsize=7.5)
52+ print("OK", savefig(fig, "ch09/ch09-fig-ratios-temps.pdf"))
53+
54+
55+def fig_prb(et):
56+ v = et["v"]; V = v["valeur_role_2025"].to_numpy(); P = v["prix_ref"].to_numpy(); r = V / P; med = np.median(r)
57+ proxy = 0.5 * (V / med + P); x = np.log2(proxy); y = (r - med) / med
58+ b = np.polyfit(x, y, 1)
59+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.0))
60+ ax.scatter(x, y, s=9, color=palette.cat[1], alpha=0.55, lw=0, label="Ventes (ratios rajustés)")
61+ xs = np.linspace(x.min(), x.max(), 50); ax.plot(xs, np.polyval(b, xs), color=palette.ink, lw=1.4, label=f"Droite du PRB : pente {et['aj']['PRB']:.3f} (e.-t. {et['aj']['PRB_se']:.3f})".replace(".", ","))
62+ ax.axhspan(-0.05, 0.05, color=palette.grid, alpha=0.5, lw=0, zorder=0)
63+ ax.text(x.min(), 0.052, "zone IAAO : pente attendue entre −0,05 et +0,05", fontsize=7.5, color=palette.ink2)
64+ ax.axhline(0, color=palette.ink2, lw=0.6)
65+ ax.set_xlabel("log₂ de la valeur proxy ((rôle / médiane + prix rajusté) / 2)"); ax.set_ylabel("(ratio − médiane) / médiane")
66+ sec = ax.secondary_xaxis("top", functions=(lambda z: 2 ** z, lambda z: np.log2(np.maximum(z, 1))))
67+ sec.set_xlabel("Valeur proxy ($)", fontsize=8); sec.xaxis.set_major_formatter(FuncFormatter(fmt_dollars)); sec.tick_params(labelsize=7)
68+ ax.set_ylim(-0.3, 0.3); ax.yaxis.set_major_formatter(PercentFormatter(1.0, decimals=0))
69+ ax.legend(loc="lower right", fontsize=7.5)
70+ print("OK", savefig(fig, "ch09/ch09-fig-prb.pdf"))
71+
72+
73+def fig_sensibilite(d, et, da):
74+ s = cas.sensibilite_acheteur(d, et, da); tv = cas.taux_voisinage_gatineau()
75+ fig, ax = plt.subplots(figsize=(LARGEUR, 2.9))
76+ for col, lab, c in [("gain_net_min", f"taux de voisinage minimal ({tv['min']:.3f} $/1 000 $)", palette.cat[2]), ("gain_net_mediane", f"taux médian ({tv['mediane']:.3f} $/1 000 $)", palette.cat[0]), ("gain_net_max", f"taux maximal ({tv['max']:.3f} $/1 000 $)", palette.cat[1])]:
77+ ax.plot(s["valeur_reelle"], s[col], marker="o", ms=3, color=c, label=lab.replace(".", ","))
78+ ax.axhline(0, color=palette.ink2, lw=0.8)
79+ ax.axvline(da["prix_ref"], color=palette.cat[3], ls="--", lw=0.9); ax.text(da["prix_ref"] + 1500, ax.get_ylim()[1] * 0.92, "prix rajusté\nde la vente", fontsize=7.5, color=palette.cat[3])
80+ ax.axvline(da["valeur_role"] - da["seuil_valeur"], color=palette.ink2, ls=":", lw=0.9)
81+ ax.set_xlabel("Valeur réelle conclue à la date de référence ($)"); ax.set_ylabel("Gain net sur trois exercices ($)")
82+ ax.xaxis.set_major_formatter(FuncFormatter(fmt_dollars)); ax.yaxis.set_major_formatter(FuncFormatter(fmt_dollars))
83+ ax.set_title(f"Valeur inscrite {fmt_dollars(da['valeur_role'])} ; frais de révision {da['frais_revision']:.0f} $ ; facteur comparatif {et['F']:.4f}".replace(".", ","), loc="left", fontsize=8.5)
84+ ax.legend(loc="upper right", fontsize=7.5)
85+ print("OK", savefig(fig, "ch09/ch09-fig-sensibilite-contestation.pdf"))
86+
87+
88+def main():
89+ setup()
90+ d = cas.charger(); et = cas.etude(d); da = cas.dossier_acheteur(d, et)
91+ fig_distribution(et); fig_temps(et); fig_prb(et); fig_sensibilite(d, et, da)
92+
93+
94+if __name__ == "__main__":
95+ main()
added analysis/python/tests/test_ch07.py +186 −0
@@ -0,0 +1,186 @@
1+"""Tests du chapitre 7 — servitude par ventes appariées, quotes-parts et charges de copropriété, fonds de prévoyance, exercices.
2+Tolérances : 1 $ sur les montants cités arrondis au dollar ; 1e-9 sur les identités comptables ; 0,0001 sur les proportions."""
3+import sys, pathlib, math
4+import numpy as np, pytest
5+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))
6+import ch07_servitudes_copro as cas
7+from traite import comparables as cp
8+from traite import finance as fin
9+
10+
11+@pytest.fixture(scope="module")
12+def res():
13+ return cas.main()
14+
15+
16+class TestServitude:
17+ def test_paires_citees(self, res):
18+ p = res["paires"]
19+ assert p["n"] == 12
20+ assert p["moyenne"] == pytest.approx(-20333, abs=1)
21+ assert p["mediane"] == pytest.approx(-19000, abs=1)
22+ assert p["ecart_type"] == pytest.approx(10026, abs=1)
23+ assert p["se"] == pytest.approx(2894, abs=1)
24+ assert p["t"] == pytest.approx(-7.03, abs=0.01)
25+ assert p["ic95"][0] == pytest.approx(-26703, abs=1) and p["ic95"][1] == pytest.approx(-13963, abs=1)
26+ assert p["effet_relatif_moyen"] == pytest.approx(-0.0451, abs=0.0001)
27+ assert p["effet_relatif_ratio"] == pytest.approx(-0.0446, abs=0.0001)
28+ assert p["effet_semilog"] == pytest.approx(-0.0454, abs=0.0001)
29+ assert p["sans"].mean() == pytest.approx(455792, abs=1)
30+ assert all(a < s for a, s in zip(p["avec"], p["sans"]))
31+
32+ def test_identites_paires(self, res):
33+ p = res["paires"]
34+ # la moyenne des différences est la différence des moyennes ; l'écart-type est celui de l'échantillon (n−1)
35+ assert p["moyenne"] == pytest.approx(p["avec"].mean() - p["sans"].mean(), abs=1e-9)
36+ assert p["ecart_type"] == pytest.approx(float(np.std(p["diffs"], ddof=1)), abs=1e-9)
37+ assert p["se"] == pytest.approx(p["ecart_type"] / math.sqrt(12), abs=1e-9)
38+ # cohérence avec traite.comparables
39+ va = cp.ventes_appariees(list(p["avec"]), list(p["sans"]))
40+ assert va["moyenne"] == pytest.approx(p["moyenne"]) and va["mediane"] == pytest.approx(p["mediane"])
41+
42+ def test_modele_bande(self, res):
43+ b = res["bande"]
44+ assert b["superficie_bande_m2"] == 120 and b["valeur_bande"] == 21600 and b["effet"] == 10800
45+ assert b["part_terrain"] == pytest.approx(120 / 700)
46+
47+
48+class TestCopropriete:
49+ def test_quotes_parts(self, res):
50+ qp = res["qp"]
51+ assert list(qp["q_dixmilliemes"]) == [935, 1352, 974, 1408, 1196, 1644, 1186, 1305]
52+ assert int(qp["q_dixmilliemes"].sum()) == 10000 and qp["q"].sum() == pytest.approx(1.0, abs=1e-12)
53+ assert qp["superficies"].sum() == 710
54+ assert qp["valeur_relative"].sum() == pytest.approx(716.91, abs=0.01)
55+ # méthode déclarée : F6 = 110 × 1,04 × 1,03
56+ assert qp["valeur_relative"][5] == pytest.approx(110 * 1.04 * 1.03)
57+
58+ def test_repartition(self, res):
59+ rep = res["rep"]; qp = res["qp"]
60+ assert rep["courantes"] == 40000 and rep["auto_assurance"] == 5000
61+ assert rep["general"] == pytest.approx(40000 + res["A_star"] + 5000)
62+ assert rep["par_fraction_general"].sum() == pytest.approx(rep["general"], abs=1e-6)
63+ assert rep["par_fraction_restreint"].sum() == pytest.approx(3200) and int(rep["n_stationnements"]) == 4
64+ assert rep["par_fraction_total"].sum() == pytest.approx(rep["total"], abs=1e-6)
65+ assert rep["total"] == pytest.approx(70091, abs=1)
66+ # chiffres cités : F4 et F6
67+ assert rep["par_fraction_total"][3] == pytest.approx(10218, abs=1) and rep["par_fraction_total"][5] == pytest.approx(11797, abs=1)
68+ assert rep["par_fraction_total"][0] == pytest.approx(6254, abs=1)
69+ assert rep["par_fraction_restreint"][3] == 800 and rep["par_fraction_restreint"][0] == 0
70+ assert rep["par_fraction_fonds"][3] == pytest.approx(qp["q"][3] * res["A_star"])
71+
72+ def test_auto_assurance(self):
73+ assert cas.contribution_auto_assurance(10000, 0) == 5000
74+ assert cas.contribution_auto_assurance(10000, 7000) == 3000
75+ assert cas.contribution_auto_assurance(10000, 12000) == 0
76+
77+
78+class TestFondsPrevoyance:
79+ def test_depenses(self, res):
80+ D = res["tr_star"]["D"]
81+ assert sum(c for _, c, a in cas.COMPOSANTES for _ in a) == 479000
82+ assert D.sum() == pytest.approx(651959, abs=1)
83+ assert D[14] == pytest.approx(140000 * 1.025 ** 14, abs=1e-6) and D[14] == pytest.approx(197816, abs=1)
84+ assert D[7] == pytest.approx(89151, abs=1)
85+
86+ def test_cotisation_seuil(self, res):
87+ A = res["A_star"]
88+ assert A == pytest.approx(21891, abs=1)
89+ tr = res["tr_star"]
90+ assert tr["min"] == pytest.approx(0.0, abs=0.01) and tr["annee_min"] == 14
91+ assert tr["F"][-1] == pytest.approx(239241, abs=1)
92+ assert tr["A"][-1] == pytest.approx(A * 1.025 ** 24, abs=1e-6)
93+ # identité de récurrence F_t = F_{t-1}(1+r) + A_t − D_t
94+ for t in range(1, cas.HORIZON + 1):
95+ assert tr["F"][t] == pytest.approx(tr["F"][t - 1] * 1.02 + tr["A"][t] - tr["D"][t], abs=1e-6)
96+ assert res["A_floor"] == pytest.approx(23228, abs=1)
97+
98+ def test_scenarios(self, res):
99+ act, regle = res["tr_act"], res["tr_regle"]
100+ assert res["regle_0_5"] == 21000
101+ assert act["premiere_annee_negative"] == 13 and act["min"] == pytest.approx(-233300, abs=1) and act["annee_min"] == 22
102+ assert act["F"][-1] == pytest.approx(-182783, abs=1)
103+ assert regle["premiere_annee_negative"] == 14 and regle["min"] == pytest.approx(-16660, abs=1)
104+
105+ def test_toiture_sff(self, res):
106+ fc = res["toiture"]
107+ assert fc["cout_indexe"] == pytest.approx(89151, abs=1)
108+ assert fc["sff"] == pytest.approx(fin.facteur_fonds_amortissement(0.02, 7)) and fc["sff"] == pytest.approx(0.13451, abs=1e-5)
109+ assert fc["versement"] == pytest.approx(11992, abs=1)
110+ # le versement accumule exactement le coût indexé en 7 ans à 2 %
111+ acc = sum(fc["versement"] * 1.02 ** (7 - k) for k in range(1, 8))
112+ assert acc == pytest.approx(fc["cout_indexe"], abs=1e-6)
113+
114+ def test_sensibilite(self, res):
115+ s = res["sens"]
116+ g = s["grille"]
117+ assert g[0.02][2] == pytest.approx(res["A_star"], abs=1)
118+ assert g[0.01][0] == pytest.approx(21683, abs=1) and g[0.01][4] == pytest.approx(24442, abs=1)
119+ assert g[0.03][0] == pytest.approx(19342, abs=1) and g[0.03][4] == pytest.approx(22099, abs=1)
120+ # monotonie : A* croît avec π et décroît avec r
121+ for r in s["rendements"]:
122+ assert all(np.diff(g[r]) > 0)
123+ for j in range(len(s["inflations"])):
124+ assert g[0.01][j] > g[0.02][j] > g[0.03][j]
125+
126+ def test_incidence_valeur(self, res):
127+ inc = res["incidence"]; qp = res["qp"]
128+ assert inc["solde_requis"] == pytest.approx(235908, abs=1) and inc["deficit"] == pytest.approx(150908, abs=1)
129+ assert inc["ajustement_par_fraction"][3] == pytest.approx(-21248, abs=1)
130+ assert inc["ajustement_par_fraction"][5] == pytest.approx(-24809, abs=1)
131+ assert inc["ajustement_par_fraction"].sum() == pytest.approx(-inc["deficit"], abs=1e-6)
132+ assert cas.trajectoire(cas.COTISATION_ACTUELLE, inc["solde_requis"])["min"] == pytest.approx(0.0, abs=0.01)
133+
134+
135+class TestExercices:
136+ def test_ex1_tarifs(self):
137+ # 1 fiche + 6 actes + 1 plan à 1 $, état certifié 16 $ + 16 $ pour 1 réquisition, copie certifiée 42 $
138+ assert 8 * 1 + (16 + 16) + 42 == 82
139+
140+ def test_ex2_quotes_parts(self):
141+ s = np.array([65.0, 80.0, 95.0, 120.0]); q = s / s.sum()
142+ assert q.sum() == pytest.approx(1) and q[3] == pytest.approx(1 / 3)
143+ charges = q * 54000
144+ assert charges[0] == pytest.approx(9750) and charges[3] == pytest.approx(18000)
145+ # variante avec coefficient d'étage 1,05 pour la fraction de 120 m²
146+ v = s * np.array([1, 1, 1, 1.05]); q2 = v / v.sum()
147+ assert q2[3] == pytest.approx(126 / 366, abs=1e-9) and (q2 * 54000)[3] == pytest.approx(18590, abs=1)
148+
149+ def test_ex3_paires(self):
150+ sans = np.array([398000, 412500, 430000, 445000, 462000, 489500]); avec = np.array([384000, 395500, 416000, 423000, 448000, 466500])
151+ va = cp.ventes_appariees(list(avec), list(sans))
152+ assert va["moyenne"] == pytest.approx(-17333, abs=1) and va["mediane"] == pytest.approx(-15500, abs=1)
153+ assert va["ecart_type"] == pytest.approx(4179, abs=1)
154+ se = va["ecart_type"] / math.sqrt(6); t = 2.571
155+ assert se == pytest.approx(1706, abs=1)
156+ assert va["moyenne"] - t * se == pytest.approx(-21720, abs=2) and va["moyenne"] + t * se == pytest.approx(-12947, abs=2)
157+ assert va["moyenne"] / sans.mean() == pytest.approx(-0.0394, abs=0.0001)
158+
159+ def test_ex4_bande(self):
160+ effet = 4 * 35 * 165 * 0.6
161+ assert effet == pytest.approx(13860)
162+ assert effet / 20000 == pytest.approx(0.693, abs=0.001)
163+
164+ def test_ex5_toiture(self):
165+ cT = 75000 * 1.025 ** 7; sff = fin.facteur_fonds_amortissement(0.02, 7)
166+ assert cT == pytest.approx(89151, abs=1) and cT * sff == pytest.approx(11992, abs=1)
167+ # sans indexation, versement 10 088 $ ; déficit à l'an 7
168+ a0 = 75000 * sff
169+ assert a0 == pytest.approx(10088, abs=1)
170+ assert cT - 75000 == pytest.approx(14151, abs=1)
171+ # variante r = 3 %
172+ assert cT * fin.facteur_fonds_amortissement(0.03, 7) == pytest.approx(11635, abs=1)
173+
174+ def test_ex6_script(self):
175+ # l'exercice demande : A* avec r = 3 % et π = 3 % ; A* avec plancher 25 000 $ ; solde initial requis
176+ assert cas.cotisation_seuil(inflation=0.03, rendement=0.03) == pytest.approx(21409, abs=1)
177+ assert cas.cotisation_seuil(plancher=25000) == pytest.approx(23228, abs=1)
178+ assert cas.cotisation_seuil(solde0=150000) == pytest.approx(17304, abs=1)
179+ assert cas.cotisation_seuil(indexer=False) == pytest.approx(25625, abs=1)
180+
181+ def test_ex7_indivision(self):
182+ V = 1050000.0
183+ part = V / 3
184+ assert part == pytest.approx(350000)
185+ assert part * (1 - 0.08) == pytest.approx(322000) and part * (1 - 0.15) == pytest.approx(297500)
186+ assert 350000 - 322000 == 28000 and 350000 - 297500 == 52500
added analysis/python/tests/test_ch08.py +162 −0
Large diff (162 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/tests/test_ch09.py +190 −0
Large diff (190 lines) is not loaded by default to keep the page fast.Load diff
added assets/figures/ch04/ch04-fig-carte-rmr.pdf +0 −0

Binary file not shown.

added assets/figures/ch04/ch04-fig-ipln-rmr.pdf +0 −0

Binary file not shown.

added assets/figures/ch04/ch04-fig-mutations.pdf +0 −0

Binary file not shown.

added assets/figures/ch04/ch04-fig-parc-types.pdf +0 −0

Binary file not shown.

added assets/figures/ch04/ch04-fig-schl-rmr.pdf +0 −0

Binary file not shown.

added assets/figures/ch04/ch04-fig-taux-2000-2026.pdf +0 −0

Binary file not shown.

added assets/figures/ch05/ch05-fig-capitalisation-taxe.pdf +0 −0

Binary file not shown.

added assets/figures/ch05/ch05-fig-gradient-monocentrique.pdf +0 −0

Binary file not shown.

added assets/figures/ch05/ch05-fig-prix-distance-cas1.pdf +0 −0

Binary file not shown.

added assets/figures/ch05/ch05-fig-surface-rente.pdf +0 −0

Binary file not shown.

added assets/figures/ch05/ch05-fig-valeur-logement-rmr.pdf +0 −0

Binary file not shown.

modified assets/figures/ch06/ch06-fig-emphyteose.pdf +0 −0

Binary file not shown.

modified assets/figures/ch07/ch07-fig-fonds-prevoyance.pdf +0 −0

Binary file not shown.

modified assets/figures/ch07/ch07-fig-paires-servitude.pdf +0 −0

Binary file not shown.

modified assets/figures/ch07/ch07-fig-repartition-charges.pdf +0 −0

Binary file not shown.

modified assets/figures/ch07/ch07-fig-sensibilite-cotisation.pdf +0 −0

Binary file not shown.

added assets/figures/ch09/ch09-fig-prb.pdf +0 −0

Binary file not shown.

added assets/figures/ch09/ch09-fig-ratios-distribution.pdf +0 −0

Binary file not shown.

added assets/figures/ch09/ch09-fig-ratios-temps.pdf +0 −0

Binary file not shown.

added assets/figures/ch09/ch09-fig-sensibilite-contestation.pdf +0 −0

Binary file not shown.

modified manuscript/chapters/ch10.tex +135 −0
Large diff (138 lines) is not loaded by default to keep the page fast.Load diff
modified manuscript/frontmatter/titlepage.tex +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added verification/phase-3-report.md +25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
modified verification/review_log.md +1 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme