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 145 class ParametresCycle:
146 146 p4q: Parametres4Q = Parametres4Q()
147 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 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)
151 − t_choc: int = 8 # trimestre du choc
152 − choc_demande: float = 0.10 # hausse permanente de l'ordonnée de demande (+10 %)
153 − mult_c1: float = 1.0 # multiplicateur de la pente d'offre c1 (1 = référence ; >1 = offre moins élastique)
150 + T: int = 100 # horizon (trimestres)
151 + t_choc: int = 8 # trimestre où le choc commence
152 + n_choc: int = 12 # durée de montée du choc (trimestres) : la demande croît linéairement puis se stabilise
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 171 def simuler_cycle(pc: ParametresCycle) -> pd.DataFrame:
@@ -158,18 +173,19 @@ def simuler_cycle(pc: ParametresCycle) -> pd.DataFrame:
158 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 174 L'état initial est stationnaire avec v = v* (l'ordonnée a est ajustée pour que D0 = (1 − v*) S0)."""
160 175 p = pc.p4q
161 − c1 = p.c1 * pc.mult_c1
176 + c1, c0 = pc.c1, pc.c0
162 177 b = p.b
163 178 a0 = (1.0 - pc.v_nat) * p.S0 + b * p.R0
164 179 S, R = p.S0, p.R0
165 180 starts = [p.C0 / 4.0] * (pc.L + 1) # chantiers en cours au régime stationnaire
166 181 rows = []
167 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 185 D = min(S, a - b * R)
170 186 v = 1.0 - D / S
171 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 189 rows.append({"t": t, "annee": t / 4.0, "S": S, "D": D, "v": v, "R": R, "P": P, "C": C, "a": a})
174 190 starts.append(C)
175 191 livraison = starts[-(pc.L + 1)]
@@ -188,9 +204,9 @@ def etat_stationnaire_cycle(pc: ParametresCycle, apres_choc: bool = True) -> dic
188 204 a0 = (1.0 - pc.v_nat) * p.S0 + b * p.R0
189 205 a = a0 * (1.0 + pc.choc_demande) if apres_choc else a0
190 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
192 − S = (a - b * p.i * p.c0) / ((1.0 - pc.v_nat) + b * p.i * c1 * p.delta)
193 − P = p.c0 + c1 * p.delta * S
207 + c1, c0 = pc.c1, pc.c0
208 + S = (a - b * p.i * c0) / ((1.0 - pc.v_nat) + b * p.i * c1 * p.delta)
209 + P = c0 + c1 * p.delta * S
194 210 R = p.i * P
195 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 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 235 rows = []
220 236 for L in delais:
221 237 for m in mult_c1:
222 238 for lam in lams:
223 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 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 303 return piv
288 304
289 305
290 −def mises_en_chantier_montreal_annuelles() -> pd.Series:
291 − """34-10-0143-01 : RMR de Montréal, mensuel, total d'unités, sommes annuelles."""
306 +def mises_en_chantier_montreal_annuelles(geo: str = "Montréal, Québec") -> pd.DataFrame:
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 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 312 and r["Type d'unité"] == "Total d'unités")
295 313 s = pd.Series({r["PÉRIODE DE RÉFÉRENCE"]: float(r["VALEUR"]) for r in rows})
296 314 s.index = pd.to_datetime(s.index)
@@ -337,11 +355,13 @@ def taux_effectifs_a4(mois: str = "2026-08-01") -> dict:
337 355 d = dict(zip(hdr, rec))
338 356 # libellés des séries (bloc SERIES du fichier)
339 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]
341 − non_assure = [k for k, v in labels.items() if "Uninsured" in v and "5 years" in v and "Funds advanced" in v]
342 − return {"mois": mois, "assure_fixe_5ans_plus": float(d[assure[0]]) if assure else float("nan"),
343 − "non_assure_fixe_5ans_plus": float(d[non_assure[0]]) if non_assure else float("nan"),
344 − "labels_assure": [labels[k] for k in assure], "labels_non_assure": [labels[k] for k in non_assure]}
358 + desc = {r[0]: (r[1], r[2]) for r in rows[:i] if len(r) >= 3 and r[0].startswith("V1226677")}
359 + def _cle(assure: bool):
360 + cible = "Residential mortgages, insured" if assure else "Residential mortgages, uninsured"
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]
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 367 def ipc_quebec(produit: str = "Ensemble") -> pd.Series:
@@ -480,22 +500,26 @@ def main() -> None:
480 500 print("\n=== 2. Dynamique stock-flux (SYNTHÉTIQUE) ===")
481 501 pc = ParametresCycle()
482 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 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 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):
486 − rc = racines_caracteristiques(el["kappa"], p.delta, L)
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']}")
507 + for kap in (el["kappa"], pc.kappa):
508 + for L in (1, 2, 3):
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 511 for k in (0.12, 0.30, 0.60, 1.20):
489 512 c = condition_oscillation_L1(k, p.delta)
490 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 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 517 print("\n=== 3. Données réelles ===")
495 518 ann = mises_en_chantier_annuelles()
496 519 cols = ["total", "individuelles", "collectifs", "part_individuelles", "part_collectifs", "trimestres"]
497 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 523 ch = changements_taux_directeur(); print(ch)
500 524 print(taux_effectifs_a4())
501 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 18 res = [cas.emphyteose(noi, cas.RENTE_EMPH, int(k)) for k in n]
19 19 emph = np.array([r["V_emph"] for r in res]) / 1e6; rentes = np.array([r["V_rentes"] for r in res]) / 1e6
20 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 22 ax.stackplot(n, emph, rentes, reprise, colors=[palette.cat[0], palette.cat[1], palette.cat[2]], alpha=0.85,
23 23 labels=["droit de l'emphytéote (RNE − rente)", "propriétaire : rentes actualisées", "propriétaire : reprise de l'immeuble au terme"])
24 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.)")):
26 − ax.axvline(seuil, color=palette.ink2, ls=":", lw=1); ax.text(seuil + 1, 0.12, txt, fontsize=7, color=palette.ink2, va="bottom")
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(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 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 29 fig.tight_layout()
30 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 11 def main():
12 12 setup()
13 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 15 blocs = [("Charges courantes (q_i × budget)", rep["par_fraction_courantes"], palette.cat[0]),
16 16 ("Fonds de prévoyance (q_i × A*)", rep["par_fraction_fonds"], palette.cat[1]),
17 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 13 A = cas.cotisation_seuil(); regle = 0.005 * cas.VALEUR_RECONSTRUCTION
14 14 t_act, t_regle, t_star = cas.trajectoire(cas.COTISATION_ACTUELLE), cas.trajectoire(regle), cas.trajectoire(A)
15 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 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 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 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 21 ax.axhline(0, color=palette.ink2, lw=0.8)
22 22 ax.yaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars))
23 23 ax.set_xlabel("Année"); ax.set_ylabel("Solde du fonds en fin d'année")
24 24 ax.set_xlim(2025.5, 2051.5)
25 − ax.legend(loc="upper left", 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,
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=(2043.5, -80000), fontsize=7.5, color=palette.ink2,
27 27 arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.7))
28 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 26 ax.set_xlim(avec.min() - 15000, sans.max() + 60000)
27 27 ax.set_xlabel("Prix de vente (dollars de 2026, données synthétiques)")
28 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 30 loc="left", fontsize=8.5)
31 31 ax.legend(loc="lower right", fontsize=7.5)
32 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 11 def main():
12 12 setup()
13 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 15 for i, (r, row) in enumerate(s["grille"].items()):
16 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=":")
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)
19 − ax.axhline(cas.COTISATION_ACTUELLE, color=palette.cat[3], lw=0.9, ls=":")
20 − ax.text(1.52, cas.COTISATION_ACTUELLE + 250, "cotisation actuelle (12 000 $)", fontsize=7.5, color=palette.cat[3])
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.axhline(cas.COTISATION_ACTUELLE, color=palette.cat[3], lw=0.9, ls=":", label="cotisation actuelle (12 000 $)")
19 + ax.set_xticks([p * 100 for p in s["inflations"]]); ax.set_xticklabels([f"{p*100:.1f}".replace(".", ",") for p in s["inflations"]])
21 20 ax.set_xlabel("Hausse annuelle des coûts de construction π (%)"); ax.set_ylabel("Cotisation de seuil A* (an 1)")
22 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 23 out = savefig(fig, "ch07/ch07-fig-sensibilite-cotisation.pdf"); print("OK", out)
25 24
26 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