SPB Git

Ch. 12 livré et validé (compilation isolée, tests, fusion des registres)

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

98 changed files +5,322 −82

98 changed files
Unified Split
modified analysis/python/cases/ch12_mesurage.py +1 −1
@@ -37,7 +37,7 @@ PLAN = {
37 37 "sous_sol_plancher_sous_sol_m": 1.90, # plancher du sous-sol à 1,90 m sous le niveau moyen du sol (> 0,4 m : pas un étage MEFQ)
38 38 "garage_long_m": 6.10, "garage_larg_m": 3.66, # garage simple attaché (20 pi × 12 pi), hors empreinte principale
39 39 "lot_facade_m": 15.24, "lot_prof_m": 40.68, # lot rectangulaire 50 pi × 133,5 pi ≈ 620 m²
40 − "marge_avant_m": 7.50, "marge_lat_gauche_m": 2.00, "marge_lat_droite_m": 1.50,
40 + "marge_avant_m": 7.50, "marge_lat_gauche_m": 2.00, "marge_lat_droite_m": 1.66, # 15,24 − 7,92 − 3,66 − 2,00
41 41 }
42 42
43 43
modified analysis/python/cases/ch15_segmentation.py +3 −1
@@ -188,7 +188,9 @@ def validation_croisee(v: pd.DataFrame, segments: dict, n_plis: int = 10) -> pd.
188 188 y = np.log(v["prix_vente"].astype(float)).values; Xb = matrice_base(v)
189 189 kf = KFold(n_splits=n_plis, shuffle=True, random_state=GRAINE)
190 190 def ajuster_predire(Xtr, ytr, Xte):
191 − b = h.mco(Xtr, ytr)["beta"]; return Xte @ b
191 + with np.errstate(invalid="ignore"): # petits segments : variance nulle d'un régresseur → erreurs-types non définies (non utilisées ici)
192 + b = h.mco(Xtr, ytr)["beta"]
193 + return Xte @ b
192 194 resultats = []
193 195 specs = {"Équation unique sans segment": ("aucun", v["secteur"].astype(str))}
194 196 for nom, lab in segments.items():
modified analysis/python/cases/ch19_spatial.py +23 −0
@@ -258,6 +258,29 @@ def sensibilite(b: dict) -> pd.DataFrame:
258 258 "rho": sar["rho"], "se_rho": sar["se_rho"], "aic_sar": sar["aic"], "lambda": sem["lambda"], "se_lambda": sem["se_lambda"], "aic_sem": sem["aic"]})
259 259 return pd.DataFrame(rows)
260 260
261 +
262 +# ----------------------------------------------------------------------------- exercice 19.7
263 +
264 +def exercice_7(b: dict | None = None) -> dict:
265 + """SEM sans secteur avec 4, 8 et 16 voisins : λ, espérance conditionnelle du résidu au sujet et indication ;
266 + matrice spatio-temporelle avec fenêtre de 24 mois : ventes sans voisine antérieure et ρ_st."""
267 + b = b or charger_base(); y, Xr, xy, xs, n = b["y"], b["Xr"].values, b["xy"], b["xs_r"], len(b["y"])
268 + duan = lambda res: float(np.mean(np.exp(res)))
269 + rows = []
270 + for k in [4, 8, 16]:
271 + W = h.matrice_poids_knn(xy, k); sem = h.sem_mv(y, Xr, W)
272 + xy_aug = np.vstack([xy, b["cs"]]); Wa = np.zeros((n + 1, n + 1)); Wa[:n, :n] = W
273 + Wa[n, :n] = h.matrice_poids_knn(xy_aug, k)[n, :n]; Wa[n] /= Wa[n].sum()
274 + u_s = float(h.sem_esperance_conditionnelle(sem["lambda"], Wa, sem["residus"], [n])[0])
275 + rows.append({"k": k, "lambda": sem["lambda"], "u_sujet": u_s, "indication": float(np.exp(xs @ sem["beta"] + u_s) * duan(sem["innovations"]))})
276 + sem_k = pd.DataFrame(rows)
277 + out = {"sem_par_k": sem_k}
278 + for fen in [12, 24]:
279 + Wst = h.matrice_poids_spatio_temporelle(xy, b["mois"], PORTEE_KM, fen); ok = Wst.sum(1) > 0
280 + Z = np.column_stack([(Wst @ y)[ok], Xr[ok]]); r = h.mco(Z, y[ok])
281 + out[f"st_{fen}"] = {"sans_voisin": int((~ok).sum()), "voisins_moyen": float((Wst[ok] > 0).sum(1).mean()), "rho_st": float(r["beta"][0]), "se": float(r["se_hc1"][0])}
282 + return out
283 +
261 284 # ----------------------------------------------------------------------------- contrôle croisé facultatif
262 285
263 286 def controle_croise(b: dict, W: np.ndarray, g: dict) -> dict:
added analysis/python/cases/ch24_depreciation.py +343 −0
@@ -0,0 +1,343 @@
1 +#!/usr/bin/env python3
2 +"""Chapitre 24 — dépréciation et désuétudes du sujet du cas 1 (Gatineau, DONNÉES SYNTHÉTIQUES).
3 +
4 +Imprime tous les chiffres cités dans le chapitre 24 :
5 + * âges (chronologique, effectif selon la convention du ch. 11), vie économique et vie restante ;
6 + * dépréciation physique : âge-vie simple (âge chronologique / âge effectif), âge-vie modifiée, ventilée par composantes
7 + (fiche d'état et travaux du ch. 11, traite.cout.depreciation_ventilee) ;
8 + * désuétude fonctionnelle : carence curable du sujet (salle d'eau), exemples incurables (perte de loyer × MRB du cas 2 ;
9 + perte de RNE capitalisée) ; désuétude externe (ventes appariées, capitalisation) ;
10 + * extraction de marché sur les 600 ventes du cas 1 : coût neuf par vente (élasticité de taille du ch. 21), prix ramenés à la
11 + date d'évaluation (tendance du modèle hédonique), valeur de terrain par secteur extraite des ventes récentes, dépréciation
12 + par vente, profil en fonction de l'âge (médianes par tranche, ajustements linéaire et quadratique) ;
13 + * profils comparés : âge-vie linéaire, extraction, courbe hédonique (coefficients âge et âge² de traite.cas1.hedonique)
14 + et profil « vrai » du générateur (data/synthetic/README.md), convertis en dépréciation du bâtiment ;
15 + * valeur par le coût du sujet (traite.cout.valeur_par_cout) selon deux lectures de la dépréciation, et sensibilité.
16 +
17 +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch24_depreciation.py
18 +Toutes les formules de dépréciation proviennent de analysis/python/traite/cout.py (aucune réécriture)."""
19 +from __future__ import annotations
20 +import sys, pathlib
21 +import numpy as np, pandas as pd
22 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
23 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
24 +from traite import cout as ct, cas1
25 +from traite import hedonique as h
26 +import ch21_cout_neuf as c21
27 +import ch11_inspection as c11
28 +
29 +ROOT = pathlib.Path(__file__).resolve().parents[3]
30 +
31 +# --- conventions et hypothèses déclarées ---------------------------------------------------------------------
32 +VIE_ECONOMIQUE = c11.VIE_ECONOMIQUE # 60 ans (convention des ch. 11 et 24, hypothèse pédagogique)
33 +AGE_CHRONO = 28 # 1998 → 2026
34 +DATE_EVAL = "2026-09-01"
35 +ELASTICITE_TAILLE = -0.10 # ch. 21 : coût unitaire ∝ (S/S_réf)^ε
36 +FACTEUR_QUALITE = 0.05 # coût neuf ± 5 % par classe de qualité (hypothèse pédagogique)
37 +AGE_MAX_RECENT = 10 # ventes « récentes » servant à extraire la valeur du terrain
38 +S_REF = 165.0
39 +# Carence fonctionnelle curable du sujet (constat FICTIF du ch. 24) : aucune salle d'eau au rez-de-chaussée
40 +SALLE_EAU_AJOUT = 16000.0 # soumission fictive : ajout a posteriori, installé (hypothèse pédagogique)
41 +SALLE_EAU_POSTES = {"Plomberie brute et finition": 2.0, "Vanités et accessoires (2 sdb)": 0.5, "Portes intérieures et moulures": 1.0,
42 + "Gypse et tirage de joints": 12.0, "Peinture intérieure": 12.0, "Planchers céramique": 3.0} # quantités si incluse à neuf
43 +# Exemples illustratifs (hypothèses pédagogiques, hors sujet)
44 +TRIPLEX_PERTE_LOYER_MOIS = 75.0 # $/mois, logement sans entrée laveuse-sécheuse
45 +RNE_PERTE_ANNUELLE = 6000.0 # $/an
46 +TAUX_CAP_ILLUSTRATIF = 0.045 # milieu de la fourchette CBRE T3 2026 Montréal low-rise B (4,25-4,75 %), calibration.json SRC-E-045
47 +PART_BATIMENT_ILLUSTRATIVE = 0.70
48 +PAIRES_SANS, PAIRES_AVEC, PAIRES_PART_BAT = 520000.0, 490000.0, 0.60
49 +
50 +
51 +def charger() -> dict:
52 + devis, sujet, cn = c11.charger()
53 + d = cas1.charger()
54 + m = cas1.hedonique(d["ventes"])
55 + fiche = c11.fiche(devis, cn)
56 + trav = c11.travaux(devis, cn)
57 + return {"devis": devis, "sujet": sujet, "cn": cn, "ventes": d["ventes"], "modele": m, "fiche": fiche, "travaux": trav}
58 +
59 +
60 +# --------------------------------------------------------------------------------------------- âges et âge-vie
61 +
62 +def ages_sujet(fiche: pd.DataFrame) -> dict:
63 + s = fiche[fiche["longue_duree"]].iloc[0]
64 + pond = float((fiche["cout_neuf"] * fiche["ratio"]).sum() / fiche["cout_neuf"].sum())
65 + return {"chronologique": AGE_CHRONO, "effectif_structure": float(s.age_effectif), "vie_economique": VIE_ECONOMIQUE,
66 + "vie_restante": ct.vie_restante(VIE_ECONOMIQUE, float(s.age_effectif)),
67 + "ratio_pondere": pond, "age_effectif_pondere": pond * VIE_ECONOMIQUE, "indice_etat": c11.indice_etat(fiche)}
68 +
69 +
70 +def variantes_physique(cn: dict, fiche: pd.DataFrame, trav: pd.DataFrame) -> dict:
71 + """Quatre lectures de la dépréciation physique du sujet (toutes sur C_N = 397 567,33 $)."""
72 + a_eff = ages_sujet(fiche)["effectif_structure"]
73 + curable = float(trav["cout"].sum())
74 + simple_chrono = ct.depreciation_age_vie(cn["total"], AGE_CHRONO, VIE_ECONOMIQUE)
75 + simple_eff = ct.depreciation_age_vie(cn["total"], a_eff, VIE_ECONOMIQUE)
76 + modifiee = ct.depreciation_age_vie_modifiee(cn["total"], curable, a_eff, VIE_ECONOMIQUE)
77 + ventilee = c11.depreciation(fiche, trav, cn)
78 + return {"age_vie_chrono": simple_chrono, "age_vie_effectif": simple_eff, "modifiee": modifiee, "ventilee": ventilee,
79 + "curable": curable, "age_effectif": a_eff}
80 +
81 +
82 +# ------------------------------------------------------------------------------------------ désuétudes
83 +
84 +def carence_salle_eau(devis: pd.DataFrame, cn: dict) -> dict:
85 + """Désuétude fonctionnelle curable (carence) : coût d'ajout a posteriori − coût de l'élément s'il avait été inclus à neuf.
86 + Le coût « à neuf » est reconstitué à partir des prix unitaires du devis (quantités déclarées), majoré comme le coût neuf."""
87 + g = c11.facteur_majoration(cn)
88 + direct = 0.0
89 + for poste, q in SALLE_EAU_POSTES.items():
90 + pu = float(devis.loc[devis["poste"] == poste, "prix_unitaire"].iloc[0])
91 + direct += pu * q
92 + cout_si_neuf = direct * g
93 + d = ct.desuetude_fonctionnelle_curable(SALLE_EAU_AJOUT, cout_si_neuf, 0.0)
94 + return {"cout_si_neuf_direct": direct, "cout_si_neuf": cout_si_neuf, "cout_ajout": SALLE_EAU_AJOUT, "desuetude": d}
95 +
96 +
97 +def exemples_incurables() -> dict:
98 + from traite import cas2
99 + ind = cas2.indicateurs_marche(cas2.comparables_plex(cas2.charger()["ventes"], cas2.charger()["sujet"]))
100 + mrb_annuel = ind["mrb_median_triplex"]
101 + loyer = ct.desuetude_incurable_par_loyer(TRIPLEX_PERTE_LOYER_MOIS, mrb_annuel * 12)
102 + rne = ct.desuetude_incurable_par_rne(RNE_PERTE_ANNUELLE, TAUX_CAP_ILLUSTRATIF, PART_BATIMENT_ILLUSTRATIVE)
103 + rne_total = ct.desuetude_incurable_par_rne(RNE_PERTE_ANNUELLE, TAUX_CAP_ILLUSTRATIF, 1.0)
104 + paires = ct.desuetude_externe_ventes_appariees(PAIRES_SANS, PAIRES_AVEC, PAIRES_PART_BAT)
105 + return {"mrb_annuel": mrb_annuel, "mrb_mensuel": mrb_annuel * 12, "perte_loyer_capitalisee": loyer,
106 + "perte_rne_capitalisee_totale": rne_total, "perte_rne_batiment": rne, "paires": paires}
107 +
108 +
109 +# ------------------------------------------------------------------------------------------ extraction de marché
110 +
111 +def cout_neuf_ventes(v: pd.DataFrame, cn_total: float, elasticite: float = ELASTICITE_TAILLE, fq: float = FACTEUR_QUALITE) -> pd.Series:
112 + """Coût neuf de chaque vente, transposé du sujet : C_N × (S/165)^(1+ε) × (1 + fq·(qualité − 3))."""
113 + unit = ct.cout_par_superficie_ajuste(cn_total / S_REF, S_REF, v["superficie_habitable_m2"].astype(float), elasticite)
114 + return unit * v["superficie_habitable_m2"].astype(float) * (1.0 + fq * (v["qualite_1a5"].astype(float) - 3.0))
115 +
116 +
117 +def prix_a_la_date(v: pd.DataFrame, modele: dict, date_eval: str = DATE_EVAL) -> pd.Series:
118 + """Prix ramenés à la date d'évaluation par la tendance mensuelle du modèle hédonique (ch. 17-18)."""
119 + g = modele["croissance_mensuelle"]
120 + mois = (pd.Timestamp(date_eval) - pd.to_datetime(v["date_vente"])).dt.days / 30.4375
121 + return v["prix_vente"].astype(float) * (1.0 + g) ** mois
122 +
123 +
124 +def valeur_terrain_par_secteur(v: pd.DataFrame, p_adj: pd.Series, cn_i: pd.Series, age_max: int = AGE_MAX_RECENT) -> pd.DataFrame:
125 + """Valeur du terrain par secteur, extraite des ventes récentes : V_L = P_adj − C_N (1 − âge/L) (extraction, ch. 25)."""
126 + r = v[v["age"] <= age_max].copy()
127 + r["p_adj"] = p_adj[r.index]; r["cn"] = cn_i[r.index]
128 + r["dep"] = [ct.depreciation_age_vie(c, a, VIE_ECONOMIQUE) for c, a in zip(r["cn"], r["age"])]
129 + r["vl"] = r["p_adj"] - (r["cn"] - r["dep"])
130 + r["part"] = r["vl"] / r["p_adj"]
131 + g = r.groupby("secteur").agg(n=("vl", "size"), vl_mediane=("vl", "median"), vl_moyenne=("vl", "mean"), vl_ecart_type=("vl", "std"),
132 + part_mediane=("part", "median"), terrain_m2_median=("superficie_terrain_m2", "median"))
133 + g["vl_par_m2"] = g["vl_mediane"] / g["terrain_m2_median"]
134 + return g
135 +
136 +
137 +def extraction(v: pd.DataFrame, modele: dict, cn_total: float, elasticite: float = ELASTICITE_TAILLE, fq: float = FACTEUR_QUALITE,
138 + age_max: int = AGE_MAX_RECENT) -> tuple[pd.DataFrame, pd.DataFrame]:
139 + """Dépréciation totale extraite de chaque vente : D = C_N − (P_adj − V_L) (traite.cout.extraction_depreciation_marche)."""
140 + w = v.copy()
141 + w["cn"] = cout_neuf_ventes(w, cn_total, elasticite, fq)
142 + w["p_adj"] = prix_a_la_date(w, modele)
143 + vl = valeur_terrain_par_secteur(w, w["p_adj"], w["cn"], age_max)
144 + w["vl"] = w["secteur"].map(vl["vl_mediane"])
145 + ext = [ct.extraction_depreciation_marche(p, l, c, a) for p, l, c, a in zip(w["p_adj"], w["vl"], w["cn"], w["age"])]
146 + w["contribution_batiment"] = [e["contribution_batiment"] for e in ext]
147 + w["D"] = [e["depreciation"] for e in ext]
148 + w["taux"] = [e["taux"] for e in ext]
149 + w["taux_annuel"] = [e.get("taux_annuel", np.nan) for e in ext]
150 + return w, vl
151 +
152 +
153 +def ajuster_profil(w: pd.DataFrame) -> dict:
154 + """Ajustements du taux de dépréciation extrait sur l'âge : linéaire (constante), quadratique, linéaire par l'origine."""
155 + a = w["age"].astype(float).values; y = w["taux"].values; n = len(w)
156 + lin = h.mco(np.column_stack([np.ones(n), a]), y)
157 + quad = h.mco(np.column_stack([np.ones(n), a, a ** 2]), y)
158 + orig = h.mco(a.reshape(-1, 1), y)
159 + return {"lineaire": lin, "quadratique": quad, "origine": orig,
160 + "taux_annuel_origine": float(orig["beta"][0]), "vie_implicite": 1.0 / float(orig["beta"][0])}
161 +
162 +
163 +def taux_quadratique(quad: dict, age) -> np.ndarray:
164 + b = quad["beta"]; age = np.asarray(age, dtype=float)
165 + return b[0] + b[1] * age + b[2] * age ** 2
166 +
167 +
168 +def tranches(w: pd.DataFrame, bornes=(0, 10, 20, 30, 40, 50, 60, 100)) -> pd.DataFrame:
169 + g = w.groupby(pd.cut(w["age"], bornes), observed=True)["taux"].agg(["size", "median", "mean", "std"])
170 + g.index = [f"{int(i.left) + (1 if i.left else 0)}-{int(i.right)}" for i in g.index]
171 + return g
172 +
173 +
174 +# ------------------------------------------------------------------------------------------ profils comparés
175 +
176 +def prix_neuf_sujet(modele: dict, sujet: dict) -> float:
177 + """Prédiction hédonique du sujet s'il était neuf (âge 0), même date, correction de Duan."""
178 + s = dict(sujet); s["age"] = 0; s["annee_construction"] = 2026
179 + return float(cas1.predire(modele, cas1.sujet_df(s))[0])
180 +
181 +
182 +def profil_total_hedonique(modele: dict, age) -> np.ndarray:
183 + """Baisse du prix TOTAL avec l'âge selon le modèle : 1 − exp(β₁ âge + β₂ âge²)."""
184 + b1 = float(modele["coef"].loc["age", "coef"]); b2 = float(modele["coef"].loc["age2_100", "coef"]) / 100.0
185 + age = np.asarray(age, dtype=float)
186 + return 1.0 - np.exp(b1 * age + b2 * age ** 2)
187 +
188 +
189 +def profil_total_generateur(age) -> np.ndarray:
190 + """Loi de génération du cas 1 (data/synthetic/README.md) : −0,0055·âge + 0,00003·âge²."""
191 + age = np.asarray(age, dtype=float)
192 + return 1.0 - np.exp(-0.0055 * age + 0.00003 * age ** 2)
193 +
194 +
195 +def total_vers_batiment(d_total, alpha: float) -> np.ndarray:
196 + """Conversion : si le terrain vaut une part α du prix neuf et ne se déprécie pas, d_B = d_total / (1 − α)."""
197 + return np.asarray(d_total, dtype=float) / (1.0 - alpha)
198 +
199 +
200 +def profils(modele: dict, sujet: dict, vl: float, quad: dict, ages=np.arange(0, 81)) -> dict:
201 + p0 = prix_neuf_sujet(modele, sujet)
202 + alpha = vl / p0
203 + return {"ages": ages, "prix_neuf": p0, "alpha": alpha,
204 + "age_vie": np.minimum(ages / VIE_ECONOMIQUE, 1.0),
205 + "extraction": taux_quadratique(quad, ages),
206 + "hedonique_total": profil_total_hedonique(modele, ages), "generateur_total": profil_total_generateur(ages),
207 + "hedonique_bat": total_vers_batiment(profil_total_hedonique(modele, ages), alpha),
208 + "generateur_bat": total_vers_batiment(profil_total_generateur(ages), alpha)}
209 +
210 +
211 +# ------------------------------------------------------------------------------------------ valeur par le coût
212 +
213 +def valeur_terrain_retenue(vl: pd.DataFrame, secteur: str = "Hull") -> dict:
214 + med = float(vl.loc[secteur, "vl_mediane"])
215 + return {"mediane": med, "retenue": round(med / 1000.0) * 1000.0, "n": int(vl.loc[secteur, "n"]), "part": float(vl.loc[secteur, "part_mediane"])}
216 +
217 +
218 +def valeur_sujet(cn: dict, vl_retenue: float, quad: dict, fonc: float, variantes: dict) -> dict:
219 + """Deux lectures : (A) dépréciation physique ventilée (ch. 11) + désuétude fonctionnelle ; (B) dépréciation extraite du marché
220 + à l'âge chronologique + désuétude fonctionnelle spécifique au sujet."""
221 + t28 = float(taux_quadratique(quad, AGE_CHRONO))
222 + d_ext = t28 * cn["total"]
223 + A = ct.valeur_par_cout(vl_retenue, cn["total"], variantes["ventilee"]["total"], fonc, 0.0)
224 + B = ct.valeur_par_cout(vl_retenue, cn["total"], d_ext, fonc, 0.0)
225 + return {"taux_extrait_28": t28, "d_extraite": d_ext, "A_ventilee": A, "B_extraction": B,
226 + "ecart": B["valeur"] - A["valeur"], "valeur_retenue": B["valeur"], "valeur_arrondie": round(B["valeur"] / 5000.0) * 5000.0}
227 +
228 +
229 +# ------------------------------------------------------------------------------------------ sensibilité
230 +
231 +def grille_age_vie(cn: dict, curable: float, vies=(50, 60, 70, 80, 100), ages_eff=(20, 25, 29, 33, 37)) -> pd.DataFrame:
232 + """Taux de dépréciation (âge-vie modifiée, curable fixé) pour chaque couple (vie économique, âge effectif)."""
233 + rows = {}
234 + for L in vies:
235 + rows[L] = {a: ct.depreciation_age_vie_modifiee(cn["total"], curable, a, L)["total"] / cn["total"] for a in ages_eff}
236 + return pd.DataFrame(rows).T
237 +
238 +
239 +def sensibilite_extraction(v: pd.DataFrame, modele: dict, cn_total: float) -> pd.DataFrame:
240 + """Taux extrait à 28 ans et valeur du terrain (Hull) selon les choix de l'extraction."""
241 + rows = []
242 + for nom, kw in [("cas de base", {}), ("ventes récentes ≤ 5 ans", {"age_max": 5}), ("ventes récentes ≤ 15 ans", {"age_max": 15}),
243 + ("élasticité −0,05", {"elasticite": -0.05}), ("élasticité −0,15", {"elasticite": -0.15}),
244 + ("qualité ±0 %", {"fq": 0.0}), ("qualité ±10 %", {"fq": 0.10})]:
245 + w, vl = extraction(v, modele, cn_total, **kw)
246 + q = ajuster_profil(w)["quadratique"]
247 + rows.append({"variante": nom, "vl_hull": float(vl.loc["Hull", "vl_mediane"]), "taux_28": float(taux_quadratique(q, AGE_CHRONO)),
248 + "taux_annuel": ajuster_profil(w)["taux_annuel_origine"]})
249 + return pd.DataFrame(rows)
250 +
251 +
252 +def tornade(cn: dict, vl: float, d_ext: float, fonc: float, variantes: dict, sens_ext: pd.DataFrame) -> list[dict]:
253 + base = ct.valeur_par_cout(vl, cn["total"], d_ext, fonc)["valeur"]
254 + out = []
255 + def add(param, bas, haut, etiquette):
256 + out.append({"param": param, "bas": bas, "haut": haut, "base": base, "etiquette": etiquette})
257 + add("Valeur du terrain ±15 %", ct.valeur_par_cout(vl * 0.85, cn["total"], d_ext, fonc)["valeur"], ct.valeur_par_cout(vl * 1.15, cn["total"], d_ext, fonc)["valeur"], f"{vl*0.85:,.0f} → {vl*1.15:,.0f} $".replace(",", " "))
258 + t = sens_ext["taux_28"]
259 + add("Choix d'extraction (taux à 28 ans)", ct.valeur_par_cout(vl, cn["total"], t.max() * cn["total"], fonc)["valeur"], ct.valeur_par_cout(vl, cn["total"], t.min() * cn["total"], fonc)["valeur"], f"{t.max()*100:.1f} % → {t.min()*100:.1f} %")
260 + add("Coût neuf ±8,6 % (date des prix)", ct.valeur_par_cout(vl, cn["total"] / 1.086, d_ext / 1.086, fonc)["valeur"], ct.valeur_par_cout(vl, cn["total"] * 1.086, d_ext * 1.086, fonc)["valeur"], "indice ×0,921 → ×1,086")
261 + add("Désuétude fonctionnelle 0 → 2×", ct.valeur_par_cout(vl, cn["total"], d_ext, 2 * fonc)["valeur"], ct.valeur_par_cout(vl, cn["total"], d_ext, 0.0)["valeur"], f"{2*fonc:,.0f} → 0 $".replace(",", " "))
262 + add("Lecture ventilée (ch. 11) au lieu de l'extraction", ct.valeur_par_cout(vl, cn["total"], variantes["ventilee"]["total"], fonc)["valeur"], base, f"{variantes['ventilee']['total']/cn['total']*100:.1f} % → {d_ext/cn['total']*100:.1f} %")
263 + return out
264 +
265 +
266 +# ------------------------------------------------------------------------------------------ main
267 +
268 +def main() -> dict:
269 + d = charger()
270 + cn, fiche, trav, v, m, sujet, devis = d["cn"], d["fiche"], d["travaux"], d["ventes"], d["modele"], d["sujet"], d["devis"]
271 + f2 = lambda x: f"{x:,.2f}".replace(",", " "); f0 = lambda x: f"{x:,.0f}".replace(",", " ")
272 + print("== DONNÉES SYNTHÉTIQUES — sujet cas 1 :", sujet["type"], sujet["annee_construction"], sujet["superficie_habitable_m2"], "m² hab.,", sujet["secteur"], "; évaluation", DATE_EVAL)
273 + print(f"== Coût neuf C_N (ch. 21) = {f2(cn['total'])} $")
274 + ag = ages_sujet(fiche)
275 + print(f"== Âges : chronologique {ag['chronologique']} ans ; effectif de la structure {ag['effectif_structure']:.1f} ans (convention ch. 11, λ = {c11.LAMBDA}) ; "
276 + f"vie économique {ag['vie_economique']:.0f} ans ; vie restante {ag['vie_restante']:.1f} ans ; indice d'état {ag['indice_etat']:.3f} ; "
277 + f"âge effectif pondéré par les coûts {ag['age_effectif_pondere']:.1f} ans")
278 + var = variantes_physique(cn, fiche, trav)
279 + print("== Dépréciation physique — quatre lectures :")
280 + print(f" âge-vie simple, âge chronologique 28/60 : {f2(var['age_vie_chrono'])} $ ({var['age_vie_chrono']/cn['total']*100:.2f} %)")
281 + print(f" âge-vie simple, âge effectif {var['age_effectif']:.0f}/60 : {f2(var['age_vie_effectif'])} $ ({var['age_vie_effectif']/cn['total']*100:.2f} %)")
282 + mo = var["modifiee"]
283 + print(f" âge-vie modifiée : curable {f2(mo['curable'])} + incurable {f2(mo['incurable'])} = {f2(mo['total'])} $ ({mo['total']/cn['total']*100:.2f} %)")
284 + ve = var["ventilee"]
285 + print(f" ventilée (ch. 11) : curable {f2(ve['curable'])} + courte durée {f2(sum(ve['courte_duree'].values()))} + longue durée {f2(ve['longue_duree'])} "
286 + f"(base {f2(ve['base_longue'])} × {ve['age_effectif_structure']:.0f}/60) = {f2(ve['total'])} $ ({ve['taux']*100:.2f} %)")
287 + courtes = fiche[(~fiche["longue_duree"]) & (fiche["composante"] != "Entrée asphaltée")]
288 + print(f" composantes de courte durée : {len(courtes)} ; coût neuf {f2(courtes['cout_neuf'].sum())} $ ; dépréciation {f2(sum(ve['courte_duree'].values()))} $ "
289 + f"({sum(ve['courte_duree'].values())/courtes['cout_neuf'].sum()*100:.1f} % de leur coût neuf)")
290 + fin = [(k, x, fiche.loc[fiche['composante'] == k, 'cout_neuf'].iloc[0]) for k, x in ve["courte_duree"].items()]
291 + for k, x, c in sorted(fin, key=lambda t: -t[1]):
292 + print(f" {k:58s} coût neuf {f2(c):>11s} $ dépréciation {f2(x):>11s} $ ({x/c*100:5.1f} %)")
293 + se = carence_salle_eau(devis, cn)
294 + print("== Désuétude fonctionnelle curable du sujet (carence : salle d'eau au rez-de-chaussée, constat fictif) :")
295 + print(f" coût si incluse à neuf : direct {f2(se['cout_si_neuf_direct'])} $ → majoré {f2(se['cout_si_neuf'])} $ ; ajout a posteriori {f2(se['cout_ajout'])} $ ; désuétude = {f2(se['desuetude'])} $")
296 + ex = exemples_incurables()
297 + print("== Exemples incurables (hypothèses pédagogiques) :")
298 + print(f" triplex cas 2 : perte {TRIPLEX_PERTE_LOYER_MOIS:.0f} $/mois × MRB mensuel {ex['mrb_mensuel']:.2f} (= 12 × {ex['mrb_annuel']:.2f}) = {f2(ex['perte_loyer_capitalisee'])} $")
299 + print(f" perte de RNE {f0(RNE_PERTE_ANNUELLE)} $/an / {TAUX_CAP_ILLUSTRATIF*100:.2f} % = {f2(ex['perte_rne_capitalisee_totale'])} $ ; part bâtiment {PART_BATIMENT_ILLUSTRATIVE:.0%} → {f2(ex['perte_rne_batiment'])} $")
300 + print(f" paires : ({f0(PAIRES_SANS)} − {f0(PAIRES_AVEC)}) × {PAIRES_PART_BAT:.0%} = {f2(ex['paires'])} $")
301 + w, vl = extraction(v, m, cn["total"])
302 + print(f"== Extraction de marché sur {len(w)} ventes : tendance {m['croissance_mensuelle']*100:.3f} %/mois ; C_N par vente = C_N × (S/165)^(1{ELASTICITE_TAILLE:+.2f}) × (1 ± {FACTEUR_QUALITE:.0%}/classe)")
303 + print(f" Valeur du terrain par secteur (ventes ≤ {AGE_MAX_RECENT} ans) :")
304 + print(vl.round(3).to_string())
305 + vlr = valeur_terrain_retenue(vl)
306 + print(f" → Hull : médiane {f2(vlr['mediane'])} $ sur {vlr['n']} ventes (part médiane du prix {vlr['part']*100:.1f} %) ; VALEUR DE TERRAIN RETENUE V_L = {f0(vlr['retenue'])} $ ({vlr['retenue']/620:.0f} $/m² pour 620 m²)")
307 + tr = tranches(w)
308 + print(" Taux de dépréciation extrait par tranche d'âge :"); print(tr.round(3).to_string())
309 + aj = ajuster_profil(w)
310 + q = aj["quadratique"]; l = aj["lineaire"]
311 + print(f" linéaire : taux = {l['beta'][0]:.4f} + {l['beta'][1]:.5f}·âge (se {l['se_hc1'][1]:.5f}), R² {l['r2']:.3f}")
312 + print(f" quadratique : taux = {q['beta'][0]:.4f} + {q['beta'][1]:.5f}·âge {q['beta'][2]:+.7f}·âge² (se {q['se_hc1'][0]:.4f}, {q['se_hc1'][1]:.5f}, {q['se_hc1'][2]:.7f}), R² {q['r2']:.3f}")
313 + print(f" par l'origine : {aj['taux_annuel_origine']*100:.3f} % de C_N par année → vie économique linéaire implicite {aj['vie_implicite']:.0f} ans")
314 + for a in (10, 28, 40, 60):
315 + print(f" taux quadratique à {a:2d} ans : {float(taux_quadratique(q, a))*100:.2f} %")
316 + pr = profils(m, sujet, vlr["retenue"], q)
317 + print(f"== Profils : prix hédonique du sujet neuf {f0(pr['prix_neuf'])} $ ; part du terrain α = {pr['alpha']:.3f}")
318 + print(" âge | âge-vie | extraction | hédonique total → bâtiment | générateur total → bâtiment")
319 + for a in (10, 20, 28, 40, 60, 80):
320 + i = int(a)
321 + print(f" {a:3d} | {pr['age_vie'][i]*100:6.1f} | {pr['extraction'][i]*100:7.1f} | {pr['hedonique_total'][i]*100:6.1f} → {pr['hedonique_bat'][i]*100:6.1f} | {pr['generateur_total'][i]*100:6.1f} → {pr['generateur_bat'][i]*100:6.1f}")
322 + vs = valeur_sujet(cn, vlr["retenue"], q, se["desuetude"], var)
323 + print("== Valeur par le coût du sujet (traite.cout.valeur_par_cout) :")
324 + A, B = vs["A_ventilee"], vs["B_extraction"]
325 + print(f" (A) ventilée : V_L {f0(vlr['retenue'])} + C_N {f2(cn['total'])} − D_phys {f2(ve['total'])} − D_fonc {f2(se['desuetude'])} = {f2(A['valeur'])} $ (dépréciation totale {A['taux_depreciation']*100:.2f} %)")
326 + print(f" (B) extraction : D_extraite(28) = {vs['taux_extrait_28']*100:.2f} % × C_N = {f2(vs['d_extraite'])} $ ; V = {f0(vlr['retenue'])} + {f2(cn['total'])} − {f2(vs['d_extraite'])} − {f2(se['desuetude'])} = {f2(B['valeur'])} $ "
327 + f"(dépréciation totale {f2(B['depreciation_totale'])} $ = {B['taux_depreciation']*100:.2f} % ; contribution du bâtiment {f2(B['contribution_batiment'])} $)")
328 + print(f" écart B − A = {f2(vs['ecart'])} $ ; VALEUR RETENUE PAR LE COÛT {f2(vs['valeur_retenue'])} $, arrondie {f0(vs['valeur_arrondie'])} $")
329 + gr = grille_age_vie(cn, var["curable"])
330 + print("== Sensibilité âge-vie modifiée (taux en %) : vie économique (lignes) × âge effectif (colonnes)"); print((gr * 100).round(1).to_string())
331 + sx = sensibilite_extraction(v, m, cn["total"])
332 + print("== Sensibilité de l'extraction :"); print(sx.assign(taux_28=sx.taux_28 * 100, taux_annuel=sx.taux_annuel * 100).round(2).to_string())
333 + to = tornade(cn, vlr["retenue"], vs["d_extraite"], se["desuetude"], var, sx)
334 + print("== Tornade (valeur par le coût) :")
335 + for o in to:
336 + print(f" {o['param']:48s} {o['etiquette']:28s} bas {f0(o['bas'])} haut {f0(o['haut'])} (base {f0(o['base'])}) ; étendue {(o['haut']-o['bas'])/o['base']*100:.1f} %")
337 + return {"cn": cn, "fiche": fiche, "travaux": trav, "ventes": v, "modele": m, "sujet": sujet, "devis": devis, "ages": ag, "variantes": var,
338 + "salle_eau": se, "exemples": ex, "extraction": w, "vl": vl, "vl_retenue": vlr, "tranches": tr, "ajustements": aj, "profils": pr,
339 + "valeur": vs, "grille": gr, "sens_extraction": sx, "tornade": to}
340 +
341 +
342 +if __name__ == "__main__":
343 + main()
added analysis/python/cases/ch25_terrain.py +364 −0
@@ -0,0 +1,364 @@
1 +#!/usr/bin/env python3
2 +"""Chapitre 25 — Évaluation du terrain (DONNÉES SYNTHÉTIQUES, graine 20261031).
3 +
4 +Imprime tous les chiffres cités dans le chapitre 25 :
5 + * jeu synthétique de ventes de terrains vacants « Gatineau » (data/synthetic/ch25_terrains_ventes.csv) : lois déclarées ;
6 + * modèle hédonique des terrains (traite.hedonique.mco) : élasticité de superficie, façade, services, topographie, forme, densité ;
7 + * comparaison directe : sélection de cinq terrains comparables pour le sujet du cas 1 et grille d'ajustements ;
8 + * allocation : part du terrain dans les ventes du cas 1 (secteur Hull) ; prix implicite marginal du modèle hédonique (traite.cas1) ;
9 + * extraction : prix − coût déprécié du bâtiment sur les cinq comparables du chapitre 16 (coût neuf du chapitre 21) ;
10 + * technique résiduelle du terrain sur le cas 3 (RNE de traite.cas3 ; taux déclarés) et sensibilité ;
11 + * lotissement (développement actualisé) d'un site de 10 000 m² en 12 terrains (traite.finance.van) ;
12 + * capitalisation d'une rente foncière (emphytéose) ;
13 + * synthèse des indications et valeur de terrain retenue pour le sujet du cas 1 (reprise par les ch. 20, 24, 45).
14 +
15 +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch25_terrain.py
16 +Toutes les fonctions financières proviennent de analysis/python/traite (aucune réécriture)."""
17 +from __future__ import annotations
18 +import sys, json, pathlib
19 +import numpy as np, pandas as pd
20 +
21 +ROOT = pathlib.Path(__file__).resolve().parents[3]
22 +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
23 +from traite import hedonique as hd, comparables as cp, finance as fin, cout as ct, cas1, cas3
24 +
25 +D = ROOT / "data/synthetic"
26 +CSV_TERRAINS = D / "ch25_terrains_ventes.csv"
27 +GRAINE = 20261031
28 +REF_DATE = pd.Timestamp("2024-07-01")
29 +DATE_EVAL = pd.Timestamp("2026-09-01")
30 +
31 +# ----------------------------------------------------------------------------- 1. jeu synthétique de ventes de terrains
32 +# Lois déclarées (hypothèses pédagogiques ; voir data/synthetic/README.md, section « Chapitre 25 »)
33 +P_REF = 225_000.0 # prix du terrain de référence : 560 m², façade 18 m, desservi, plat, régulier, 1 logement, Hull, 2024-07-01
34 +ELAST_T = 0.50 # élasticité du prix du terrain à la superficie (< 1 : valeur unitaire décroissante)
35 +ELAST_F = 0.08 # élasticité à la façade (à superficie donnée)
36 +B_NON_DESSERVI = -0.30 # ni aqueduc ni égout (puits + installation septique à la charge de l'acheteur)
37 +B_PENTE = -0.06 # pente marquée
38 +B_ROC = -0.12 # roc affleurant ou remblai important
39 +B_IRREG = -0.05 # forme irrégulière (triangle, drapeau, trapèze prononcé)
40 +B_LOGEMENT = 0.12 # par logement constructible supplémentaire (zonage H2/H3)
41 +TENDANCE_MOIS = 0.0036 # même tendance que le coefficient « mois » du modèle hédonique du cas 1 (≈ 4,4 %/an)
42 +SIGMA = 0.10
43 +MEDIANES_APCIQ = {"Hull": 475_000, "Gatineau": 464_900, "Aylmer": 530_000, "Buckingham": 395_000, "Masson-Angers": 395_000 * 0.98}
44 +PRIME_SECTEUR = {k: float(np.log(v / MEDIANES_APCIQ["Hull"])) for k, v in MEDIANES_APCIQ.items()}
45 +CENTROIDES = {"Hull": (45.4296, -75.7450), "Gatineau": (45.4769, -75.6650), "Aylmer": (45.3936, -75.8450),
46 + "Buckingham": (45.5899, -75.4200), "Masson-Angers": (45.5468, -75.4150)}
47 +N_PAR_SECTEUR = {"Hull": 70, "Gatineau": 60, "Aylmer": 40, "Buckingham": 15, "Masson-Angers": 15}
48 +
49 +def generer_terrains(graine: int = GRAINE) -> pd.DataFrame:
50 + """Ventes synthétiques de terrains vacants résidentiels (2023-01 → 2026-08). Aucun terrain réel."""
51 + rng = np.random.default_rng(graine)
52 + rows = []
53 + k = 0
54 + for sct, n in N_PAR_SECTEUR.items():
55 + periurbain = sct in ("Buckingham", "Masson-Angers")
56 + for _ in range(n):
57 + k += 1
58 + desservi = int(rng.random() < (0.30 if periurbain else 0.85))
59 + if desservi:
60 + T = float(np.clip(np.exp(rng.normal(np.log(600), 0.28)), 320, 2500))
61 + else:
62 + T = float(np.clip(np.exp(rng.normal(np.log(2200), 0.45)), 900, 9000))
63 + F = float(np.clip(0.60 * np.sqrt(T) * np.exp(rng.normal(0, 0.14)), 10, 90))
64 + topo = rng.choice(["plat", "pente", "roc"], p=[0.70, 0.20, 0.10])
65 + forme = rng.choice(["régulier", "irrégulier"], p=[0.80, 0.20])
66 + logements = int(rng.choice([1, 2, 3], p=[0.82, 0.12, 0.06])) if desservi and not periurbain else 1
67 + jours = int(rng.integers(0, (pd.Timestamp("2026-08-31") - pd.Timestamp("2023-01-01")).days + 1))
68 + date = pd.Timestamp("2023-01-01") + pd.Timedelta(days=jours)
69 + mois = (date - REF_DATE).days / 30.4375
70 + lat0, lon0 = CENTROIDES[sct]
71 + lat = lat0 + rng.normal(0, 0.012); lon = lon0 + rng.normal(0, 0.017)
72 + lnp = (np.log(P_REF) + ELAST_T * np.log(T / 560) + ELAST_F * np.log(F / 18) + B_NON_DESSERVI * (1 - desservi)
73 + + B_PENTE * (topo == "pente") + B_ROC * (topo == "roc") + B_IRREG * (forme == "irrégulier")
74 + + B_LOGEMENT * (logements - 1) + PRIME_SECTEUR[sct] + TENDANCE_MOIS * mois + rng.normal(0, SIGMA))
75 + prix = float(np.round(np.exp(lnp) / 500) * 500)
76 + rows.append({"id_vente": f"T{k:03d}", "date_vente": date.strftime("%Y-%m-%d"), "secteur": sct, "superficie_m2": round(T, 1),
77 + "facade_m": round(F, 2), "desservi": desservi, "topographie": topo, "forme": forme, "logements_constructibles": logements,
78 + "lat": round(lat, 5), "lon": round(lon, 5), "prix_vente": prix})
79 + df = pd.DataFrame(rows)
80 + df["prix_m2"] = df["prix_vente"] / df["superficie_m2"]
81 + return df
82 +
83 +def charger_terrains(regenerer: bool = False) -> pd.DataFrame:
84 + if regenerer or not CSV_TERRAINS.exists():
85 + df = generer_terrains(); df.to_csv(CSV_TERRAINS, index=False)
86 + df = pd.read_csv(CSV_TERRAINS, parse_dates=["date_vente"])
87 + return df
88 +
89 +# ----------------------------------------------------------------------------- 2. modèle hédonique des terrains
90 +SECTEURS = ["Hull", "Gatineau", "Aylmer", "Buckingham", "Masson-Angers"]
91 +
92 +def matrice_terrains(df: pd.DataFrame) -> pd.DataFrame:
93 + X = pd.DataFrame(index=df.index)
94 + X["const"] = 1.0
95 + X["ln_T"] = np.log(df["superficie_m2"] / 560.0)
96 + X["ln_F"] = np.log(df["facade_m"] / 18.0)
97 + X["non_desservi"] = 1.0 - df["desservi"].astype(float)
98 + X["pente"] = (df["topographie"] == "pente").astype(float)
99 + X["roc"] = (df["topographie"] == "roc").astype(float)
100 + X["irregulier"] = (df["forme"] == "irrégulier").astype(float)
101 + X["logements_sup"] = df["logements_constructibles"].astype(float) - 1.0
102 + for s in SECTEURS[1:]:
103 + X[f"sect_{s}"] = (df["secteur"] == s).astype(float)
104 + X["mois"] = ((pd.to_datetime(df["date_vente"]) - REF_DATE).dt.days / 30.4375).astype(float)
105 + return X
106 +
107 +def hedonique_terrains(df: pd.DataFrame) -> dict:
108 + X = matrice_terrains(df); y = np.log(df["prix_vente"].astype(float))
109 + r = hd.mco(X.values, y.values)
110 + coef = pd.DataFrame({"coef": r["beta"], "se_hc1": r["se_hc1"]}, index=X.columns)
111 + coef["t"] = coef["coef"] / coef["se_hc1"]; coef["effet_pct"] = np.exp(coef["coef"]) - 1
112 + return {"coef": coef, "beta": pd.Series(r["beta"], index=X.columns), "r2": r["r2"], "sigma": float(np.sqrt(r["sigma2"])), "n": r["n"],
113 + "residus": r["residus"], "X": X, "duan": float(np.mean(np.exp(r["residus"])))}
114 +
115 +def sujet_terrain() -> dict:
116 + """Le terrain du sujet du cas 1, réputé vacant : 620 m², Hull, desservi, plat, régulier, zonage unifamilial ; façade posée à 20 m (hypothèse)."""
117 + s = cas1.charger()["sujet"]
118 + return {"id_vente": "SUJET", "secteur": s["secteur"], "superficie_m2": float(s["superficie_terrain_m2"]), "facade_m": 20.0, "desservi": 1,
119 + "topographie": "plat", "forme": "régulier", "logements_constructibles": 1, "lat": s["lat"], "lon": s["lon"], "date_vente": DATE_EVAL}
120 +
121 +def predire_terrain(modele: dict, terrain: dict | pd.DataFrame, duan: bool = True) -> float:
122 + df = pd.DataFrame([terrain]) if isinstance(terrain, dict) else terrain
123 + X = matrice_terrains(df)[modele["X"].columns]
124 + return float(np.exp(X.values @ modele["beta"].values)[0] * (modele["duan"] if duan else 1.0))
125 +
126 +# ----------------------------------------------------------------------------- 3. comparaison directe
127 +ECHELLES = {"superficie_m2": 100.0, "facade_m": 4.0, "dist_km": 1.5, "mois_anc": 6.0}
128 +POIDS = {"superficie_m2": 2.0, "facade_m": 0.5, "dist_km": 1.5, "mois_anc": 1.0}
129 +
130 +def selection_terrains(df: pd.DataFrame, suj: dict, k: int = 5, mois_max: float = 24, dist_max: float = 4.0) -> dict:
131 + v = df.copy()
132 + v["mois_anc"] = (DATE_EVAL - v["date_vente"]).dt.days / 30.4375
133 + v["dist_km"] = np.sqrt(((v["lat"] - suj["lat"]) * 111) ** 2 + ((v["lon"] - suj["lon"]) * 78) ** 2)
134 + etapes = {"total": len(v)}
135 + f = v[v["mois_anc"].between(0, mois_max)]; etapes["fenetre"] = len(f)
136 + f = f[f["dist_km"] <= dist_max]; etapes["rayon"] = len(f)
137 + f = f[(f["desservi"] == 1) & (f["logements_constructibles"] == 1)]; etapes["desservi_unifamilial"] = len(f)
138 + f = f[f["superficie_m2"].between(0.6 * suj["superficie_m2"], 1.4 * suj["superficie_m2"])]; etapes["superficie"] = len(f)
139 + s0 = dict(suj); s0["mois_anc"] = 0.0; s0["dist_km"] = 0.0
140 + f = f.assign(distance=[cp.distance_attributs(s0, r, ECHELLES, POIDS) for r in f.to_dict("records")]).sort_values("distance")
141 + return {"etapes": etapes, "candidats": f, "comparables": f.head(k), "ventes": v}
142 +
143 +GROUPES = {"conditions_marche": ["mois"], "localisation": [f"sect_{s}" for s in SECTEURS[1:]], "superficie": ["ln_T"], "facade": ["ln_F"],
144 + "services": ["non_desservi"], "topographie": ["pente", "roc"], "forme": ["irregulier"], "densite": ["logements_sup"]}
145 +
146 +def grille_terrains(modele: dict, comps: pd.DataFrame, suj: dict) -> pd.DataFrame:
147 + """Ajustements en cascade dérivés du modèle hédonique des terrains (même logique que traite.cas1.ajustements_hedoniques) :
148 + A_k = base × [exp(β_k (x_s − x_c)) − 1], conditions du marché d'abord, puis localisation, puis caractéristiques physiques."""
149 + Xs = matrice_terrains(pd.DataFrame([suj])).iloc[0]; Xc = matrice_terrains(comps); beta = modele["beta"]
150 + rows = []
151 + for i, c in comps.iterrows():
152 + base = float(c["prix_vente"]); row = {"id_vente": c["id_vente"], "prix_vente": base}
153 + for g, cols in GROUPES.items():
154 + dlog = float(sum(beta[col] * (Xs[col] - Xc.loc[i, col]) for col in cols))
155 + adj = base * (np.exp(dlog) - 1.0); row[g] = adj; base += adj
156 + row["prix_ajuste"] = base
157 + row["ajust_brut_pct"] = sum(abs(row[g]) for g in GROUPES) / row["prix_vente"]
158 + row["ajust_net_pct"] = (base - row["prix_vente"]) / row["prix_vente"]
159 + rows.append(row)
160 + return pd.DataFrame(rows)
161 +
162 +def reconcilier(grille: pd.DataFrame) -> dict:
163 + objs = [cp.ComparableAjuste(r["id_vente"], r["prix_vente"], {g: r[g] for g in GROUPES}) for _, r in grille.iterrows()]
164 + w = cp.poids_inverse_brut(objs)
165 + return {**cp.reconciliation(objs, w), "objets": objs}
166 +
167 +def comparaison(df: pd.DataFrame | None = None) -> dict:
168 + df = charger_terrains() if df is None else df
169 + m = hedonique_terrains(df); suj = sujet_terrain(); sel = selection_terrains(df, suj)
170 + g = grille_terrains(m, sel["comparables"], suj); rec = reconcilier(g)
171 + pred = predire_terrain(m, suj)
172 + return {"terrains": df, "modele": m, "sujet": suj, "selection": sel, "grille": g, "reconciliation": rec, "prediction_hedonique": pred,
173 + "valeur_m2": rec["valeur_ponderee"] / suj["superficie_m2"]}
174 +
175 +# ----------------------------------------------------------------------------- 4. allocation et prix implicite marginal (cas 1)
176 +C_N_SUJET = 397_567.33 # coût neuf du sujet du cas 1 (ch. 21, analysis/python/cases/ch21_cout_neuf.py)
177 +ELAST_COUT_TOTAL = 0.90 # élasticité du coût total à la superficie (ch. 21 : ε = −0,10 sur le coût unitaire)
178 +QUALITE_COUT = 0.10 # ±10 % de coût neuf par classe de qualité (hypothèse pédagogique)
179 +VIE_ECONOMIQUE = 60.0 # variante « âge-vie » de l'extraction (convention déclarée ; le ch. 24 discute la vie économique)
180 +
181 +def cout_neuf_comparable(S: float, qualite: float) -> float:
182 + """Coût neuf d'un comparable transposé depuis le sujet : C_N(S, q) = C_N,sujet × (S/165)^0,90 × (1 + 0,10 (q − 3))."""
183 + return C_N_SUJET * (S / 165.0) ** ELAST_COUT_TOTAL * (1 + QUALITE_COUT * (qualite - 3))
184 +
185 +def facteur_age(modele_cas1: dict, age) -> np.ndarray:
186 + """Rapport prix à l'âge a / prix neuf selon le profil d'âge du modèle hédonique du cas 1 : exp(β_age a + β_age2 a²/100)."""
187 + b = modele_cas1["coef"]["coef"]
188 + a = np.asarray(age, float)
189 + return np.exp(b["age"] * a + b["age2_100"] * a ** 2 / 100.0)
190 +
191 +def extraction_cas1(cas: dict | None = None) -> dict:
192 + """Extraction sur les cinq comparables du chapitre 16 : V_L = P − C_N(1 − δ), sous deux conventions de dépréciation :
193 + (a) âge-vie linéaire (vie 60 ans) ; (b) dépréciation implicite du marché (profil d'âge hédonique) : V_L = P_neuf − C_N."""
194 + cas = cas or cas1.tout()
195 + comps = cas["selection"]["comparables"].copy(); m = cas["modele"]
196 + comps["C_N"] = cout_neuf_comparable(comps["superficie_habitable_m2"], comps["qualite_1a5"])
197 + comps["delta_agevie"] = comps["age"] / VIE_ECONOMIQUE
198 + comps["V_L_agevie"] = comps["prix_vente"] - comps["C_N"] * (1 - comps["delta_agevie"])
199 + f = facteur_age(m, comps["age"]); comps["P_neuf"] = comps["prix_vente"] / f
200 + comps["delta_marche"] = (comps["P_neuf"] - comps["prix_vente"]) / comps["C_N"]
201 + comps["V_L_marche"] = comps["P_neuf"] - comps["C_N"]
202 + for k in ("agevie", "marche"):
203 + comps[f"V_L_{k}_m2"] = comps[f"V_L_{k}"] / comps["superficie_terrain_m2"]
204 + # transposition au terrain du sujet (620 m²) avec l'élasticité de superficie du marché des terrains
205 + comps[f"V_L_{k}_sujet"] = comps[f"V_L_{k}"] * (620.0 / comps["superficie_terrain_m2"]) ** ELAST_T
206 + # Hull, toutes ventes : part du terrain par extraction (dépréciation de marché)
207 + v = cas["ventes"]; h = v[v["secteur"] == "Hull"].copy()
208 + h["C_N"] = cout_neuf_comparable(h["superficie_habitable_m2"], h["qualite_1a5"])
209 + h["V_L"] = h["prix_vente"] / facteur_age(m, h["age"]) - h["C_N"]; h["part"] = h["V_L"] / h["prix_vente"]; h["V_L_m2"] = h["V_L"] / h["superficie_terrain_m2"]
210 + return {"comparables": comps, "hull": h, "part_hull_mediane": float(h["part"].median()), "part_hull_q1": float(h["part"].quantile(0.25)),
211 + "part_hull_q3": float(h["part"].quantile(0.75)), "V_L_hull_mediane": float(h["V_L"].median()),
212 + "indication_agevie": float(comps["V_L_agevie_sujet"].median()), "indication_marche": float(comps["V_L_marche_sujet"].median())}
213 +
214 +def allocation_cas1(cas: dict | None = None, extr: dict | None = None) -> dict:
215 + cas = cas or cas1.tout(); extr = extr or extraction_cas1(cas)
216 + a = extr["part_hull_mediane"]
217 + P_grille = float(cas["ajustements"]["prix_ajuste"].mean()) # moyenne des prix ajustés des 5 comparables (ch. 17)
218 + P_hed = float(cas["prediction_hedonique"]) # prédiction hédonique du sujet (ch. 18)
219 + b = cas["modele"]["coef"]["coef"]; T = 620.0
220 + return {"ratio": a, "sur_grille": a * P_grille, "sur_hedonique": a * P_hed, "P_grille": P_grille, "P_hed": P_hed,
221 + "beta_ln_T": float(b["ln_T"]), "prix_marginal_m2": float(b["ln_T"] * P_hed / T), "contribution_marginale": float(b["ln_T"] * P_hed),
222 + "sujet_age_factor": float(facteur_age(cas["modele"], 28)), "delta_marche_sujet": float((P_hed / facteur_age(cas["modele"], 28) - P_hed) / C_N_SUJET)}
223 +
224 +# ----------------------------------------------------------------------------- 5. technique résiduelle du terrain (cas 3)
225 +RES = {"cout_unitaire_neuf": 2500.0, "superficie_brute_m2": 950.0, "taux_depreciation": 0.52, "R_L": 0.0375, "n_restante": 40, "terrain_m2": 740.0}
226 +
227 +def residuelle_cas3(params: dict | None = None, t3: dict | None = None) -> dict:
228 + p = {**RES, **(params or {})}; t3 = t3 or cas3.tout()
229 + noi = float(t3["base"]["rne"]); R = float(t3["taux"]["mediane"]); V_cap = float(t3["valeur_capitalisation"])
230 + C_N = p["cout_unitaire_neuf"] * p["superficie_brute_m2"]; C_B = C_N * (1 - p["taux_depreciation"])
231 + R_B = p["R_L"] + 1.0 / p["n_restante"]
232 + V_L = (noi - R_B * C_B) / p["R_L"]
233 + R_L_implicite = (noi - R_B * C_B) / (V_cap - C_B) if V_cap > C_B else float("nan")
234 + return {"noi": noi, "R": R, "V_cap": V_cap, "C_N": C_N, "C_B": C_B, "R_L": p["R_L"], "R_B": R_B, "revenu_batiment": R_B * C_B,
235 + "revenu_terrain": noi - R_B * C_B, "V_L": V_L, "V_L_m2": V_L / p["terrain_m2"], "somme": V_L + C_B, "ecart_vs_cap": (V_L + C_B) / V_cap - 1,
236 + "R_L_implicite": R_L_implicite, "part_terrain": V_L / (V_L + C_B), "params": p}
237 +
238 +def sensibilite_residuelle(t3: dict | None = None, R_Ls=(0.0325, 0.035, 0.0375, 0.04, 0.0425), ns=(30, 40, 50), deltas=(0.44, 0.52, 0.60)) -> pd.DataFrame:
239 + t3 = t3 or cas3.tout(); rows = []
240 + for d in deltas:
241 + for n in ns:
242 + for rl in R_Ls:
243 + r = residuelle_cas3({"taux_depreciation": d, "n_restante": n, "R_L": rl}, t3)
244 + rows.append({"taux_depreciation": d, "C_B": r["C_B"], "n_restante": n, "R_L": rl, "R_B": r["R_B"], "V_L": r["V_L"], "V_L_m2": r["V_L_m2"]})
245 + return pd.DataFrame(rows)
246 +
247 +# ----------------------------------------------------------------------------- 6. lotissement (développement actualisé)
248 +LOT = {"site_m2": 10_000.0, "n_lots": 12, "lot_m2": 625.0, "rue_m2": 1500.0, "parc_m2": 1000.0, "facade_m": 20.0,
249 + "infrastructure_par_lot": 65_000.0, "honoraires_permis": 90_000.0, "taxes_annuelles": 12_000.0, "frais_vente_pct": 0.04,
250 + "profit_pct": 0.15, "lots_par_an": 4, "taux": 0.10, "croissance_prix": 0.0}
251 +
252 +def lotissement(modele_terrains: dict, params: dict | None = None) -> dict:
253 + """Valeur d'un site brut par la méthode du lotissement actualisé (subdivision development method).
254 + Produit des ventes de lots finis au prix prédit par le modèle hédonique des terrains à la date d'évaluation, moins les coûts
255 + de viabilisation, d'honoraires, de portage, de vente et le profit du promoteur ; flux de fin d'année actualisés au taux déclaré."""
256 + p = {**LOT, **(params or {})}
257 + assert abs(p["n_lots"] * p["lot_m2"] + p["rue_m2"] + p["parc_m2"] - p["site_m2"]) < 1e-6
258 + lot = {"secteur": "Hull", "superficie_m2": p["lot_m2"], "facade_m": p["facade_m"], "desservi": 1, "topographie": "plat", "forme": "régulier",
259 + "logements_constructibles": 1, "date_vente": DATE_EVAL}
260 + prix_lot = predire_terrain(modele_terrains, lot)
261 + n_ans = int(np.ceil(p["n_lots"] / p["lots_par_an"]))
262 + flux, detail = [], []
263 + # t = 0 : honoraires, permis, études
264 + f0 = -p["honoraires_permis"]; flux.append(f0); detail.append({"annee": 0, "lots_vendus": 0, "revenus": 0.0, "infrastructure": 0.0, "honoraires": p["honoraires_permis"],
265 + "taxes": 0.0, "frais_vente": 0.0, "profit": 0.0, "flux": f0})
266 + restants = p["n_lots"]
267 + for t in range(1, n_ans + 1):
268 + vendus = min(p["lots_par_an"], restants); restants -= vendus
269 + rev = vendus * prix_lot * (1 + p["croissance_prix"]) ** (t - 1)
270 + infra = p["infrastructure_par_lot"] * p["n_lots"] if t == 1 else 0.0
271 + taxes = p["taxes_annuelles"] * (restants + vendus) / p["n_lots"]
272 + fv = p["frais_vente_pct"] * rev; prof = p["profit_pct"] * rev
273 + f = rev - infra - taxes - fv - prof
274 + flux.append(f); detail.append({"annee": t, "lots_vendus": vendus, "revenus": rev, "infrastructure": infra, "honoraires": 0.0, "taxes": taxes,
275 + "frais_vente": fv, "profit": prof, "flux": f})
276 + V = fin.van(flux, p["taux"])
277 + statique = sum(d["flux"] for d in detail)
278 + return {"prix_lot": prix_lot, "produit_brut": prix_lot * p["n_lots"], "flux": flux, "detail": pd.DataFrame(detail), "valeur_actualisee": V,
279 + "valeur_statique": statique, "valeur_m2": V / p["site_m2"], "valeur_statique_m2": statique / p["site_m2"], "n_ans": n_ans,
280 + "ratio_terrain_brut_sur_produit": V / (prix_lot * p["n_lots"]), "params": p}
281 +
282 +def sensibilite_lotissement(modele_terrains: dict) -> dict:
283 + base = lotissement(modele_terrains)["valeur_actualisee"]
284 + out = {"base": base}
285 + out["absorption"] = {k: lotissement(modele_terrains, {"lots_par_an": k})["valeur_actualisee"] for k in (2, 3, 4, 6, 12)}
286 + out["taux"] = {r: lotissement(modele_terrains, {"taux": r})["valeur_actualisee"] for r in (0.08, 0.10, 0.12, 0.15)}
287 + out["prix"] = {}
288 + for d in (-0.10, 0.0, 0.10):
289 + m2 = dict(modele_terrains); m2["duan"] = modele_terrains["duan"] * (1 + d)
290 + out["prix"][d] = lotissement(m2)["valeur_actualisee"]
291 + out["infrastructure"] = {c: lotissement(modele_terrains, {"infrastructure_par_lot": c})["valeur_actualisee"] for c in (50_000, 65_000, 80_000)}
292 + out["profit"] = {c: lotissement(modele_terrains, {"profit_pct": c})["valeur_actualisee"] for c in (0.10, 0.15, 0.20)}
293 + return out
294 +
295 +# ----------------------------------------------------------------------------- 7. capitalisation de la rente foncière
296 +def rente_fonciere(rente: float = 11_000.0, R_L: float = 0.05, g: float = 0.02, n: int = 40, valeur_reversion: float = 300_000.0) -> dict:
297 + """Terrain loué (emphytéose ou bail foncier) : V_L = rente/R_L (rente constante perpétuelle) ; V_L = rente_1/(R_L − g) (Gordon) ;
298 + bail de n ans à rente croissante + réversion actualisée."""
299 + perp = rente / R_L
300 + gordon = rente / fin.taux_gordon(R_L, g)
301 + va_rentes = sum(rente * (1 + g) ** (t - 1) / (1 + R_L) ** t for t in range(1, n + 1))
302 + va_rev = valeur_reversion / (1 + R_L) ** n
303 + return {"perpetuelle": perp, "gordon": gordon, "bail_n": va_rentes + va_rev, "va_rentes": va_rentes, "va_reversion": va_rev, "n": n}
304 +
305 +# ----------------------------------------------------------------------------- 8. synthèse et valeur retenue
306 +def synthese() -> dict:
307 + df = charger_terrains(); comp = comparaison(df); cas = cas1.tout(); extr = extraction_cas1(cas); allo = allocation_cas1(cas, extr)
308 + t3 = cas3.tout(); res = residuelle_cas3(t3=t3); lot = lotissement(comp["modele"]); rf = rente_fonciere()
309 + retenue = round(comp["reconciliation"]["valeur_ponderee"] / 5000) * 5000
310 + return {"comparaison": comp, "cas1": cas, "extraction": extr, "allocation": allo, "cas3": t3, "residuelle": res, "lotissement": lot,
311 + "rente": rf, "valeur_retenue": retenue, "part_retenue_grille": retenue / allo["P_grille"], "part_retenue_hed": retenue / allo["P_hed"]}
312 +
313 +def _d(x): return f"{x:,.0f} $".replace(",", " ")
314 +
315 +def main() -> dict:
316 + S = synthese(); c = S["comparaison"]; m = c["modele"]; df = c["terrains"]
317 + print("=== 1. Jeu synthétique de terrains (ch25_terrains_ventes.csv, graine 20261031) ===")
318 + print(f"n = {len(df)} ; desservis = {int(df.desservi.sum())} ; période {df.date_vente.min().date()} → {df.date_vente.max().date()}")
319 + print(df.groupby("secteur").agg(n=("prix_vente", "size"), T_med=("superficie_m2", "median"), prix_med=("prix_vente", "median"), prix_m2_med=("prix_m2", "median")).round(0))
320 + print(df.groupby("desservi")[["superficie_m2", "prix_vente", "prix_m2"]].median().round(0))
321 + print("\n=== 2. Modèle hédonique des terrains ===")
322 + print(m["coef"].round(4)); print(f"R² = {m['r2']:.3f} ; σ = {m['sigma']:.3f} ; Duan = {m['duan']:.4f}")
323 + print("\n=== 3. Comparaison directe — sujet 620 m², Hull ===")
324 + print("Entonnoir :", c["selection"]["etapes"])
325 + comps = c["selection"]["comparables"]
326 + print(comps[["id_vente", "date_vente", "secteur", "superficie_m2", "facade_m", "topographie", "forme", "dist_km", "mois_anc", "prix_vente", "prix_m2", "distance"]].round(2).to_string())
327 + print(c["grille"].round(0).to_string())
328 + r = c["reconciliation"]
329 + print(f"Poids (inverse du brut) : {np.round(r['poids'], 3)} ; valeur pondérée {_d(r['valeur_ponderee'])} ; moyenne {_d(r['moyenne'])} ; médiane {_d(r['mediane'])} ; "
330 + f"min {_d(r['min'])} ; max {_d(r['max'])} ; étendue {r['etendue_pct']*100:.1f} % ; {_d(c['valeur_m2'])}/m²")
331 + print(f"Prédiction hédonique directe du terrain du sujet : {_d(c['prediction_hedonique'])}")
332 + print("\n=== 4. Allocation et prix implicite marginal (cas 1) ===")
333 + a = S["allocation"]; e = S["extraction"]
334 + print(f"Part du terrain, ventes de Hull (extraction, dépréciation de marché) : médiane {a['ratio']*100:.1f} % (Q1 {e['part_hull_q1']*100:.1f} %, Q3 {e['part_hull_q3']*100:.1f} %) ; V_L médian Hull {_d(e['V_L_hull_mediane'])}")
335 + print(f"Allocation sur la grille du ch. 17 ({_d(a['P_grille'])}) : {_d(a['sur_grille'])} ; sur la prédiction hédonique ({_d(a['P_hed'])}) : {_d(a['sur_hedonique'])}")
336 + print(f"β(ln T) = {a['beta_ln_T']:.4f} → prix implicite marginal {a['prix_marginal_m2']:.1f} $/m² ; contribution marginale β × P = {_d(a['contribution_marginale'])}")
337 + print(f"Facteur d'âge du sujet (28 ans) : {a['sujet_age_factor']:.4f} → dépréciation implicite {a['delta_marche_sujet']*100:.1f} % de C_N")
338 + print("\n=== 5. Extraction sur les 5 comparables du ch. 16 ===")
339 + cols = ["id_vente", "prix_vente", "superficie_habitable_m2", "superficie_terrain_m2", "age", "qualite_1a5", "C_N", "delta_agevie", "V_L_agevie", "V_L_agevie_m2",
340 + "P_neuf", "delta_marche", "V_L_marche", "V_L_marche_m2", "V_L_agevie_sujet", "V_L_marche_sujet"]
341 + print(e["comparables"][cols].round(3).to_string())
342 + print(f"Indication (médiane transposée à 620 m²) : âge-vie 60 ans {_d(e['indication_agevie'])} ; dépréciation de marché {_d(e['indication_marche'])}")
343 + print("\n=== 6. Technique résiduelle du terrain (cas 3) ===")
344 + rs = S["residuelle"]
345 + print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in rs.items() if k != "params"})
346 + sens = sensibilite_residuelle(S["cas3"]); print(sens.pivot_table(index=["taux_depreciation", "n_restante"], columns="R_L", values="V_L").round(0))
347 + print("\n=== 7. Lotissement actualisé ===")
348 + L = S["lotissement"]; print(f"Prix d'un lot fini (625 m², Hull, {DATE_EVAL.date()}) : {_d(L['prix_lot'])} ; produit brut {_d(L['produit_brut'])}")
349 + print(L["detail"].round(0).to_string())
350 + print(f"Valeur actualisée du site brut : {_d(L['valeur_actualisee'])} ({L['valeur_m2']:.1f} $/m²) ; statique {_d(L['valeur_statique'])} ({L['valeur_statique_m2']:.1f} $/m²) ; "
351 + f"ratio terrain brut / produit {L['ratio_terrain_brut_sur_produit']*100:.1f} %")
352 + sl = sensibilite_lotissement(m)
353 + for k, v in sl.items():
354 + if k != "base": print(k, {kk: round(vv) for kk, vv in v.items()})
355 + print("\n=== 8. Rente foncière ===")
356 + print({k: round(v, 0) if isinstance(v, float) else v for k, v in S["rente"].items()})
357 + print("\n=== 9. Synthèse — sujet du cas 1 ===")
358 + print(f"Comparaison (terrains) {_d(r['valeur_ponderee'])} ; extraction (marché) {_d(e['indication_marche'])} ; extraction (âge-vie 60) {_d(e['indication_agevie'])} ; "
359 + f"allocation (grille) {_d(a['sur_grille'])} ; allocation (hédonique) {_d(a['sur_hedonique'])}")
360 + print(f"VALEUR DU TERRAIN RETENUE : {_d(S['valeur_retenue'])} ({S['valeur_retenue']/620:.0f} $/m²) ; part de la grille {S['part_retenue_grille']*100:.1f} % ; part de la prédiction hédonique {S['part_retenue_hed']*100:.1f} %")
361 + return S
362 +
363 +if __name__ == "__main__":
364 + main()
added analysis/python/cases/ch26_etat_locatif.py +318 −0
@@ -0,0 +1,318 @@
1 +#!/usr/bin/env python3
2 +"""Chapitre 26 — Baux et loyers : calculs de l'exemple intégral (cas 3, 12 logements à Montréal ; cas 2, triplex à Québec ;
3 +DONNÉES SYNTHÉTIQUES) et des exercices.
4 +
5 +Tous les chiffres cités dans manuscript/chapters/ch26.tex et solutions/ch26.tex sortent d'ici. Les revenus du cas 3 et du
6 +cas 2 viennent des modules partagés traite.cas3 / traite.cas2 (fonctions `charger`, `revenus`, `rne`, `taux_extraits`,
7 +`indicateurs_marche`) afin de coïncider avec les chapitres 2, 27-31 et 46-47 ; les facteurs d'actualisation viennent de
8 +traite.finance. La formule fermée de la valeur actuelle de l'écart contractuel/marché sous rotation géométrique est celle
9 +du chapitre 2 (éq. « rotation-va ») ; elle est appliquée ici logement par logement.
10 +
11 +Hypothèses de travail propres au chapitre (déclarées comme telles dans le texte, pas des statistiques) :
12 + - profil mensuel de consommation d'électricité d'un 4½ chauffé à l'électricité, 12 000 kWh/an (PROFIL_KWH) ;
13 + - loyer « chauffé-éclairé » d'un comparable fictif : 1 450 $/mois (COMPARABLE_CHAUFFE) ;
14 + - structure du tarif D d'Hydro-Québec en vigueur le 1er avril 2026 (source : hydrotarifs2026, section 2.5).
15 +
16 +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch26_etat_locatif.py
17 +"""
18 +from __future__ import annotations
19 +import sys, pathlib, datetime as dt
20 +import numpy as np, pandas as pd
21 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
22 +from traite import cas3, cas2, finance as fin
23 +
24 +ROOT = pathlib.Path(__file__).resolve().parents[3]
25 +DATE_EVAL = dt.date(2026, 9, 1)
26 +
27 +# ----------------------------------------------------------------------------- tarif D (Hydro-Québec, 1er avril 2026)
28 +TARIF_D = {"redevance_par_jour": 0.46154, "prix_tranche1": 0.07065, "seuil_kwh_par_jour": 40.0, "prix_tranche2": 0.11142}
29 +# Hypothèse de travail (pas une statistique) : consommation mensuelle d'un 4½ de 70 m² chauffé à l'électricité, 12 000 kWh/an.
30 +MOIS = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."]
31 +JOURS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
32 +PROFIL_KWH = [1900, 1700, 1400, 1000, 700, 550, 500, 500, 600, 850, 1150, 1150]
33 +COMPARABLE_CHAUFFE = 1450.0 # $/mois, loyer « chauffé-éclairé » d'un comparable fictif (hypothèse pédagogique)
34 +GESTION = 0.04 # convention du cas 3 : gestion imputée à 4 % du RBE
35 +
36 +
37 +# ----------------------------------------------------------------------------- état locatif et indicateurs
38 +def tableau_etat(etat: pd.DataFrame, date_eval: dt.date = DATE_EVAL) -> pd.DataFrame:
39 + """État locatif enrichi : ancienneté (années), écarts mensuel/annuel, ratio contrat/marché, mois avant l'échéance."""
40 + t = etat.copy()
41 + t["anciennete"] = date_eval.year - t["locataire_depuis"].astype(int)
42 + t["ecart_mensuel"] = t["loyer_mensuel_marche"] - t["loyer_mensuel_contrat"]
43 + t["ecart_annuel"] = 12 * t["ecart_mensuel"]
44 + t["ecart_pct"] = t["ecart_mensuel"] / t["loyer_mensuel_marche"]
45 + t["ratio"] = t["loyer_mensuel_contrat"] / t["loyer_mensuel_marche"]
46 + ech = pd.to_datetime(t["echeance_bail"]).dt.date
47 + t["mois_echeance"] = [round((e - date_eval).days / 30.4375, 1) for e in ech]
48 + t["loyer_m2_contrat"] = t["loyer_mensuel_contrat"] / t["superficie_m2"]
49 + t["loyer_m2_marche"] = t["loyer_mensuel_marche"] / t["superficie_m2"]
50 + return t
51 +
52 +
53 +def indicateurs(etat: pd.DataFrame, hyp: dict, seuils=(0.05, 0.10, 0.20)) -> dict:
54 + """Indicateurs synthétiques de l'état locatif (éq. 26.1 à 26.4 du chapitre)."""
55 + t = tableau_etat(etat)
56 + r = cas3.revenus(etat, hyp) # RBP contrat / marché / écart (module partagé)
57 + lc, lm = t["loyer_mensuel_contrat"].astype(float), t["loyer_mensuel_marche"].astype(float)
58 + out = {**r, "n": len(t),
59 + "ecart_pondere": float((lm - lc).sum() / lm.sum()), # δ̄ = Σ(Lm − Lc) / Σ Lm
60 + "ratio_pondere": float(lc.sum() / lm.sum()),
61 + "ratio_moyen_simple": float(t["ratio"].mean()),
62 + "ecart_mensuel_moyen": float((lm - lc).mean()),
63 + "ecart_mensuel_median": float((lm - lc).median()),
64 + "anciennete_moyenne": float(t["anciennete"].mean()), "anciennete_mediane": float(t["anciennete"].median()),
65 + "anciennete_max": int(t["anciennete"].max()),
66 + "loyer_moyen_contrat": float(lc.mean()), "loyer_moyen_marche": float(lm.mean()),
67 + "loyer_m2_contrat_moyen": float(lc.sum() / t["superficie_m2"].sum()), "loyer_m2_marche_moyen": float(lm.sum() / t["superficie_m2"].sum()),
68 + "part_ecart_top3": float((lm - lc).sort_values(ascending=False).head(3).sum() / (lm - lc).sum()),
69 + "nb_stationnements": int(t["stationnement"].sum()), "tableau": t}
70 + for s in seuils:
71 + out[f"part_sous_marche_{int(s*100)}"] = float((t["ratio"] < 1 - s).mean())
72 + out[f"nb_sous_marche_{int(s*100)}"] = int((t["ratio"] < 1 - s).sum())
73 + return out
74 +
75 +
76 +# ----------------------------------------------------------------------------- rotation, survie et valeur de l'écart
77 +def survie(rho: float, t: int | np.ndarray) -> float | np.ndarray:
78 + """Probabilité qu'un locataire en place le soit encore après t années (départs géométriques, taux ρ)."""
79 + return (1 - rho) ** t
80 +
81 +
82 +def esperance_duree(rho: float) -> float:
83 + """Durée espérée de l'occupation restante, E[n] = Σ t ρ(1−ρ)^(t−1) = 1/ρ (années ; départ compté en fin d'année)."""
84 + if not 0 < rho <= 1:
85 + raise ValueError("ρ doit être dans ]0, 1]")
86 + return 1.0 / rho
87 +
88 +
89 +def va_ecart(delta1: float, r: float, g: float, rho: float) -> float:
90 + """VA espérée de l'écart contractuel/marché d'un logement : Δ₁ reçu avec certitude l'année 1, puis l'écart croît à g et
91 + survit avec probabilité (1−ρ) chaque année → Δ₁ / (r − g + ρ(1+g)) (formule fermée du chapitre 2, éq. rotation-va).
92 + Condition de convergence : (1−ρ)(1+g) < 1+r."""
93 + if (1 - rho) * (1 + g) >= 1 + r:
94 + raise ValueError("série divergente : (1−ρ)(1+g) ≥ 1+r")
95 + return delta1 / (r - g + rho * (1 + g))
96 +
97 +
98 +def va_ecart_serie(delta1: float, r: float, g: float, rho: float, T: int = 400) -> float:
99 + """Même valeur par sommation explicite (vérification numérique de la formule fermée)."""
100 + t = np.arange(1, T + 1)
101 + return float(np.sum(delta1 * ((1 - rho) * (1 + g)) ** (t - 1) / (1 + r) ** t))
102 +
103 +
104 +def va_annuite(delta: float, r: float, n: float) -> float:
105 + """Annuité d'un écart constant pendant n années (éq. « annuite » du chapitre 2) ; n peut être non entier."""
106 + return delta * (1 - (1 + r) ** (-n)) / r
107 +
108 +
109 +def va_ecart_logements(etat: pd.DataFrame, hyp: dict, rhos=(0.085, 0.094, 0.15), r: float | None = None, g: float | None = None) -> pd.DataFrame:
110 + """VA de l'écart par logement, nette de l'inoccupation (convention du chapitre 2 : Δ₁ = 12 × écart mensuel × (1 − v))."""
111 + t = tableau_etat(etat); r = hyp["taux_actualisation"] if r is None else r; g = hyp["croissance_loyers_marche"] if g is None else g
112 + v = hyp["taux_inoccupation_pertes"]
113 + out = t[["logement", "pieces", "anciennete", "ecart_mensuel", "ecart_annuel"]].copy()
114 + out["delta1_net"] = out["ecart_annuel"] * (1 - v)
115 + for rho in rhos:
116 + out[f"va_rho_{rho:.3f}"] = [va_ecart(d, r, g, rho) for d in out["delta1_net"]]
117 + out["va_perpetuel"] = out["delta1_net"] / (r - g)
118 + return out
119 +
120 +
121 +def sensibilite_va(etat: pd.DataFrame, hyp: dict, rhos=(0.05, 0.085, 0.094, 0.15, 0.25), rs=(0.0625, 0.0725, 0.0825), gs=(0.025, 0.035, 0.045)) -> dict:
122 + """VA agrégée de l'écart (12 logements) selon ρ × r (g du cas) et ρ × g (r du cas)."""
123 + t = tableau_etat(etat); v = hyp["taux_inoccupation_pertes"]; d1 = float(t["ecart_annuel"].sum() * (1 - v))
124 + g0, r0 = hyp["croissance_loyers_marche"], hyp["taux_actualisation"]
125 + tab_r = pd.DataFrame({f"r={r:.4f}": [va_ecart(d1, r, g0, rho) for rho in rhos] for r in rs}, index=[f"rho={rho:.3f}" for rho in rhos])
126 + tab_g = pd.DataFrame({f"g={g:.3f}": [va_ecart(d1, r0, g, rho) for rho in rhos] for g in gs}, index=[f"rho={rho:.3f}" for rho in rhos])
127 + return {"delta1_net": d1, "par_taux": tab_r, "par_croissance": tab_g, "sans_rotation": d1 / (r0 - g0)}
128 +
129 +
130 +# ----------------------------------------------------------------------------- loyer économique
131 +def loyer_economique(base: dict, R: float, V: float, n_logements: int, m: float = GESTION) -> dict:
132 + """Loyer économique (loyer de faisabilité) : RBP requis pour que le RNE après réserve rémunère la valeur V au taux R,
133 + compte tenu des dépenses hors gestion DE_hg, de la réserve RR, des autres revenus A, de l'inoccupation v et d'une
134 + gestion à m × RBE : RNE* = R·V ; RBE* = (RNE* + DE_hg + RR)/(1 − m) ; RBP* = (RBE* − A)/(1 − v)."""
135 + det = base["detail"]; v = base["taux_vacance_pertes"]; A = base["autres_revenus"]
136 + gestion = float(det.loc[det["poste"].str.startswith("Gestion"), "normalise"].sum())
137 + de_hg = base["depenses_hors_reserve"] - gestion; RR = base["reserve"]
138 + rne_req = R * V; rbe_req = (rne_req + de_hg + RR) / (1 - m); rbp_req = (rbe_req - A) / (1 - v)
139 + return {"R": R, "V": V, "rne_requis": rne_req, "rbe_requis": rbe_req, "rbp_requis": rbp_req, "de_hors_gestion": de_hg, "reserve": RR,
140 + "loyer_economique_mensuel": rbp_req / (12 * n_logements), "loyer_contrat_mensuel": base["rbp_contrat"] / (12 * n_logements),
141 + "loyer_marche_mensuel": base["rbp_marche"] / (12 * n_logements)}
142 +
143 +
144 +# ----------------------------------------------------------------------------- normalisation : électricité (tarif D)
145 +def cout_electricite_mensuel(kwh: float, jours: int, tarif: dict = TARIF_D) -> dict:
146 + """Coût mensuel hors taxes de vente selon la structure du tarif D : redevance × jours + 1re tranche (≤ 40 kWh/jour) + 2e tranche."""
147 + seuil = tarif["seuil_kwh_par_jour"] * jours
148 + t1 = min(kwh, seuil); t2 = max(kwh - seuil, 0.0)
149 + red = tarif["redevance_par_jour"] * jours
150 + return {"kwh": kwh, "jours": jours, "redevance": red, "tranche1_kwh": t1, "tranche2_kwh": t2,
151 + "cout_tranche1": t1 * tarif["prix_tranche1"], "cout_tranche2": t2 * tarif["prix_tranche2"],
152 + "total": red + t1 * tarif["prix_tranche1"] + t2 * tarif["prix_tranche2"]}
153 +
154 +
155 +def normalisation_electricite(profil=PROFIL_KWH, jours=JOURS, loyer_chauffe: float = COMPARABLE_CHAUFFE, tarif: dict = TARIF_D) -> dict:
156 + rows = [{"mois": m, **cout_electricite_mensuel(k, j, tarif)} for m, k, j in zip(MOIS, profil, jours)]
157 + tab = pd.DataFrame(rows)
158 + annuel = float(tab["total"].sum()); mensuel = annuel / 12
159 + return {"tableau": tab, "kwh_annuel": float(sum(profil)), "cout_annuel": annuel, "cout_mensuel_moyen": mensuel,
160 + "cout_moyen_kwh": annuel / sum(profil), "part_redevance": float(tab["redevance"].sum() / annuel),
161 + "kwh_tranche2": float(tab["tranche2_kwh"].sum()), "mois_depassement": int((tab["tranche2_kwh"] > 0).sum()),
162 + "loyer_chauffe": loyer_chauffe, "loyer_nu": loyer_chauffe - mensuel,
163 + "cout_mensuel_indexe_2027": mensuel * 1.03} # hausse domestique 3,0 % (Régie, D-2026-033) appliquée à toute la facture, approximation
164 +
165 +
166 +# ----------------------------------------------------------------------------- calendrier des avis
167 +def _moins_mois(d: dt.date, k: int) -> dt.date:
168 + """Date d, moins k mois (même quantième, borné à la fin du mois)."""
169 + y, m = d.year, d.month - k
170 + while m <= 0:
171 + m += 12; y -= 1
172 + while m > 12:
173 + m -= 12; y += 1
174 + import calendar
175 + return dt.date(y, m, min(d.day, calendar.monthrange(y, m)[1]))
176 +
177 +
178 +def calendrier(echeance: dt.date = dt.date(2027, 6, 30), duree_mois: int = 12) -> dict:
179 + """Fenêtres légales (C.c.Q.) pour un bail à durée fixe se terminant à `echeance` :
180 + avis de modification 3 à 6 mois avant le terme (art. 1942 ; 1 à 2 mois si bail < 12 mois) ; réponse du locataire dans le
181 + mois (1945) ; demande de fixation dans le mois du refus (1947) ; avis de reprise/éviction 6 mois avant (1960 ; 1 mois si
182 + bail ≤ 6 mois) ; nouveau locataire : 10 jours (1950) après la conclusion du bail suivant."""
183 + if duree_mois >= 12:
184 + a_min, a_max = 3, 6
185 + else:
186 + a_min, a_max = 1, 2
187 + debut_fenetre, fin_fenetre = _moins_mois(echeance, a_max), _moins_mois(echeance, a_min)
188 + reprise = _moins_mois(echeance, 6 if duree_mois > 6 else 1)
189 + reponse_si_fin = _moins_mois(echeance, a_min - 1) # avis reçu au dernier jour permis → réponse dans le mois
190 + demande_tal = _moins_mois(echeance, a_min - 2) # refus au dernier jour → demande dans le mois
191 + debut_suivant = echeance + dt.timedelta(days=1)
192 + return {"echeance": echeance, "avis_modification_debut": debut_fenetre, "avis_modification_fin": fin_fenetre,
193 + "reponse_locataire_fin_si_avis_tardif": reponse_si_fin, "demande_fixation_fin_si_refus_tardif": demande_tal,
194 + "avis_reprise_eviction_fin": reprise, "debut_bail_suivant": debut_suivant,
195 + "nouveau_locataire_fixation_10j": debut_suivant + dt.timedelta(days=10), "nouveau_locataire_sans_avis_2mois": _moins_mois(debut_suivant, -2)}
196 +
197 +
198 +# ----------------------------------------------------------------------------- loyer maximal des cinq ans (art. 1955)
199 +def croissance_max_1955(loyer_initial: float, loyer_maximal: float, annees: int = 5) -> float:
200 + """Taux annuel composé maximal compatible avec le loyer maximal indiqué au bail (art. 1955 C.c.Q., 2024, c. 2)."""
201 + return (loyer_maximal / loyer_initial) ** (1 / annees) - 1
202 +
203 +
204 +# ----------------------------------------------------------------------------- bail commercial : loyer net équivalent
205 +def loyer_net_equivalent(loyer_brut_annuel: float, depenses_recuperables: float, superficie_m2: float) -> dict:
206 + net = loyer_brut_annuel - depenses_recuperables
207 + return {"loyer_brut": loyer_brut_annuel, "recuperables": depenses_recuperables, "loyer_net": net,
208 + "brut_m2": loyer_brut_annuel / superficie_m2, "net_m2": net / superficie_m2}
209 +
210 +
211 +# ----------------------------------------------------------------------------- cas 2 (triplex)
212 +def cas2_etat() -> dict:
213 + d = cas2.charger(); l = d["loyers"].copy(); r = cas2.revenus(l)
214 + l["anciennete"] = DATE_EVAL.year - l["locataire_depuis"].astype(int)
215 + l["ecart_mensuel"] = l["loyer_marche_estime"] - l["loyer_mensuel"]; l["ratio"] = l["loyer_mensuel"] / l["loyer_marche_estime"]
216 + return {**r, "tableau": l, "ecart_pondere": float(l["ecart_mensuel"].sum() / l["loyer_marche_estime"].sum()),
217 + "anciennete_moyenne": float(l["anciennete"].mean()), "nb_sous_marche_10": int((l["ratio"] < 0.9).sum()), "sujet": d["sujet"], "data": d}
218 +
219 +
220 +# ----------------------------------------------------------------------------- tout
221 +def calculer() -> dict:
222 + d = cas3.charger(); etat, dep, hyp, sujet = d["etat"], d["dep"], d["hyp"], d["sujet"]
223 + ind = indicateurs(etat, hyp); base = cas3.rne(etat, dep, hyp); tx = cas3.taux_extraits(d["comps"])
224 + R = tx["mediane"]; V_cap = fin.capitalisation_directe(base["rne"], R)
225 + econ_role = loyer_economique(base, R, hyp["valeur_role_2026"], sujet["nb_logements"])
226 + econ_cap = loyer_economique(base, R, V_cap, sujet["nb_logements"])
227 + va = va_ecart_logements(etat, hyp); sens = sensibilite_va(etat, hyp)
228 + r, g, rho = hyp["taux_actualisation"], hyp["croissance_loyers_marche"], hyp["rotation_annuelle"]
229 + va_agregee_cas = va_ecart(sens["delta1_net"], r, g, rho) # = 102 188 $ (chapitre 2, ρ = 15 %)
230 + norm = normalisation_electricite(); cal = calendrier()
231 + return {"etat": etat, "hyp": hyp, "sujet": sujet, "ind": ind, "base": base, "R": R, "V_cap": V_cap,
232 + "econ_role": econ_role, "econ_cap": econ_cap, "va": va, "sens": sens, "va_agregee_cas": va_agregee_cas,
233 + "esperance_duree": {rho_: esperance_duree(rho_) for rho_ in (0.085, 0.094, 0.15, 0.13)},
234 + "norm": norm, "cal": cal, "cas2": cas2_etat()}
235 +
236 +
237 +# ----------------------------------------------------------------------------- exercices
238 +def exercices(res: dict | None = None) -> dict:
239 + res = res or calculer(); hyp = res["hyp"]; ex = {}
240 + # 26.1 — délais : bail 12 mois échéant le 30 juin 2027 et bail de 6 mois échéant le 31 décembre 2026
241 + ex["1"] = {"bail12": calendrier(dt.date(2027, 6, 30), 12), "bail6": calendrier(dt.date(2026, 12, 31), 6)}
242 + # 26.2 — indicateurs d'un état locatif fictif de six logements (énoncé)
243 + e2 = pd.DataFrame({"logement": ["1", "2", "3", "4", "5", "6"], "loyer_mensuel_contrat": [850, 925, 1180, 1240, 1410, 1495],
244 + "loyer_mensuel_marche": [1100, 1100, 1300, 1300, 1550, 1550], "locataire_depuis": [2012, 2016, 2021, 2025, 2019, 2024],
245 + "superficie_m2": [52, 52, 70, 70, 92, 92], "echeance_bail": ["2027-06-30"] * 6, "chauffage_inclus": [0] * 6, "stationnement": [1, 0, 1, 0, 1, 0]})
246 + hyp2 = {**hyp, "autres_revenus_annuels": 0.0, "taux_inoccupation_pertes": 0.03}
247 + ex["2"] = indicateurs(e2, hyp2)
248 + # 26.3 — normalisation : comparable chauffé-éclairé 1 450 $, profil de consommation donné ; variante 1 500 $ et 14 000 kWh (profil × 14/12)
249 + ex["3"] = {"base": normalisation_electricite(), "variante": normalisation_electricite(profil=[k * 14000 / 12000 for k in PROFIL_KWH], loyer_chauffe=1500.0)}
250 + # 26.4 — VA de l'écart du logement 103 (319 $/mois) : ρ = 9,4 % vs 15 % ; annuité sur E[n] = 1/ρ années ; perpétuel
251 + d103 = 12 * 319 * (1 - hyp["taux_inoccupation_pertes"]); r, g = hyp["taux_actualisation"], hyp["croissance_loyers_marche"]
252 + ex["4"] = {"delta1_net": d103, "va_094": va_ecart(d103, r, g, 0.094), "va_150": va_ecart(d103, r, g, 0.15),
253 + "E_n_094": esperance_duree(0.094), "E_n_150": esperance_duree(0.15),
254 + "annuite_094": va_annuite(d103, r, esperance_duree(0.094)), "annuite_150": va_annuite(d103, r, esperance_duree(0.15)),
255 + "perpetuel": d103 / (r - g), "serie_094": va_ecart_serie(d103, r, g, 0.094)}
256 + # 26.5 — loyer économique du triplex (cas 2) sur la valeur au rôle 495 000 $ et sur la valeur par capitalisation
257 + c2 = res["cas2"]["data"]; base2 = cas2.rne(c2["loyers"], c2["depenses"]); comp2 = cas2.comparables_plex(c2["ventes"], c2["sujet"]); ind2 = cas2.indicateurs_marche(comp2)
258 + R2 = ind2["taux_median"]; V2_role = c2["sujet"]["valeur_role_2025"]; V2_cap = fin.capitalisation_directe(base2["rne"], R2)
259 + det2 = base2["detail"]; gestion2 = float(det2.loc[det2["type"] == "imputée", "normalise"].sum()); de_hg2 = base2["depenses_hors_reserve"] - gestion2
260 + def econ2(V):
261 + rne_req = R2 * V; rbe_req = (rne_req + de_hg2 + base2["reserve"]) / (1 - cas2.GESTION_IMPUTEE); rbp_req = rbe_req / (1 - cas2.TAUX_VACANCE_PERTES)
262 + return {"V": V, "rne_requis": rne_req, "rbe_requis": rbe_req, "rbp_requis": rbp_req, "loyer_mensuel_moyen": rbp_req / 36}
263 + ex["5"] = {"R": R2, "base": base2, "de_hors_gestion": de_hg2, "role": econ2(V2_role), "cap": econ2(V2_cap), "V_cap": V2_cap,
264 + "rbp_contrat": base2["rbp_contrat"], "rbp_marche": base2["rbp_marche"]}
265 + # 26.6 — immeuble neuf (art. 1955) : loyer 1 600 $, loyer maximal 1 850 $ indiqué au bail ; trois trajectoires sur cinq ans :
266 + # (a) indexation au taux de base 3,1 % (sans clause : fixation possible), (b) saut au loyer maximal dès la 2e année (borne haute permise
267 + # par la clause), (c) croissance régulière au taux g_max ; VA des loyers des années 1 à 5 au taux r du cas (7,25 %).
268 + L0, Lmax, gtal = 1600.0, 1850.0, 0.031
269 + gmax = croissance_max_1955(L0, Lmax)
270 + traj_tal = [L0 * (1 + gtal) ** t for t in range(0, 5)]
271 + traj_saut = [L0] + [Lmax] * 4
272 + traj_reg = [L0 * (1 + gmax) ** t for t in range(0, 5)]
273 + va = lambda traj: sum(12 * traj[t - 1] / (1 + r) ** t for t in range(1, 6))
274 + ex["6"] = {"g_max": gmax, "traj_tal": traj_tal, "traj_saut": traj_saut, "traj_reg": traj_reg, "va_tal": va(traj_tal), "va_saut": va(traj_saut), "va_reg": va(traj_reg),
275 + "loyer_an5_tal": traj_tal[4], "depasse_tal": traj_tal[4] > Lmax, "annee_depassement": next((t + 1 for t, L in enumerate(traj_tal) if L > Lmax), None),
276 + "loyer_an6_tal": L0 * (1 + gtal) ** 5}
277 + # 26.7 — bail commercial au rez-de-chaussée : brut 36 000 $/an, récupérables 9 500 $, 110 m², 5 ans fixes, option 5 ans ; VA à 7,25 %
278 + lne = loyer_net_equivalent(36000.0, 9500.0, 110.0)
279 + va_com = fin.van([0.0] + [lne["loyer_net"]] * 5, r)
280 + loyer_res = 1292.0 * 12 * (1 - hyp["taux_inoccupation_pertes"]) # logement de marché moyen du cas 3 (1 292 $) indexé à g
281 + va_res = sum(loyer_res * (1 + g) ** (t - 1) / (1 + r) ** t for t in range(1, 6))
282 + ex["7"] = {**lne, "va_net_5ans": va_com, "va_residentiel_5ans": va_res, "ratio_va": va_com / va_res, "loyer_res_an1_net": loyer_res}
283 + return ex
284 +
285 +
286 +def _f(x): return f"{x:,.0f}".replace(",", " ")
287 +
288 +
289 +if __name__ == "__main__":
290 + res = calculer(); ind = res["ind"]; t = ind["tableau"]
291 + print("=== Cas 3 — état locatif (données synthétiques)")
292 + print(t[["logement", "pieces", "superficie_m2", "loyer_mensuel_contrat", "loyer_mensuel_marche", "anciennete", "ecart_mensuel", "ecart_pct", "ratio", "echeance_bail", "mois_echeance"]].to_string(index=False))
293 + for k in ["rbp_contrat", "rbp_marche", "ecart_marche", "ecart_pondere", "ratio_pondere", "ratio_moyen_simple", "ecart_mensuel_moyen", "ecart_mensuel_median",
294 + "anciennete_moyenne", "anciennete_mediane", "anciennete_max", "part_sous_marche_5", "part_sous_marche_10", "part_sous_marche_20",
295 + "nb_sous_marche_5", "nb_sous_marche_10", "nb_sous_marche_20", "loyer_moyen_contrat", "loyer_moyen_marche", "loyer_m2_contrat_moyen", "loyer_m2_marche_moyen", "part_ecart_top3"]:
296 + print(f"{k:28s} {ind[k]:.4f}" if isinstance(ind[k], float) else f"{k:28s} {ind[k]}")
297 + print("\n=== Loyer économique (R médian = %.4f %%)" % (100 * res["R"]))
298 + for lab, e in (("sur valeur au rôle 2 200 000 $", res["econ_role"]), ("sur valeur par capitalisation", res["econ_cap"])):
299 + print(lab, {k: round(v, 2) for k, v in e.items()})
300 + print("\n=== VA de l'écart par logement (r = %.4f, g = %.3f ; nette de l'inoccupation)" % (res["hyp"]["taux_actualisation"], res["hyp"]["croissance_loyers_marche"]))
301 + print(res["va"].round(0).to_string(index=False)); print("totaux :", res["va"].drop(columns=["logement", "pieces"]).sum().round(0).to_dict())
302 + print("VA agrégée ρ = 15 % (chapitre 2) :", round(res["va_agregee_cas"], 0), "; sans rotation :", round(res["sens"]["sans_rotation"], 0))
303 + print("Durées espérées 1/ρ :", {k: round(v, 2) for k, v in res["esperance_duree"].items()})
304 + print("\n=== Sensibilité (VA agrégée)"); print(res["sens"]["par_taux"].round(0)); print(res["sens"]["par_croissance"].round(0))
305 + n = res["norm"]; print("\n=== Normalisation électricité (tarif D, hypothèse 12 000 kWh/an)")
306 + print(n["tableau"].round(2).to_string(index=False))
307 + print({k: round(v, 2) for k, v in n.items() if k != "tableau"})
308 + print("\n=== Calendrier (bail 2026-07-01 → 2027-06-30)"); print(res["cal"])
309 + c2 = res["cas2"]; print("\n=== Cas 2 — triplex"); print(c2["tableau"].to_string(index=False))
310 + print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in c2.items() if k not in ("tableau", "sujet", "data")})
311 + print("\n=== Exercices"); ex = exercices(res)
312 + for k, v in ex.items():
313 + print(f"--- {k}")
314 + if isinstance(v, dict):
315 + for kk, vv in v.items():
316 + if isinstance(vv, (int, float)): print(f" {kk}: {vv:.4f}")
317 + elif isinstance(vv, dict): print(f" {kk}: {{" + ", ".join(f"{a}: {round(b, 4) if isinstance(b, float) else b}" for a, b in vv.items() if not isinstance(b, (pd.DataFrame, dict))) + "}")
318 + elif isinstance(vv, list): print(f" {kk}: {[round(x, 2) for x in vv]}")
added analysis/python/cases/ch27_revenus.py +356 −0
@@ -0,0 +1,356 @@
1 +#!/usr/bin/env python3
2 +"""Chapitre 27 — Revenus potentiels, vacance et pertes : calculs du cas 3 (12 logements, Rosemont) et du cas 2 (triplex,
3 +Limoilou) — DONNÉES SYNTHÉTIQUES — et lecture des tableaux publics de la SCHL (Enquête sur les logements locatifs,
4 +octobre 2025, data/raw/schl_rml_2025/) — DONNÉES RÉELLES citées avec attribution.
5 +
6 +Tous les chiffres cités dans manuscript/chapters/ch27.tex et solutions/ch27.tex sortent d'ici. Les revenus des cas
7 +viennent de traite.cas3.revenus / traite.cas2.revenus et la projection des loyers de traite.cas3.projection_dcf, pour
8 +coïncider avec les chapitres 29, 30, 32, 46 et 47 ; les formules viennent de traite.finance (aucune n'est réécrite).
9 +
10 +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch27_revenus.py
11 +"""
12 +from __future__ import annotations
13 +import copy, math, pathlib, sys
14 +import numpy as np, pandas as pd
15 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
16 +from traite import cas2, cas3, finance as fin
17 +
18 +ROOT = pathlib.Path(__file__).resolve().parents[3]
19 +RAW = ROOT / "data/raw/schl_rml_2025"
20 +SIZES = ["3 à 5 unités", "6 à 19 unités", "20 à 49 unités", "50 à 99 unités", "100 unités et +", "Tous les immeubles"]
21 +TYPES = ["Studio", "1 chambre", "2 chambres", "3 chambres +", "Tous"]
22 +RMR_FICHIER = {"Montréal": "rmr-montreal-2025-fr.xlsx", "Québec": "rmr-quebec-2025-fr.xlsx", "Gatineau (partie QC)": "rmr-gatineau-2025-fr.xlsx",
23 + "Sherbrooke": "rmr-sherbrooke-2025-fr.xlsx", "Trois-Rivières": "rmr-trois-rivieres-2025-fr.xlsx", "Saguenay": "rmr-saguenay-2025-fr.xlsx"}
24 +# Libellés de la RMR dans les fichiers (tableaux par centre du fichier Québec ; tableaux par zone des autres fichiers)
25 +RMR_LIBELLE_QC = {"Montréal": "Montréal (RMR)", "Québec": "Québec (RMR)", "Gatineau (partie QC)": "Ottawa-Gatineau (RMR) (partie QC)",
26 + "Sherbrooke": "Sherbrooke (RMR)", "Trois-Rivières": "Trois-Rivières (RMR)", "Saguenay": "Saguenay (RMR)"}
27 +
28 +
29 +# ----------------------------------------------------------------------------- lecture des tableaux SCHL (réels)
30 +def _valeurs(tokens) -> list[float]:
31 + """Convertit une ligne SCHL en nombres : « 2,9 » → 2.9 ; « ** » (supprimé) → NaN ; lettres de qualité a-d et flèches ignorées."""
32 + out = []
33 + for t in tokens:
34 + s = str(t).strip()
35 + if s == "**":
36 + out.append(float("nan")); continue
37 + if s in {"a", "b", "c", "d", "↑", "↓", "-", "–", "O", "N"}:
38 + continue
39 + try:
40 + out.append(float(s.replace(",", ".").replace(" ", "").replace(" ", "")))
41 + except ValueError:
42 + continue
43 + return out
44 +
45 +
46 +def _lignes(fichier: str, tableau: str) -> list[tuple[str, list[float]]]:
47 + import openpyxl
48 + wb = openpyxl.load_workbook(RAW / fichier, read_only=True, data_only=True)
49 + ws = wb[tableau]
50 + rows = []
51 + for r in ws.iter_rows(values_only=True):
52 + cells = [c for c in r if c is not None]
53 + if not cells:
54 + continue
55 + rows.append((str(cells[0]).strip(), _valeurs(cells[1:])))
56 + return rows
57 +
58 +
59 +def _bloc(rows, debut: str, libelles: list[str]) -> dict[str, list[float]]:
60 + """Extrait, dans le bloc commençant par la ligne `debut`, les lignes dont le libellé figure dans `libelles`."""
61 + out, actif = {}, False
62 + for lab, vals in rows:
63 + if lab == debut:
64 + actif = True; continue
65 + if actif and lab in libelles and len(vals) >= 2:
66 + out[lab] = vals
67 + if actif and lab in libelles and lab == libelles[-1]:
68 + break
69 + return out
70 +
71 +
72 +def schl_inoccupation_rmr() -> pd.DataFrame:
73 + """Taux d'inoccupation (%), tous les appartements, oct. 2024 et oct. 2025, par RMR (tableau 1.1.1 du fichier Québec, par centre)."""
74 + rows = _lignes("rmr-quebec-2025-fr.xlsx", "Tableau 1.1.1")
75 + rec = []
76 + for rmr, lab in RMR_LIBELLE_QC.items():
77 + v = [vals for l, vals in rows if l == lab][0]
78 + # 5 types × (2024, 2025) = 10 valeurs ; « Tous » = les deux dernières
79 + rec.append({"rmr": rmr, "inocc_2024": v[-2], "inocc_2025": v[-1], "inocc_2ch_2025": v[5]})
80 + return pd.DataFrame(rec).set_index("rmr")
81 +
82 +
83 +def schl_rotation_rmr() -> pd.DataFrame:
84 + """Taux de rotation (%), tous les appartements, oct. 2024 et 2025, par RMR (tableau 1.1.6 du fichier Québec)."""
85 + rows = _lignes("rmr-quebec-2025-fr.xlsx", "Tableau 1.1.6")
86 + rec = []
87 + for rmr, lab in RMR_LIBELLE_QC.items():
88 + v = [vals for l, vals in rows if l == lab][0]
89 + rec.append({"rmr": rmr, "rotation_2024": v[-2], "rotation_2025": v[-1]})
90 + return pd.DataFrame(rec).set_index("rmr")
91 +
92 +
93 +def schl_taille_montreal() -> pd.DataFrame:
94 + """Inoccupation (tous les appartements, %) et rotation (%) selon la taille de l'immeuble, oct. 2025 : RMR de Montréal et île de Montréal
95 + (tableaux 1.3.1 et 1.3.4 du fichier Montréal)."""
96 + inoc = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.3.1"); rot = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.3.4")
97 + rec = []
98 + for zone, debut in (("Île de Montréal", "Île de Montréal (zones 1-18)"), ("RMR de Montréal", "Montréal (RMR)")):
99 + bi = _bloc(inoc, debut, SIZES); br = _bloc(rot, debut, SIZES)
100 + for s in SIZES:
101 + vi = bi.get(s, []); vr = br.get(s, [])
102 + rec.append({"zone": zone, "taille": s, "inocc_2024": vi[-2] if len(vi) >= 2 else np.nan, "inocc_2025": vi[-1] if vi else np.nan,
103 + "rotation_2024": vr[-2] if len(vr) >= 2 else np.nan, "rotation_2025": vr[-1] if vr else np.nan})
104 + return pd.DataFrame(rec)
105 +
106 +
107 +def schl_quartiles_montreal() -> pd.DataFrame:
108 + """Inoccupation (%) selon le quartile de loyer, tous les appartements, RMR de Montréal, oct. 2024 et 2025 (tableau 1.4)."""
109 + rows = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.4")
110 + b = _bloc(rows, "Montréal (RMR)", ["Q1", "Q2", "Q3", "Q4"])
111 + return pd.DataFrame([{"quartile": q, "inocc_2024": b[q][-2], "inocc_2025": b[q][-1]} for q in ["Q1", "Q2", "Q3", "Q4"]]).set_index("quartile")
112 +
113 +
114 +def schl_inoccupes_occupes_ile() -> pd.DataFrame:
115 + """Loyer moyen ($) des logements inoccupés et occupés, île de Montréal, oct. 2025, par taille de logement (tableau 1.1.9)."""
116 + rows = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.1.9")
117 + v = [vals for l, vals in rows if l.startswith("Île-de-Montréal")][0]
118 + rec = [{"type": t, "inoccupes": v[2 * i], "occupes": v[2 * i + 1], "ecart_pct": (v[2 * i] / v[2 * i + 1] - 1) * 100} for i, t in enumerate(TYPES)]
119 + return pd.DataFrame(rec).set_index("type")
120 +
121 +
122 +def schl_annee_construction_ile() -> pd.DataFrame:
123 + """Inoccupation (%) selon l'année de construction, tous les appartements, île de Montréal, oct. 2025 (tableau 1.2.1)."""
124 + rows = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.2.1")
125 + libs = ["Avant 1940", "1940 à 1959", "1960 à 1974", "1975 à 1989", "1990 à 2004", "2005 à 2014", "2015 ou après", "Tous les immeubles"]
126 + b = _bloc(rows, "Île de Montréal (zones 1-18)", libs)
127 + return pd.DataFrame([{"periode": k, "inocc_2025": b[k][-1]} for k in libs if k in b]).set_index("periode")
128 +
129 +
130 +def ipc_loyer_quebec() -> dict:
131 + """IPC « Loyer », Québec, non désaisonnalisé (Statistique Canada 18-10-0004-01, 2002 = 100) : variation sept. 2026 / sept. 2025
132 + et croissance annuelle moyenne sur 10 ans (sept. 2016 → sept. 2026)."""
133 + p = ROOT / "data/raw/statcan_18100004/18100004.csv"
134 + import csv
135 + vals = {}
136 + with p.open(encoding="utf-8-sig") as f:
137 + for row in csv.reader(f, delimiter=";"):
138 + if len(row) > 10 and row[1] == "Québec" and row[3] == "Loyer" and row[0] >= "2015-01":
139 + try:
140 + vals[row[0]] = float(row[10])
141 + except ValueError:
142 + pass
143 + s = pd.Series(vals).sort_index()
144 + return {"serie": s, "sept2026": s["2026-09"], "sept2025": s["2025-09"], "var_12m": s["2026-09"] / s["2025-09"] - 1,
145 + "cagr_10ans": (s["2026-09"] / s["2016-09"]) ** (1 / 10) - 1, "cagr_5ans": (s["2026-09"] / s["2021-09"]) ** (1 / 5) - 1}
146 +
147 +
148 +# ----------------------------------------------------------------------------- cas 3 et cas 2 (synthétiques)
149 +def cascade_cas3() -> dict:
150 + d = cas3.charger(); etat, hyp = d["etat"], d["hyp"]
151 + r = cas3.revenus(etat, hyp)
152 + n = len(etat); sup = float(etat["superficie_m2"].sum())
153 + v = r["taux_vacance_pertes"]
154 + r.update({
155 + "n_logements": n, "superficie_m2": sup, "loyer_mensuel_contrat": float(etat["loyer_mensuel_contrat"].sum()),
156 + "loyer_mensuel_marche": float(etat["loyer_mensuel_marche"].sum()),
157 + "deficit_loyer": r["ecart_marche"], "deficit_pct": r["ecart_marche"] / r["rbp_marche"],
158 + "rbp_contrat_sur_marche": r["rbp_contrat"] / r["rbp_marche"],
159 + "loyers_effectifs": r["rbp_contrat"] * (1 - v),
160 + "taux_occupation_economique": r["rbp_contrat"] * (1 - v) / r["rbp_marche"], # loyers encaissés / loyers de marché
161 + "rbe_sur_rbp": r["rbe"] / r["rbp_contrat"], "autres_sur_rbp": r["autres_revenus"] / r["rbp_contrat"],
162 + "autres_par_logement_mois": r["autres_revenus"] / n / 12,
163 + "rbe_par_logement": r["rbe"] / n, "rbe_par_m2": r["rbe"] / sup, "rbp_par_m2": r["rbp_contrat"] / sup,
164 + "rbe_marche": fin.revenu_brut_effectif(r["rbp_marche"], v, r["autres_revenus"]),
165 + # variantes de convention (même v) : v appliqué au RBP + autres revenus ; autres revenus à 50 % vacants ; v sur RBP de marché
166 + "rbe_variante_v_sur_tout": (r["rbp_contrat"] + r["autres_revenus"]) * (1 - v),
167 + "rbe_variante_v_sur_marche": r["rbp_marche"] * (1 - v) + r["autres_revenus"],
168 + })
169 + r["ecart_variante_v_sur_tout"] = r["rbe_variante_v_sur_tout"] - r["rbe"]
170 + # décomposition de v (hypothèse pédagogique déclarée, cohérente avec le ch. 47) : frictionnel = rotation × durée ; reste = créances + structurel
171 + rot, duree = hyp["rotation_annuelle"], 1.0
172 + r["v_frictionnel_dossier"] = fin.taux_vacance_frictionnelle(rot, duree)
173 + r["v_residuel_dossier"] = v - r["v_frictionnel_dossier"]
174 + r["etat"] = etat; r["hyp"] = hyp; r["sujet"] = d["sujet"]
175 + return r
176 +
177 +
178 +def par_type_cas3(etat: pd.DataFrame) -> pd.DataFrame:
179 + g = etat.groupby("pieces").agg(n=("logement", "count"), superficie=("superficie_m2", "sum"), contrat=("loyer_mensuel_contrat", "sum"),
180 + marche=("loyer_mensuel_marche", "sum"), anciennete=("locataire_depuis", lambda s: float((2026 - s).mean())))
181 + g["contrat_moyen"] = g["contrat"] / g["n"]; g["marche_moyen"] = g["marche"] / g["n"]
182 + g["ecart"] = g["marche"] - g["contrat"]; g["ecart_pct"] = g["ecart"] / g["marche"] * 100
183 + g["rbp_contrat"] = g["contrat"] * 12; g["rbp_marche"] = g["marche"] * 12
184 + tot = g.sum(numeric_only=True); tot["contrat_moyen"] = tot["contrat"] / tot["n"]; tot["marche_moyen"] = tot["marche"] / tot["n"]
185 + tot["ecart_pct"] = tot["ecart"] / tot["marche"] * 100; tot["anciennete"] = float((2026 - etat["locataire_depuis"]).mean())
186 + g.loc["Total"] = tot
187 + return g
188 +
189 +
190 +def cascade_cas2() -> dict:
191 + c = cas2.charger(); loyers = c["loyers"]
192 + r = cas2.revenus(loyers)
193 + v = r["taux_vacance_pertes"]
194 + r.update({"n_logements": len(loyers), "superficie_m2": float(loyers["superficie_m2"].sum()), "vacance_pertes": r["rbp_contrat"] * v,
195 + "deficit_pct": r["ecart_marche"] / r["rbp_marche"], "taux_occupation_economique": r["rbp_contrat"] * (1 - v) / r["rbp_marche"],
196 + "rbe_par_logement": r["rbe"] / len(loyers), "autres_revenus": 0.0,
197 + # décomposition déclarée du 3 % du dossier : inoccupation SCHL Québec 2,4 % + créances ≈ 0,6 %
198 + "v_inoccupation_schl": 0.024, "v_creances": v - 0.024,
199 + # occupant : le logement du propriétaire (RC, hypothèse du ch. 46) n'a pas de risque d'inoccupation marchand → RBE si l'on
200 + # n'applique v qu'aux deux logements loués (variante pédagogique)
201 + "loyers": loyers})
202 + loue = loyers[loyers["logement"] != "RC"]
203 + r["rbe_variante_occupant"] = float(loyers.loc[loyers["logement"] == "RC", "loyer_mensuel"].sum() * 12) + fin.revenu_brut_effectif(float(loue["loyer_mensuel"].sum() * 12), v)
204 + return r
205 +
206 +
207 +# ----------------------------------------------------------------------------- modèle d'inoccupation frictionnelle et grilles
208 +def grille_frictionnelle(rotations=(0.05, 0.094, 0.13, 0.15, 0.20, 0.25), durees=(0.5, 1.0, 1.5, 2.0, 3.0)) -> pd.DataFrame:
209 + g = pd.DataFrame({d: [fin.taux_vacance_frictionnelle(r, d) * 100 for r in rotations] for d in durees}, index=[r * 100 for r in rotations])
210 + g.index.name = "rotation_pct"; g.columns.name = "duree_mois"
211 + return g
212 +
213 +
214 +def sensibilite_v(base: dict, taux=(0.02, 0.029, 0.039, 0.05, 0.06, 0.08)) -> pd.DataFrame:
215 + """RBE, RNE normalisé et valeur par capitalisation (taux médian du cas 3) pour différents v ; dépenses recalculées par traite.cas3.rne
216 + (la gestion imputée à 4 % du RBE varie avec v)."""
217 + d = cas3.charger(); R = cas3.taux_extraits(d["comps"])["mediane"]
218 + rows = []
219 + for v in taux:
220 + h = copy.deepcopy(d["hyp"]); h["taux_inoccupation_pertes"] = v
221 + b = cas3.rne(d["etat"], d["dep"], h)
222 + rows.append({"v": v, "vacance_pertes": b["vacance_pertes"], "rbe": b["rbe"], "rne": b["rne"], "valeur": fin.capitalisation_directe(b["rne"], R)})
223 + s = pd.DataFrame(rows)
224 + h0 = copy.deepcopy(d["hyp"]); b0 = cas3.rne(d["etat"], d["dep"], h0) # référence = v du dossier, même si absent de la grille
225 + ref = {"rbe": b0["rbe"], "valeur": fin.capitalisation_directe(b0["rne"], R)}
226 + s["d_rbe"] = s["rbe"] - ref["rbe"]; s["d_valeur"] = s["valeur"] - ref["valeur"]; s["d_valeur_pct"] = s["valeur"] / ref["valeur"] - 1
227 + s.attrs["R"] = R
228 + # dérivées analytiques : ∂RBE/∂v = −RBP ; ∂RNE/∂v = −RBP(1−0,04) ; ∂V/∂v = ∂RNE/∂v / R
229 + s.attrs["dRBE_dv_pt"] = -base["rbp_contrat"] / 100
230 + s.attrs["dRNE_dv_pt"] = -base["rbp_contrat"] * (1 - 0.04) / 100
231 + s.attrs["dV_dv_pt"] = -base["rbp_contrat"] * (1 - 0.04) / 100 / R
232 + return s
233 +
234 +
235 +# ----------------------------------------------------------------------------- trajectoires des loyers (cas 3)
236 +def trajectoires_cas3() -> dict:
237 + """Trois trajectoires du RBP sur l'horizon du DCF : (i) indexation TAL seule (rotation 0) ; (ii) base du dossier (TAL 3,1 % + rotation 15 %
238 + relouée au marché, marché +3,5 %) ; (iii) RBP au marché (indice) ; variante (iv) rotation SCHL 9,4 %."""
239 + d = cas3.charger(); etat, dep, hyp = d["etat"], d["dep"], d["hyp"]
240 + def proj(**chg):
241 + h = copy.deepcopy(hyp); h.update(chg)
242 + return cas3.projection_dcf(etat, dep, h)
243 + base = proj(); tal = proj(rotation_annuelle=0.0); rot_schl = proj(rotation_annuelle=0.094)
244 + rot_schl_marche_schl = proj(rotation_annuelle=0.094, croissance_loyers_marche=0.033) # marché au rythme de l'IPC-loyer récent
245 + t = pd.DataFrame({"annee": base["annee"], "exercice": base["exercice"], "tal_seul": tal["rbp"], "base_rot15": base["rbp"],
246 + "rot_schl_094": rot_schl["rbp"], "marche": base["rbp_marche"], "rot094_marche033": rot_schl_marche_schl["rbp"],
247 + "rbe_tal_seul": tal["rbe"], "rbe_base": base["rbe"], "rbe_rot094": rot_schl["rbe"], "rbe_marche_plein": base["rbp_marche"] * (1 - hyp["taux_inoccupation_pertes"]) + hyp["autres_revenus_annuels"] * (1 + hyp["croissance_depenses"]) ** (base["annee"] - 1)})
248 + t["ecart_base_pct"] = (1 - t["base_rot15"] / t["marche"]) * 100
249 + t["ecart_tal_pct"] = (1 - t["tal_seul"] / t["marche"]) * 100
250 + t["ecart_rot094_pct"] = (1 - t["rot_schl_094"] / t["marche"]) * 100
251 + T = hyp["horizon_ans"]
252 + res = {"table": t, "hyp": hyp, "T": T,
253 + "cagr": {k: (t.loc[t["annee"] == T, k].iloc[0] / t.loc[t["annee"] == 1, k].iloc[0]) ** (1 / (T - 1)) - 1 for k in ["tal_seul", "base_rot15", "rot_schl_094", "marche"]},
254 + "rbp_T": {k: float(t.loc[t["annee"] == T, k].iloc[0]) for k in ["tal_seul", "base_rot15", "rot_schl_094", "marche"]},
255 + "somme_rbp_1_T": {k: float(t.loc[t["annee"] <= T, k].sum()) for k in ["tal_seul", "base_rot15", "rot_schl_094", "marche"]}}
256 + # valeur DCF associée à chaque trajectoire (pour mémoire : sensibilité de la valeur à la trajectoire ; ch. 30/34 approfondissent)
257 + res["valeur_dcf"] = {"tal_seul": cas3.valeur_dcf(tal, hyp)["valeur"], "base_rot15": cas3.valeur_dcf(base, hyp)["valeur"],
258 + "rot_schl_094": cas3.valeur_dcf(rot_schl, hyp)["valeur"]}
259 + return res
260 +
261 +
262 +def mini_projection(loyers: list[float], marche: float, g_c: float, g_m: float, k_par_an: int, annees: int) -> pd.DataFrame:
263 + """Petit modèle de trajectoire (exercice 27.6) : chaque année, les loyers reconduits croissent de g_c, le marché de g_m, et les k logements les
264 + plus sous le marché sont reloués au marché. Année 1 = loyers en place. Reproduit la logique de traite.cas3.projection_dcf à petite échelle."""
265 + L = np.array(loyers, dtype=float); M = float(marche); rows = []
266 + for t in range(1, annees + 1):
267 + if t > 1:
268 + L = L * (1 + g_c); M = M * (1 + g_m)
269 + idx = np.argsort(L / M)[:k_par_an]; L[idx] = M
270 + rows.append({"annee": t, "loyers": L.copy(), "marche": M, "rbp": L.sum() * 12, "rbp_marche": M * len(L) * 12})
271 + return pd.DataFrame(rows)
272 +
273 +
274 +# ----------------------------------------------------------------------------- exercices (solutions/ch27.tex)
275 +def exercices(base3: dict, base2: dict) -> dict:
276 + ex = {}
277 + # 27.1 — RBP contractuel, au marché, déficit ; six logements
278 + L = [895, 910, 1040, 1150, 1225, 1310]; M = [1100, 1100, 1150, 1250, 1300, 1350]
279 + ex["1"] = {"loyers": L, "marche": M, "rbp_c": sum(L) * 12, "rbp_m": sum(M) * 12, "deficit": (sum(M) - sum(L)) * 12,
280 + "deficit_pct": 1 - sum(L) / sum(M), "ecarts_pct": [1 - l / m for l, m in zip(L, M)]}
281 + # 27.2 — RBE selon trois conventions ; v = 4 %, autres 4 800 $
282 + rbp = ex["1"]["rbp_c"]; v = 0.04; autres = 4800.0
283 + ex["2"] = {"rbp": rbp, "v": v, "autres": autres, "rbe_livre": fin.revenu_brut_effectif(rbp, v, autres), "rbe_v_sur_tout": (rbp + autres) * (1 - v),
284 + "rbe_autres_demi": rbp * (1 - v) + autres * (1 - v / 2), "vacance": rbp * v}
285 + ex["2"]["ecart_tout"] = ex["2"]["rbe_v_sur_tout"] - ex["2"]["rbe_livre"]
286 + # 27.3 — inoccupation frictionnelle : Québec rotation 13 %, durées 1 et 1,5 mois ; créances 0,5 % ; comparer à SCHL 2,4 %
287 + ex["3"] = {"rot": 0.13, "vf_1": fin.taux_vacance_frictionnelle(0.13, 1.0), "vf_15": fin.taux_vacance_frictionnelle(0.13, 1.5),
288 + "creances": 0.005, "schl": 0.024}
289 + ex["3"]["v_1"] = ex["3"]["vf_1"] + 0.005; ex["3"]["v_15"] = ex["3"]["vf_15"] + 0.005
290 + ex["3"]["duree_implicite_schl"] = 0.024 * 12 / 0.13 # durée (mois) qui reproduirait 2,4 % avec ρ = 13 %
291 + # 27.4 — concession : un mois gratuit sur 12 à 1 300 $ ; équivalent en v ; deux mois sur 24
292 + ex["4"] = {"L": 1300.0, "L_eff_1_12": fin.loyer_effectif_concession(1300, 1, 12), "L_eff_2_24": fin.loyer_effectif_concession(1300, 2, 24),
293 + "v_equiv_1_12": 1 / 12, "v_equiv_2_24": 2 / 24, "double_compte": 1300 * 12 * (1 - 1 / 12) * (1 - 1 / 12)}
294 + ex["4"]["rbp_annuel"] = 1300 * 12; ex["4"]["rbe_correct"] = 1300 * 12 * (1 - 1 / 12)
295 + # 27.5 — triplex (cas 2) : cascade, RBE marché, occupation économique, variante occupant
296 + ex["5"] = {k: base2[k] for k in ["rbp_contrat", "rbp_marche", "ecart_marche", "taux_vacance_pertes", "vacance_pertes", "rbe", "rbe_marche", "taux_occupation_economique", "rbe_variante_occupant", "rbe_par_logement"]}
297 + # 27.6 — mini-projection : 4 logements
298 + mp = mini_projection([900, 950, 1000, 1100], 1200, 0.031, 0.035, 1, 4)
299 + ex["6"] = {"table": mp, "rbp": mp["rbp"].tolist(), "rbp_marche": mp["rbp_marche"].tolist(), "ecart_pct": (1 - mp["rbp"] / mp["rbp_marche"]).tolist(),
300 + "tal_seul": [sum([900, 950, 1000, 1100]) * 12 * 1.031 ** (t - 1) for t in range(1, 5)]}
301 + # 27.7 — valeur et v : cas 3 à v = 2,9 % et 5,9 % ; R = médiane ; via traite.cas3.rne
302 + s = sensibilite_v(base3, taux=(0.029, 0.039, 0.059))
303 + ex["7"] = {"table": s, "R": s.attrs["R"], "dV_dv_pt": s.attrs["dV_dv_pt"], "dRBE_dv_pt": s.attrs["dRBE_dv_pt"],
304 + "V_029": float(s.loc[np.isclose(s["v"], 0.029), "valeur"].iloc[0]), "V_039": float(s.loc[np.isclose(s["v"], 0.039), "valeur"].iloc[0]),
305 + "V_059": float(s.loc[np.isclose(s["v"], 0.059), "valeur"].iloc[0]),
306 + "rne_029": float(s.loc[np.isclose(s["v"], 0.029), "rne"].iloc[0]), "rne_059": float(s.loc[np.isclose(s["v"], 0.059), "rne"].iloc[0])}
307 + ex["7"]["approx_lineaire_2pts"] = 2 * ex["7"]["dV_dv_pt"]
308 + # 27.8 — lecture SCHL : quartiles, loyers inoccupés/occupés, implication pour un immeuble au-dessus du marché
309 + q = schl_quartiles_montreal(); io = schl_inoccupes_occupes_ile()
310 + ex["8"] = {"quartiles": q, "inoccupes_occupes": io, "ratio_tous": io.loc["Tous", "inoccupes"] / io.loc["Tous", "occupes"],
311 + "ratio_2ch": io.loc["2 chambres", "inoccupes"] / io.loc["2 chambres", "occupes"], "q4_sur_q1": q.loc["Q4", "inocc_2025"] / q.loc["Q1", "inocc_2025"]}
312 + return ex
313 +
314 +
315 +def calculer() -> dict:
316 + base3 = cascade_cas3(); base2 = cascade_cas2()
317 + res = {"cas3": base3, "cas2": base2, "par_type": par_type_cas3(base3["etat"]),
318 + "schl_rmr": schl_inoccupation_rmr().join(schl_rotation_rmr()), "schl_taille": schl_taille_montreal(),
319 + "schl_quartiles": schl_quartiles_montreal(), "schl_inocc_occ": schl_inoccupes_occupes_ile(), "schl_annee": schl_annee_construction_ile(),
320 + "ipc_loyer": ipc_loyer_quebec(), "grille_frictionnelle": grille_frictionnelle(), "sensibilite_v": sensibilite_v(base3),
321 + "trajectoires": trajectoires_cas3()}
322 + res["exercices"] = exercices(base3, base2)
323 + return res
324 +
325 +
326 +def _fmt(x, d=2):
327 + return f"{x:,.{d}f}".replace(",", " ").replace(".", ",")
328 +
329 +
330 +if __name__ == "__main__":
331 + r = calculer(); c3 = r["cas3"]; c2 = r["cas2"]
332 + print("=== Cas 3 — cascade RBP → RBE (données synthétiques)")
333 + for k in ["loyer_mensuel_contrat", "loyer_mensuel_marche", "rbp_contrat", "rbp_marche", "deficit_loyer", "deficit_pct", "taux_vacance_pertes", "vacance_pertes",
334 + "loyers_effectifs", "autres_revenus", "autres_par_logement_mois", "rbe", "rbe_sur_rbp", "rbe_par_logement", "rbe_par_m2", "rbp_par_m2",
335 + "taux_occupation_economique", "rbe_marche", "rbe_variante_v_sur_tout", "ecart_variante_v_sur_tout", "rbe_variante_v_sur_marche",
336 + "v_frictionnel_dossier", "v_residuel_dossier"]:
337 + print(f" {k:32s} {_fmt(c3[k], 4 if abs(c3[k]) < 2 else 2)}")
338 + print(r["par_type"].round(2).to_string())
339 + print("=== Cas 2 — triplex")
340 + for k in ["rbp_contrat", "rbp_marche", "ecart_marche", "deficit_pct", "taux_vacance_pertes", "vacance_pertes", "rbe", "rbe_marche", "taux_occupation_economique", "rbe_variante_occupant", "rbe_par_logement"]:
341 + print(f" {k:32s} {_fmt(c2[k], 4 if abs(c2[k]) < 2 else 2)}")
342 + print("=== SCHL oct. 2025 (réel) — inoccupation et rotation par RMR"); print(r["schl_rmr"].to_string())
343 + print("=== SCHL — taille d'immeuble (Montréal)"); print(r["schl_taille"].to_string())
344 + print("=== SCHL — quartiles RMR Montréal"); print(r["schl_quartiles"].to_string())
345 + print("=== SCHL — loyers inoccupés / occupés, île"); print(r["schl_inocc_occ"].round(1).to_string())
346 + print("=== SCHL — année de construction, île"); print(r["schl_annee"].to_string())
347 + ipc = r["ipc_loyer"]; print(f"=== IPC loyer Québec : sept. 2026 {ipc['sept2026']} / sept. 2025 {ipc['sept2025']} → {ipc['var_12m']*100:.2f} % ; TCAC 10 ans {ipc['cagr_10ans']*100:.2f} % ; 5 ans {ipc['cagr_5ans']*100:.2f} %")
348 + print("=== Grille frictionnelle (v_f en %)"); print(r["grille_frictionnelle"].round(2).to_string())
349 + s = r["sensibilite_v"]; print("=== Sensibilité à v (R = %.4f %%)" % (s.attrs["R"] * 100)); print(s.round(2).to_string())
350 + print(f" dRBE/dv (par point) {s.attrs['dRBE_dv_pt']:.2f} ; dRNE/dv {s.attrs['dRNE_dv_pt']:.2f} ; dV/dv {s.attrs['dV_dv_pt']:.2f}")
351 + t = r["trajectoires"]; print("=== Trajectoires RBP"); print(t["table"].round(2).to_string()); print("TCAC", {k: round(v * 100, 3) for k, v in t["cagr"].items()})
352 + print("RBP_T", {k: round(v, 2) for k, v in t["rbp_T"].items()}); print("Somme 1..T", {k: round(v, 2) for k, v in t["somme_rbp_1_T"].items()}); print("V_DCF", {k: round(v, 2) for k, v in t["valeur_dcf"].items()})
353 + print("=== Exercices")
354 + for k, v in r["exercices"].items():
355 + print(k, {kk: (vv if not isinstance(vv, (pd.DataFrame, pd.Series)) else "<table>") for kk, vv in v.items()})
356 + print(r["exercices"]["6"]["table"].to_string()); print(r["exercices"]["7"]["table"].round(2).to_string())
added analysis/python/cases/ch31_financement.py +330 −0
@@ -0,0 +1,330 @@
1 +#!/usr/bin/env python3
2 +"""Chapitre 31 — Financement, levier, RCD et RPV : calculs du chapitre (cas 3, 12 logements, Montréal,
3 +DONNÉES SYNTHÉTIQUES, sauf les taux de la Banque du Canada cités en repère).
4 +Tous les chiffres de manuscript/chapters/ch31.tex et solutions/ch31.tex proviennent de ce script, qui n'appelle que la
5 +bibliothèque commune (traite.finance, traite.cas3) et les fonctions du chapitre 30 (cases.ch30_dcf) pour les flux.
6 +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch31_financement.py [--latex]
7 +Les fonctions sont importées par analysis/python/figures/ch31_*.py et analysis/python/tests/test_ch31.py.
8 +
9 +Aides propres au chapitre (candidates à une promotion dans traite.finance après harmonisation) :
10 + prime_aph(rpv, amortissement_mois) — barème SCHL immeubles collectifs (locatif ordinaire / APH Select)
11 + mise_de_fonds_minimale(prix) — règle fédérale 5 % / 10 % (plafond 1,5 M$) en vigueur le 15 déc. 2024
12 + renouvellement(...) — recalcul exact du paiement sur le solde au terme, à un nouveau taux
13 + cout_effectif_dette(...) — TRI annuel des flux de la dette (coût effectif k)
14 +"""
15 +from __future__ import annotations
16 +import sys, pathlib
17 +import numpy as np, pandas as pd
18 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
19 +from traite import cas3, finance as fin
20 +from cases import ch30_dcf as c30
21 +
22 +ROOT = pathlib.Path(__file__).resolve().parents[3]
23 +
24 +# Repères publics (sources/notes-C.md §4, consultés le 2026-10-31) — valeurs citées dans le texte, non des intrants du cas 3
25 +REPERES = {
26 + "taux_directeur": 0.0250, # BdC, 28 octobre 2026
27 + "gdc_5ans": 0.0358, "gdc_10ans": 0.0384, # BdC Valet, 2026-10-29
28 + "affiche_5ans": 0.0619, # V80691335, semaine du 2026-10-28
29 + "assure_5ans_plus": 0.0412, # A4_RATES_MORTGAGES, V122667780, août 2026
30 + "non_assure_5ans_plus": 0.0458, # A4_RATES_MORTGAGES, V122667786, août 2026
31 + "mqr_plancher": 0.0525, "mqr_marge": 0.02, # BSIF, page modifiée le 2026-01-29
32 + "lti_multiple": 4.5, # BSIF, limites LTI
33 + "plafond_assurable": 1_500_000.0, "seuil_mdf": 500_000.0, # Finances Canada, 15 décembre 2024
34 +}
35 +
36 +# ----------------------------------------------------------------------------- aides propres au chapitre
37 +
38 +def prime_aph(rpv: float, amortissement_mois: int) -> float:
39 + """Prime d'assurance SCHL (immeubles collectifs, locatif ordinaire et APH Select, tous buts sauf construction),
40 + en proportion du prêt : barème par tranche de RPV + surprime de 0,25 % par tranche de 5 ans d'amortissement au-delà
41 + de 25 ans (sources/notes-C.md §4.4, PDF « Droits et primes » consulté le 2026-10-31)."""
42 + if rpv <= 0.65: base = 0.0260
43 + elif rpv <= 0.70: base = 0.0285
44 + elif rpv <= 0.75: base = 0.0335
45 + elif rpv <= 0.80: base = 0.0435
46 + elif rpv <= 0.85: base = 0.0535
47 + elif rpv <= 0.90: base = 0.0590
48 + else: base = 0.0615
49 + annees = amortissement_mois / 12
50 + tranches = max(0, int(np.ceil((annees - 25) / 5 - 1e-9)))
51 + return base + 0.0025 * tranches
52 +
53 +def mise_de_fonds_minimale(prix: float) -> float:
54 + """Mise de fonds minimale d'un prêt assuré (résidentiel 1-4 logements, propriétaire-occupant) : 5 % jusqu'à 500 000 $,
55 + 10 % de la tranche entre 500 000 $ et 1,5 M$ ; au-delà de 1,5 M$ le prêt n'est pas assurable (20 % exigés en pratique
56 + par la règle RPV ≤ 80 % de B-20). Règles fédérales en vigueur le 15 décembre 2024 (notes-C.md §4.3)."""
57 + if prix > REPERES["plafond_assurable"]:
58 + return 0.20 * prix
59 + s = REPERES["seuil_mdf"]
60 + return 0.05 * min(prix, s) + 0.10 * max(prix - s, 0.0)
61 +
62 +def cout_effectif_dette(hyp: fin.Hypotheque, annees: int) -> dict:
63 + """Coût effectif annuel de la dette : TRI des flux annuels de l'emprunteur (+M_0 ; −SD … ; −SD − solde) sur `annees`,
64 + comparé au taux effectif annuel (1 + i/2)^2 − 1 (paiements mensuels agrégés en fin d'année ⇒ TRI annuel < effectif)."""
65 + sd = hyp.service_dette_annuel; solde = hyp.solde(annees * 12)
66 + flux_emprunteur = [hyp.capital] + [-sd] * annees
67 + flux_emprunteur[-1] -= solde
68 + flux_preteur = [-x for x in flux_emprunteur]
69 + return {"k_annuel": fin.tri(flux_preteur), "taux_effectif": fin.taux_effectif_annuel(hyp.taux_nominal, hyp.composition),
70 + "flux_emprunteur": flux_emprunteur, "solde_fin": solde}
71 +
72 +# ----------------------------------------------------------------------------- cœur
73 +
74 +def base() -> dict:
75 + """Tout le cas 3 (via traite.cas3.tout) + flux après dette du chapitre 30."""
76 + r = c30.base()
77 + r["levier30"] = c30.flux_levier(r)
78 + return r
79 +
80 +def conventions(capital: float, i: float = 0.0412, n: int = 480) -> pd.DataFrame:
81 + """Comparaison des conventions canadienne (composition semestrielle) et américaine (mensuelle) sur un même prêt."""
82 + rows = []
83 + for nom, comp in (("Canada (semestrielle)", 2), ("États-Unis (mensuelle)", 12)):
84 + h = fin.Hypotheque(capital, i, n, composition=comp)
85 + rows.append({"convention": nom, "composition": comp, "taux_mensuel": h.taux_periodique, "taux_effectif": fin.taux_effectif_annuel(i, comp),
86 + "paiement": h.paiement, "service_dette": h.service_dette_annuel, "constante": h.constante_hypothecaire,
87 + "solde_60": h.solde(60), "solde_120": h.solde(120), "interets_an1": h.interets_annee(1), "capital_an1": h.capital_annee(1)})
88 + df = pd.DataFrame(rows)
89 + return df
90 +
91 +def tableau_extraits(h: fin.Hypotheque, periodes=(1, 2, 3, 12, 13, 60, 61, 120, 121, 240, 360, 479, 480)) -> pd.DataFrame:
92 + tab = pd.DataFrame(h.tableau())
93 + out = tab[tab["periode"].isin(periodes)].copy()
94 + out["part_interet"] = out["interet"] / out["paiement"]
95 + return out.reset_index(drop=True)
96 +
97 +def decomposition_annuelle(h: fin.Hypotheque) -> pd.DataFrame:
98 + tab = pd.DataFrame(h.tableau()); tab["annee"] = (tab["periode"] - 1) // 12 + 1
99 + g = tab.groupby("annee").agg(interet=("interet", "sum"), capital=("capital", "sum"), paiement=("paiement", "sum"), solde_fin=("solde", "last")).reset_index()
100 + g["solde_debut"] = [h.capital] + g["solde_fin"].tolist()[:-1]
101 + return g
102 +
103 +def financement(r: dict) -> dict:
104 + """Dimensionnement du prêt du cas 3 (traite.cas3.financement) + indicateurs dérivés (ratios, point mort, coût effectif)."""
105 + f = r["financement"]; h = f["hypotheque"]; b = r["base"]; hyp = r["hyp"]; T = hyp["horizon_ans"]
106 + V = r["dcf"]["valeur"]; noi1 = b["rne"]
107 + cout = cout_effectif_dette(h, T)
108 + return {**{k: v for k, v in f.items() if k != "hypotheque"}, "hypotheque": h, "valeur": V, "rne_1": noi1,
109 + "paiement": h.paiement, "taux_effectif": fin.taux_effectif_annuel(h.taux_nominal, 2), "taux_mensuel_us": h.taux_nominal / 12,
110 + "interets_an1": h.interets_annee(1), "capital_an1": h.capital_annee(1), "solde_60": h.solde(60), "solde_120": h.solde(120),
111 + "rpv_max_par_rcd_sur_V": noi1 / (hyp["financement"]["rcd_min"] * h.constante_hypothecaire) / V,
112 + "rpv_max_par_rcd_sur_Vcap": r["taux"]["mediane"] / (hyp["financement"]["rcd_min"] * h.constante_hypothecaire),
113 + "R_dcf": noi1 / V, "R_median": r["taux"]["mediane"],
114 + "point_mort": fin.ratio_point_mort(b["depenses_totales"], h.service_dette_annuel, b["rbp_contrat"]),
115 + "point_mort_hors_reserve": fin.ratio_point_mort(b["depenses_hors_reserve"], h.service_dette_annuel, b["rbp_contrat"]),
116 + "flux_apres_dette_1": noi1 - h.service_dette_annuel, "cash_on_cash_1": (noi1 - h.service_dette_annuel) / f["mise_de_fonds"],
117 + "dette_sur_fp": f["pret"] / f["mise_de_fonds"], "k_annuel": cout["k_annuel"], "tri_fp": r["levier30"]["tri"], "tri_sans_levier": hyp["taux_actualisation"]}
118 +
119 +def levier_courant(r: dict) -> dict:
120 + """Vérification de R_E = (R − M f)/(1 − M) = R + (R − f) D/E sur la base DCF et sur la base capitalisation directe."""
121 + fi = financement(r); f = fi["constante"]; M = fi["rpv"]
122 + R = fi["R_dcf"]; RE_formule = (R - M * f) / (1 - M); RE_alt = R + (R - f) * fi["dette_sur_fp"]
123 + # sur la valeur par capitalisation directe (prix = V_cap, même prêt) :
124 + Vc = r["valeur_capitalisation"]; Mc = fi["pret"] / Vc; Rc = fi["rne_1"] / Vc
125 + return {"R": R, "f": f, "M": M, "D_sur_E": fi["dette_sur_fp"], "RE_formule": RE_formule, "RE_alt": RE_alt, "RE_direct": fi["cash_on_cash_1"],
126 + "R_cap": Rc, "M_cap": Mc, "RE_cap": (Rc - Mc * f) / (1 - Mc), "levier_courant": "négatif" if R < f else "positif"}
127 +
128 +def levier_par_rpv(r: dict, rpvs=(0.0, 0.25, 0.50, 0.60, None, 0.70, 0.75, 0.80, 0.85)) -> pd.DataFrame:
129 + """Effet du ratio prêt-valeur sur la mise de fonds, le service de la dette, le RCD, le rendement courant et le TRI des fonds
130 + propres (achat à la valeur DCF, flux du cas 3, même taux et même amortissement ; None = RPV du prêt retenu)."""
131 + d = r["dcf"]; hyp = r["hyp"]; fi = r["financement"]; V = d["valeur"]; T = hyp["horizon_ans"]
132 + noi1 = r["base"]["rne"]; cf = d["flux"]
133 + rows = []
134 + for M in rpvs:
135 + Mv = fi["rpv"] if M is None else M
136 + pret = Mv * V
137 + h = fin.Hypotheque(pret, hyp["financement"]["taux"], hyp["financement"]["amortissement_mois"])
138 + res = fin.rendement_fonds_propres_levier(cf, V, h, d["vt_nette"])
139 + sd = h.service_dette_annuel
140 + rows.append({"rpv": Mv, "pret": pret, "mise_de_fonds": V - pret, "service_dette": sd, "rcd_1": (noi1 / sd) if sd > 0 else np.inf,
141 + "flux_apres_dette_1": noi1 - sd, "cash_on_cash_1": (noi1 - sd) / (V - pret), "tri_fp": res["tri"],
142 + "flux_min": min(res["flux"][1:-1]), "retenu": M is None})
143 + return pd.DataFrame(rows)
144 +
145 +def tri_fp_courbe(r: dict, rpvs=np.linspace(0.0, 0.85, 35)) -> pd.DataFrame:
146 + return levier_par_rpv(r, tuple(float(x) for x in rpvs))
147 +
148 +def pret_maximal(r: dict) -> dict:
149 + fi = r["financement"]; hyp = r["hyp"]["financement"]
150 + return {"pret_rpv": fi["pret_rpv"], "pret_rcd": fi["pret_rcd"], "pret": fi["pret"], "contrainte": fi["contrainte"],
151 + "sd_max": r["base"]["rne"] / hyp["rcd_min"], "f": fi["constante"]}
152 +
153 +def grille_pret_max(r: dict, taux=(0.0312, 0.0412, 0.0512, 0.0612), amort=(300, 360, 480), rcd=(1.10, 1.20, 1.30)) -> pd.DataFrame:
154 + """Prêt maximal par le RCD selon le taux, l'amortissement et le RCD minimal, en dollars et en % de la valeur DCF."""
155 + noi1 = r["base"]["rne"]; V = r["dcf"]["valeur"]; rows = []
156 + for i in taux:
157 + for n in amort:
158 + for q in rcd:
159 + p = fin.pret_max_par_rcd(noi1, q, i, n)
160 + rows.append({"taux": i, "amortissement_mois": n, "rcd_min": q, "pret_max": p, "rpv_implicite": p / V,
161 + "constante": fin.Hypotheque(1.0, i, n).constante_hypothecaire})
162 + return pd.DataFrame(rows)
163 +
164 +def aph_select(r: dict, rcd_min: float = 1.10, rpv_max: float = 0.85, amorts=(480, 540, 600)) -> pd.DataFrame:
165 + """Variante APH Select (50 points : RPV 85 %, RCD 1,10 ; 70 points : 45 ans ; 100 points : 50 ans) sur la valeur DCF, au
166 + même taux de 4,12 % ; prime SCHL selon le barème (prime_aph), capitalisée ou non au prêt (ici affichée séparément)."""
167 + noi1 = r["base"]["rne"]; V = r["dcf"]["valeur"]; i = r["hyp"]["financement"]["taux"]; rows = []
168 + fi = r["financement"]
169 + rows.append({"formule": "Conventionnel 40 ans (RPV 75 %, RCD 1,20)", "amortissement_mois": 480, "pret_rpv": fi["pret_rpv"], "pret_rcd": fi["pret_rcd"], "pret": fi["pret"],
170 + "rpv": fi["pret"] / V, "service_dette": fi["service_dette"], "mise_de_fonds": V - fi["pret"], "prime_pct": np.nan, "prime": np.nan, "rcd_1": noi1 / fi["service_dette"]})
171 + for n in amorts:
172 + p_rpv = fin.pret_max_par_rpv(V, rpv_max); p_rcd = fin.pret_max_par_rcd(noi1, rcd_min, i, n); p = min(p_rpv, p_rcd)
173 + h = fin.Hypotheque(p, i, n); pr = prime_aph(p / V, n)
174 + rows.append({"formule": f"APH Select {n // 12} ans (RPV {rpv_max:.0%}, RCD {rcd_min:.2f})".replace("%", " %"), "amortissement_mois": n, "pret_rpv": p_rpv, "pret_rcd": p_rcd, "pret": p,
175 + "rpv": p / V, "service_dette": h.service_dette_annuel, "mise_de_fonds": V - p, "prime_pct": pr, "prime": pr * p, "rcd_1": noi1 / h.service_dette_annuel})
176 + return pd.DataFrame(rows)
177 +
178 +# ----------------------------------------------------------------------------- B-20, MQR, LTI, mise de fonds
179 +
180 +def regles_residentielles() -> dict:
181 + """Illustrations chiffrées des règles fédérales (résidentiel 1-4 logements)."""
182 + mqr_na = fin.taux_admissible_minimal(REPERES["non_assure_5ans_plus"]); mqr_as = fin.taux_admissible_minimal(REPERES["assure_5ans_plus"])
183 + mqr_bas = fin.taux_admissible_minimal(0.0300)
184 + prix = (400_000.0, 650_000.0, 1_000_000.0, 1_500_000.0, 1_600_000.0)
185 + mdf = pd.DataFrame({"prix": prix, "mise_de_fonds": [mise_de_fonds_minimale(p) for p in prix]})
186 + mdf["pct"] = mdf["mise_de_fonds"] / mdf["prix"]; mdf["pret"] = mdf["prix"] - mdf["mise_de_fonds"]; mdf["rpv"] = mdf["pret"] / mdf["prix"]
187 + # capacité d'emprunt d'un ménage : paiement admissible au MQR vs au taux contractuel (ménage fictif, 25 ans)
188 + revenu = 120_000.0; part_dette = 0.39 # ratio ABD indicatif, hypothèse pédagogique (non une règle citée)
189 + paiement_mensuel = revenu * part_dette / 12 * 0.85 # 85 % du service de la dette admissible va à l'hypothèque (reste : taxes, chauffage) — hypothèse
190 + def capacite(i, n=300):
191 + f = fin.Hypotheque(1.0, i, n).paiement
192 + return paiement_mensuel / f
193 + cap = {"revenu": revenu, "paiement_mensuel": paiement_mensuel, "pret_au_contrat": capacite(REPERES["non_assure_5ans_plus"]), "pret_au_mqr": capacite(mqr_na),
194 + "pret_lti": REPERES["lti_multiple"] * revenu}
195 + cap["reduction_mqr"] = 1 - cap["pret_au_mqr"] / cap["pret_au_contrat"]
196 + return {"mqr_non_assure": mqr_na, "mqr_assure": mqr_as, "mqr_taux_bas": mqr_bas, "mise_de_fonds": mdf, "capacite": cap}
197 +
198 +def stress_rcd(r: dict, marges=(0.0, 0.01, 0.02, 0.03)) -> pd.DataFrame:
199 + """Test de résistance « à la B-20 » appliqué au RCD du cas 3 : service de la dette du prêt retenu recalculé au taux contractuel
200 + + marge (même amortissement), RCD sur le RNE de l'année 1, et prêt maximal si le prêteur qualifiait au taux majoré."""
201 + fi = r["financement"]; hyp = r["hyp"]["financement"]; noi1 = r["base"]["rne"]; V = r["dcf"]["valeur"]; rows = []
202 + for m in marges:
203 + i = hyp["taux"] + m
204 + h = fin.Hypotheque(fi["pret"], i, hyp["amortissement_mois"])
205 + pmax = fin.pret_max_par_rcd(noi1, hyp["rcd_min"], i, hyp["amortissement_mois"])
206 + rows.append({"marge": m, "taux": i, "service_dette": h.service_dette_annuel, "rcd_1": noi1 / h.service_dette_annuel,
207 + "pret_max_qualifie": pmax, "rpv_qualifie": pmax / V, "constante": h.constante_hypothecaire})
208 + return pd.DataFrame(rows)
209 +
210 +# ----------------------------------------------------------------------------- renouvellement au terme
211 +
212 +def renouvellement(r: dict, deltas=(0.0, 0.01, 0.02, 0.03), terme_ans: int | None = None) -> dict:
213 + """Renouvellement exact à l'échéance du terme : solde après `terme` années, nouveau paiement sur l'amortissement résiduel
214 + au taux initial + delta ; service de la dette, RCD des années suivantes, flux des fonds propres et TRI (achat à la valeur
215 + DCF, projection de base du cas 3). Comparaison avec l'approximation du chapitre 30 (taux majoré dès l'origine)."""
216 + d = r["dcf"]; hyp = r["hyp"]; fi = r["financement"]; h0 = fi["hypotheque"]; T = hyp["horizon_ans"]
217 + terme = terme_ans or hyp["financement"]["terme_ans"]; n0 = hyp["financement"]["amortissement_mois"]
218 + V = d["valeur"]; mise = V - h0.capital; cf = list(d["flux"]); rne = r["projection"].loc[r["projection"]["annee"] <= T, "rne"].tolist()
219 + solde_terme = h0.solde(terme * 12); n_res = n0 - terme * 12
220 + sd0 = h0.service_dette_annuel
221 + rows = []; detail = {}
222 + for dlt in deltas:
223 + i2 = h0.taux_nominal + dlt
224 + h2 = fin.Hypotheque(solde_terme, i2, n_res)
225 + sd2 = h2.service_dette_annuel
226 + sd = [sd0] * terme + [sd2] * (T - terme)
227 + solde_T = h2.solde((T - terme) * 12)
228 + flux = [-mise] + [cf[t] - sd[t] for t in range(T)]
229 + flux[-1] += d["vt_nette"] - solde_T
230 + tri = fin.tri(flux)
231 + rcd = [rne[t] / sd[t] for t in range(T)]
232 + # approximation du ch. 30 : même prêt, taux majoré dès l'origine
233 + h_approx = fin.Hypotheque(h0.capital, i2, n0)
234 + approx = fin.rendement_fonds_propres_levier(cf, V, h_approx, d["vt_nette"])
235 + rows.append({"delta": dlt, "taux_renouvele": i2, "solde_terme": solde_terme, "paiement_2": h2.paiement, "service_dette_2": sd2,
236 + "hausse_sd": sd2 - sd0, "hausse_sd_pct": sd2 / sd0 - 1, "rcd_an_terme_plus_1": rcd[terme], "rcd_min_apres": min(rcd[terme:]),
237 + "rcd_an_T": rcd[-1], "flux_apres_dette_an_terme_plus_1": cf[terme] - sd2, "solde_T": solde_T, "tri_fp": tri,
238 + "tri_fp_approx_ch30": approx["tri"], "sd_approx_ch30": h_approx.service_dette_annuel, "interets_an_terme_plus_1": h2.interets_annee(1)})
239 + detail[dlt] = {"flux": flux, "rcd": rcd, "sd": sd, "hypotheque_2": h2}
240 + tab = pd.DataFrame(rows)
241 + # seuil : hausse de taux qui ramène le RCD de l'année terme+1 à 1,20 et à 1,00
242 + def rcd_apres(dlt):
243 + h2 = fin.Hypotheque(solde_terme, h0.taux_nominal + dlt, n_res); return rne[terme] / h2.service_dette_annuel
244 + def bissection(cible, lo=0.0, hi=0.20):
245 + for _ in range(200):
246 + mid = 0.5 * (lo + hi)
247 + if rcd_apres(mid) > cible: lo = mid
248 + else: hi = mid
249 + return 0.5 * (lo + hi)
250 + return {"tableau": tab, "detail": detail, "solde_terme": solde_terme, "n_residuel": n_res, "terme": terme,
251 + "delta_rcd_120": bissection(1.20), "delta_rcd_100": bissection(1.00), "taux_rcd_120": h0.taux_nominal + bissection(1.20), "taux_rcd_100": h0.taux_nominal + bissection(1.00)}
252 +
253 +def sensibilite_service_dette(r: dict, taux=np.arange(0.03, 0.0801, 0.0025), amorts=(300, 360, 480)) -> pd.DataFrame:
254 + """Service de la dette et RCD (RNE_1) du prêt retenu selon le taux et l'amortissement (pour la figure et le tableau)."""
255 + fi = r["financement"]; noi1 = r["base"]["rne"]; rows = []
256 + for n in amorts:
257 + for i in taux:
258 + h = fin.Hypotheque(fi["pret"], float(i), n)
259 + rows.append({"amortissement_mois": n, "taux": float(i), "service_dette": h.service_dette_annuel, "rcd_1": noi1 / h.service_dette_annuel, "constante": h.constante_hypothecaire})
260 + return pd.DataFrame(rows)
261 +
262 +def risque_levier(r: dict, sigma_actif: float = 0.08) -> dict:
263 + """Illustration : écart-type du rendement des fonds propres = σ_actif / (1 − M) (dette sans risque, une période)."""
264 + fi = r["financement"]; M = fi["rpv"]
265 + return {"sigma_actif": sigma_actif, "M": M, "sigma_fp": sigma_actif / (1 - M), "multiplicateur": 1 / (1 - M),
266 + "grille": pd.DataFrame({"rpv": [0, 0.25, 0.5, M, 0.75, 0.85], "sigma_fp": [sigma_actif / (1 - m) for m in [0, 0.25, 0.5, M, 0.75, 0.85]]})}
267 +
268 +# ----------------------------------------------------------------------------- exercices
269 +
270 +def exercices() -> dict:
271 + r = base(); e = {}
272 + # ex 1 : convention canadienne vs américaine, 850 000 $, 4,58 %, 300 mois
273 + e["ex1"] = conventions(850_000.0, 0.0458, 300).set_index("convention").to_dict("index")
274 + # ex 2 : terme vs amortissement : prêt 1 000 000 $, 4,12 %, 480 mois ; solde à 60 et 120 ; intérêts an 1 ; part du capital remboursée au terme
275 + h = fin.Hypotheque(1_000_000.0, 0.0412, 480)
276 + e["ex2"] = {"paiement": h.paiement, "sd": h.service_dette_annuel, "solde_60": h.solde(60), "solde_120": h.solde(120), "interets_an1": h.interets_annee(1),
277 + "capital_an1": h.capital_annee(1), "part_remboursee_60": 1 - h.solde(60) / h.capital, "interets_totaux": h.paiement * 480 - h.capital,
278 + "h25": fin.Hypotheque(1_000_000.0, 0.0412, 300)}
279 + e["ex2"]["interets_totaux_25"] = e["ex2"]["h25"].paiement * 300 - 1_000_000.0; e["ex2"]["sd_25"] = e["ex2"]["h25"].service_dette_annuel
280 + # ex 3 : prêt maximal : RNE 120 000 $, valeur 2 600 000 $, RPV 75 %, RCD 1,25, 4,58 %, 360 mois ; puis APH 1,10 / 85 % / 480 mois
281 + e["ex3"] = {"pret_rpv": fin.pret_max_par_rpv(2_600_000, 0.75), "pret_rcd": fin.pret_max_par_rcd(120_000, 1.25, 0.0458, 360),
282 + "f": fin.Hypotheque(1.0, 0.0458, 360).constante_hypothecaire, "pret_rcd_aph": fin.pret_max_par_rcd(120_000, 1.10, 0.0412, 480),
283 + "pret_rpv_aph": fin.pret_max_par_rpv(2_600_000, 0.85), "f_aph": fin.Hypotheque(1.0, 0.0412, 480).constante_hypothecaire}
284 + e["ex3"]["pret"] = min(e["ex3"]["pret_rpv"], e["ex3"]["pret_rcd"]); e["ex3"]["pret_aph"] = min(e["ex3"]["pret_rpv_aph"], e["ex3"]["pret_rcd_aph"])
285 + e["ex3"]["prime_aph"] = prime_aph(e["ex3"]["pret_aph"] / 2_600_000, 480); e["ex3"]["rpv_aph"] = e["ex3"]["pret_aph"] / 2_600_000
286 + # ex 4 : levier courant : R = 5,2 %, f = 6,0 % et 4,5 %, M = 0,70
287 + e["ex4"] = {M: {f: (0.052 - M * f) / (1 - M) for f in (0.060, 0.045)} for M in (0.0, 0.5, 0.7, 0.8)}
288 + # ex 5 : MQR et mise de fonds : prix 720 000 $, taux 4,58 %
289 + e["ex5"] = {"mdf": mise_de_fonds_minimale(720_000), "mqr": fin.taux_admissible_minimal(0.0458), "mqr_bas": fin.taux_admissible_minimal(0.0300),
290 + "paiement_contrat": fin.Hypotheque(720_000 - mise_de_fonds_minimale(720_000), 0.0458, 300).paiement,
291 + "paiement_mqr": fin.Hypotheque(720_000 - mise_de_fonds_minimale(720_000), fin.taux_admissible_minimal(0.0458), 300).paiement,
292 + "lti_revenu_requis": (720_000 - mise_de_fonds_minimale(720_000)) / 4.5}
293 + # ex 6 : renouvellement du cas 3 à +150 pb
294 + e["ex6"] = renouvellement(r, deltas=(0.015,))["tableau"].iloc[0].to_dict()
295 + # ex 7 : levier et TRI par RPV (cas 3) : 50 % et 85 %
296 + e["ex7"] = levier_par_rpv(r, (0.50, 0.85)).to_dict("records")
297 + e["ex7_k"] = financement(r)["k_annuel"]
298 + return e
299 +
300 +# ----------------------------------------------------------------------------- sortie
301 +
302 +def _d(x): return f"{x:,.0f}".replace(",", " ") + " $"
303 +def _p(x, d=2): return f"{x*100:.{d}f}".replace(".", ",") + " %"
304 +
305 +def main():
306 + r = base(); fi = financement(r)
307 + print("=== Cas 3 — financement (traite.cas3.financement) ===")
308 + for k in ("valeur", "rne_1", "pret_rpv", "pret_rcd", "pret", "contrainte", "rpv", "mise_de_fonds", "paiement", "service_dette", "constante", "taux_mensuel", "taux_mensuel_us", "taux_effectif",
309 + "interets_an1", "capital_an1", "solde_60", "solde_120", "rcd", "flux_apres_dette_1", "cash_on_cash_1", "dette_sur_fp", "point_mort", "point_mort_hors_reserve",
310 + "rpv_max_par_rcd_sur_V", "rpv_max_par_rcd_sur_Vcap", "R_dcf", "R_median", "k_annuel", "tri_fp", "tri_sans_levier"):
311 + v = fi[k]; print(f"{k:28s} {v if not isinstance(v, float) else (round(v, 6) if abs(v) < 10 else round(v, 2))}")
312 + print("\n=== Conventions (prêt du cas 3) ===\n", conventions(fi["pret"]).T)
313 + print("\n=== Tableau d'amortissement — extraits ===\n", tableau_extraits(fi["hypotheque"]).round(2).to_string())
314 + dec = decomposition_annuelle(fi["hypotheque"]); print("\n=== Décomposition annuelle (années 1, 5, 10, 20, 30, 40) ===\n", dec[dec["annee"].isin([1, 5, 10, 20, 30, 40])].round(0).to_string())
315 + print("\n=== Levier courant ===\n", levier_courant(r))
316 + print("\n=== Levier par RPV ===\n", levier_par_rpv(r).round(4).to_string())
317 + print("\n=== Prêt maximal ===\n", pret_maximal(r))
318 + print("\n=== Grille prêt max (RCD) ===\n", grille_pret_max(r).round(4).to_string())
319 + print("\n=== APH Select ===\n", aph_select(r).round(4).to_string())
320 + rr = regles_residentielles(); print("\n=== Règles résidentielles ===\n", {k: v for k, v in rr.items() if k != "mise_de_fonds"}, "\n", rr["mise_de_fonds"].round(4).to_string())
321 + print("\n=== Stress RCD (taux + marge) ===\n", stress_rcd(r).round(4).to_string())
322 + ren = renouvellement(r); print("\n=== Renouvellement au terme ===\n", ren["tableau"].round(4).T.to_string(), "\nseuils :", {k: ren[k] for k in ("solde_terme", "n_residuel", "delta_rcd_120", "delta_rcd_100", "taux_rcd_120", "taux_rcd_100")})
323 + print("\n=== Risque ===\n", risque_levier(r)["grille"].round(4).to_string())
324 + s = sensibilite_service_dette(r, taux=np.array([0.03, 0.04, 0.0412, 0.05, 0.06, 0.07, 0.08])); print("\n=== Service de la dette vs taux ===\n", s.round(4).to_string())
325 + print("\n=== Exercices ===")
326 + e = exercices()
327 + for k, v in e.items(): print(k, v)
328 +
329 +if __name__ == "__main__":
330 + main()
added analysis/python/cases/ch32_plex.py +298 −0
@@ -0,0 +1,298 @@
1 +"""Chapitre 32 — Duplex à quintuplex : calculs de l'exemple intégral (cas 2, triplex de Limoilou, données SYNTHÉTIQUES).
2 +
3 +Tout chiffre cité dans manuscript/chapters/ch32.tex et solutions/ch32.tex provient de ce script, qui s'appuie sur les
4 +fonctions partagées de traite.cas2 (revenus, RNE, ventes de plex), traite.finance (hypothèque canadienne, capitalisation)
5 +et traite.comparables (distance d'attributs). Aucune formule financière n'est réécrite ici.
6 +
7 +Exécution : python3 analysis/python/cases/ch32_plex.py (depuis la racine du dépôt)
8 +"""
9 +from __future__ import annotations
10 +import sys, pathlib
11 +import numpy as np, pandas as pd
12 +
13 +ROOT = pathlib.Path(__file__).resolve().parents[3]
14 +sys.path.insert(0, str(ROOT / "analysis/python"))
15 +from traite import cas2, finance as fin, comparables as cmp # noqa: E402
16 +
17 +# ----------------------------------------------------------------------------- paramètres déclarés du chapitre
18 +TAUX_ASSURE = 0.0412 # BdC, fonds avancés, prêts assurés à taux fixe de 5 ans et plus, août 2026 (SRC-E-012)
19 +TAUX_NON_ASSURE = 0.0458 # idem, prêts non assurés
20 +AMORT_MOIS = 300 # 25 ans (30 ans possibles pour un premier acheteur ou une construction neuve, Finances Canada 2024)
21 +PLANCHER_MQR, MARGE_MQR = 0.0525, 0.02 # BSIF, taux admissible minimal (prêts non assurés) : max(contrat + 2 %, 5,25 %)
22 +SEUIL_5PCT, PLAFOND_ASSURABLE = 500_000.0, 1_500_000.0 # Finances Canada, en vigueur le 15 décembre 2024
23 +TAUX_SANS_RISQUE = 0.0358 # obligation du GdC 5 ans, 2026-10-29 (coût d'opportunité de la mise de fonds)
24 +R_INVESTISSEUR_REF = 0.05 # taux global « d'investisseur » illustratif : borne basse CBRE Québec, basse hauteur B, T3 2026 (5,00-6,00 %)
25 +POIDS_INDICATIONS = {"par_logement": 0.40, "mrb": 0.35, "capitalisation": 0.25} # pondération raisonnée (sect. 32.4)
26 +ECHELLES = {"superficie_habitable_m2": 30.0, "age": 15.0, "etat_1a5": 1.0, "stationnement": 1.0, "loyer_relatif": 0.05, "mois": 6.0}
27 +POIDS_DIST = {"superficie_habitable_m2": 1.5, "age": 1.0, "etat_1a5": 1.0, "stationnement": 0.5, "loyer_relatif": 1.5, "mois": 1.0}
28 +K_COMPARABLES = 5
29 +
30 +
31 +# ----------------------------------------------------------------------------- règles de financement (vérifiées, notes-C)
32 +def mise_de_fonds_minimale_assuree(prix: float) -> float:
33 + """Mise de fonds minimale d'un prêt assuré (Finances Canada, 15 déc. 2024) : 5 % jusqu'à 500 000 $, 10 % de la tranche
34 + entre 500 000 $ et 1,5 M$ ; au-delà de 1,5 M$ le prêt n'est pas assurable (retourne NaN)."""
35 + if prix > PLAFOND_ASSURABLE:
36 + return float("nan")
37 + return 0.05 * min(prix, SEUIL_5PCT) + 0.10 * max(prix - SEUIL_5PCT, 0.0)
38 +
39 +
40 +def financement_occupant(prix: float, mise_de_fonds: float, taux: float, amort_mois: int = AMORT_MOIS) -> dict:
41 + """Prêt, paiement mensuel (composition semestrielle), service annuel, constante, paiement au taux admissible minimal."""
42 + pret = prix - mise_de_fonds
43 + h = fin.Hypotheque(pret, taux, amort_mois)
44 + tam = fin.taux_admissible_minimal(taux, PLANCHER_MQR, MARGE_MQR)
45 + h_tam = fin.Hypotheque(pret, tam, amort_mois)
46 + return {"prix": prix, "mise_de_fonds": mise_de_fonds, "rpv": fin.rpv(pret, prix), "pret": pret, "taux": taux, "amort_mois": amort_mois,
47 + "paiement_mensuel": h.paiement, "service_dette": h.service_dette_annuel, "constante": h.constante_hypothecaire,
48 + "taux_admissible": tam, "paiement_mensuel_tam": h_tam.paiement, "solde_60": h.solde(60), "capital_rembourse_5ans": pret - h.solde(60)}
49 +
50 +
51 +def cout_occupation(fi: dict, base: dict, loyers: pd.DataFrame, logement_occupe: str, taux_opportunite: float = TAUX_SANS_RISQUE) -> dict:
52 + """Coût net annuel et mensuel d'occupation pour un acheteur-occupant qui reprend `logement_occupe` :
53 + service de la dette + dépenses d'exploitation hors gestion imputée (il gère lui-même) + coût d'opportunité de la mise de fonds
54 + − loyers des autres logements (après inoccupation et pertes), comparé au loyer de marché du logement occupé."""
55 + d = base["detail"]
56 + dep_hors_gestion = float(d.loc[d["type"] != "imputée", "normalise"].sum())
57 + autres = loyers[loyers["logement"] != logement_occupe]
58 + loyers_percus = float(autres["loyer_mensuel"].sum() * 12) * (1 - cas2.TAUX_VACANCE_PERTES)
59 + occ = loyers[loyers["logement"] == logement_occupe].iloc[0]
60 + opp = fi["mise_de_fonds"] * taux_opportunite
61 + brut = fi["service_dette"] + dep_hors_gestion + opp
62 + net = brut - loyers_percus
63 + net_hors_capital = net - fi["capital_rembourse_5ans"] / 5.0
64 + return {"logement": logement_occupe, "loyer_marche_logement": float(occ["loyer_marche_estime"]), "loyer_contrat_logement": float(occ["loyer_mensuel"]),
65 + "depenses_hors_gestion": dep_hors_gestion, "cout_opportunite": opp, "loyers_percus_nets": loyers_percus, "sortie_brute": brut,
66 + "cout_net_annuel": net, "cout_net_mensuel": net / 12, "cout_net_mensuel_hors_capital": net_hors_capital / 12,
67 + "capital_annuel_moyen_5ans": fi["capital_rembourse_5ans"] / 5.0}
68 +
69 +
70 +# ----------------------------------------------------------------------------- indicateurs de marché et décomposition du taux
71 +def indicateurs_par_groupe(c: pd.DataFrame, cle: str) -> pd.DataFrame:
72 + g = c.assign(prix_m2=c["prix_vente"] / c["superficie_habitable_m2"], loyer_relatif=c["loyer_mensuel_moyen_contrat"] / c["loyer_mensuel_moyen_marche"])
73 + out = g.groupby(cle).agg(n=("prix_vente", "size"), prix_median=("prix_vente", "median"), prix_par_logement=("prix_par_logement", "median"),
74 + prix_m2=("prix_m2", "median"), mrb=("mrb", "median"), taux=("taux_global", "median"), loyer_relatif=("loyer_relatif", "median"),
75 + ratio_depenses=("ratio_depenses", "median"))
76 + return out
77 +
78 +
79 +def decomposition_taux(c: pd.DataFrame, inocc: float = 0.024) -> pd.DataFrame:
80 + """Pour chaque vente : loyer relatif ρ = contrat/marché, taux extrait au contrat R_c = RNE_c/P, taux « au marché »
81 + R_m = RBP_m (1 − v)(1 − d)/P avec le ratio de dépenses d de la vente, MRB au marché, et identité R_c = ρ R_m (dépenses proportionnelles)."""
82 + g = c.copy()
83 + g["loyer_relatif"] = g["loyer_mensuel_moyen_contrat"] / g["loyer_mensuel_moyen_marche"]
84 + g["rbp_marche"] = g["loyer_mensuel_moyen_marche"] * g["nb_logements"] * 12
85 + g["rne_marche"] = g["rbp_marche"] * (1 - inocc) * (1 - g["ratio_depenses"])
86 + g["taux_marche"] = g["rne_marche"] / g["prix_vente"]
87 + g["mrb_marche"] = g["prix_vente"] / g["rbp_marche"]
88 + g["taux_contrat_reconstitue"] = g["loyer_relatif"] * g["taux_marche"]
89 + return g
90 +
91 +
92 +def regression_taux_loyer_relatif(g: pd.DataFrame) -> dict:
93 + """OLS : taux_global = a + b (ρ − 1) + ε, puis variante avec l'état (centré sur 3) et le nombre de logements (centré sur 3)."""
94 + y = g["taux_global"].to_numpy()
95 + x1 = np.column_stack([np.ones(len(g)), g["loyer_relatif"] - 1.0])
96 + b1, *_ = np.linalg.lstsq(x1, y, rcond=None)
97 + res1 = y - x1 @ b1
98 + x2 = np.column_stack([np.ones(len(g)), g["loyer_relatif"] - 1.0, g["etat_1a5"] - 3, g["nb_logements"] - 3])
99 + b2, *_ = np.linalg.lstsq(x2, y, rcond=None)
100 + res2 = y - x2 @ b2
101 + sst = ((y - y.mean()) ** 2).sum()
102 + # MRB (log) sur ρ
103 + ym = np.log(g["mrb"].to_numpy())
104 + bm, *_ = np.linalg.lstsq(x1, ym, rcond=None)
105 + resm = ym - x1 @ bm
106 + return {"a": b1[0], "b_rho": b1[1], "r2": 1 - (res1 ** 2).sum() / sst, "sigma": float(np.sqrt((res1 ** 2).sum() / (len(y) - 2))),
107 + "a2": b2[0], "b2_rho": b2[1], "b2_etat": b2[2], "b2_nb": b2[3], "r2_2": 1 - (res2 ** 2).sum() / sst,
108 + "mrb_a": bm[0], "mrb_b_rho": bm[1], "mrb_r2": 1 - (resm ** 2).sum() / ((ym - ym.mean()) ** 2).sum(),
109 + "rho_median": float(g["loyer_relatif"].median()), "n": len(g)}
110 +
111 +
112 +def prime_occupant(prix: float, rne_contrat: float, rne_marche: float, r_inv: float = R_INVESTISSEUR_REF) -> dict:
113 + """Décomposition pédagogique P = RNE_c/R* + (RNE_m − RNE_c)/R* + π : valeur d'investisseur aux loyers en place, valeur du rattrapage
114 + (si les loyers étaient au marché), et résidu π attribué à la demande d'occupants (prime d'occupation)."""
115 + v_inv = fin.capitalisation_directe(rne_contrat, r_inv); v_m = fin.capitalisation_directe(rne_marche, r_inv)
116 + return {"r_inv": r_inv, "v_investisseur_contrat": v_inv, "v_rattrapage": v_m - v_inv, "v_investisseur_marche": v_m, "prime": prix - v_m,
117 + "prime_pct": (prix - v_m) / prix, "taux_implicite": rne_contrat / prix}
118 +
119 +
120 +# ----------------------------------------------------------------------------- sélection des comparables triplex de Limoilou
121 +def selection_comparables(c: pd.DataFrame, sujet: dict, base: dict, k: int = K_COMPARABLES, quartier: str = "Limoilou") -> pd.DataFrame:
122 + cand = c[(c["nb_logements"] == sujet["nb_logements"]) & (c["quartier"] == quartier)].copy()
123 + cand["loyer_relatif"] = cand["loyer_mensuel_moyen_contrat"] / cand["loyer_mensuel_moyen_marche"]
124 + cand["prix_m2"] = cand["prix_vente"] / cand["superficie_habitable_m2"]
125 + s = {"superficie_habitable_m2": sujet["superficie_habitable_m2"], "age": sujet["age"], "etat_1a5": sujet["etat_1a5"], "stationnement": sujet["stationnement"],
126 + "loyer_relatif": base["rbp_contrat"] / base["rbp_marche"], "mois": 0.0}
127 + cand["distance"] = [cmp.distance_attributs(s, {kk: float(row[kk]) for kk in ECHELLES}, ECHELLES, POIDS_DIST) for _, row in cand.iterrows()]
128 + cand = cand.sort_values("distance")
129 + cand["retenu"] = False; cand.iloc[:k, cand.columns.get_loc("retenu")] = True
130 + return cand
131 +
132 +
133 +G_ROLE_ANNUEL = (1 + 0.235) ** (1 / 3) - 1 # croissance annuelle composée implicite du rôle 2025-2027 de Québec (+23,5 % en 3 ans), hypothèse du jeu
134 +
135 +
136 +def indications(base: dict, sujet: dict, ind: dict, comp5: pd.DataFrame, reg: dict, c: pd.DataFrame | None = None) -> dict:
137 + rbp, rne, n = base["rbp_contrat"], base["rne"], sujet["nb_logements"]
138 + rho_s = base["rbp_contrat"] / base["rbp_marche"]
139 + taux_ajuste = reg["a"] + reg["b_rho"] * (rho_s - 1.0)
140 + mrb_ajuste = float(np.exp(reg["mrb_a"] + reg["mrb_b_rho"] * (rho_s - 1.0)))
141 + out = {
142 + "rho_sujet": rho_s,
143 + # échantillon des 127 ventes (traite.cas2.tout)
144 + "v_mrb_127": ind["mrb_median_triplex"] * rbp, "v_cap_127": fin.capitalisation_directe(rne, ind["taux_median"]),
145 + "v_log_127": ind["prix_par_logement_triplex"] * n, "v_m2_127": ind["prix_m2_median"] * sujet["superficie_habitable_m2"],
146 + # ajustements par régression sur le loyer relatif
147 + "taux_ajuste": taux_ajuste, "v_cap_ajuste": fin.capitalisation_directe(rne, taux_ajuste), "mrb_ajuste": mrb_ajuste, "v_mrb_ajuste": mrb_ajuste * rbp,
148 + # cinq comparables triplex de Limoilou
149 + "mrb_comp": float(comp5["mrb"].median()), "taux_comp": float(comp5["taux_global"].median()), "log_comp": float(comp5["prix_par_logement"].median()),
150 + "m2_comp": float(comp5["prix_m2"].median()),
151 + }
152 + # explication de l'écart capitalisation vs comparaison : (i) structure de dépenses du sujet vs ratio médian des ventes ;
153 + # (ii) ancienneté médiane des 127 ventes et indexation au rythme du rôle (+23,5 % en 3 ans, hypothèse du jeu synthétique)
154 + out["v_mrb_comp"] = out["mrb_comp"] * rbp; out["v_cap_comp"] = fin.capitalisation_directe(rne, out["taux_comp"])
155 + out["v_log_comp"] = out["log_comp"] * n; out["v_m2_comp"] = out["m2_comp"] * sujet["superficie_habitable_m2"]
156 + # indications retenues pour la pondération : comparables (par logement), MRB des comparables, capitalisation ajustée au loyer relatif
157 + if c is not None:
158 + d_med = float(c["ratio_depenses"].median()); mois_med = float(c["mois"].median())
159 + rbe = base["rbe"]; rne_ratio_ventes = rbe * (1 - d_med)
160 + out.update({"ratio_dep_median_ventes": d_med, "rne_au_ratio_des_ventes": rne_ratio_ventes,
161 + "v_cap_ratio_ventes": fin.capitalisation_directe(rne_ratio_ventes, taux_ajuste),
162 + "mois_median_127": mois_med, "g_role_annuel": G_ROLE_ANNUEL, "v_log_127_indexe": out["v_log_127"] * (1 + G_ROLE_ANNUEL) ** (mois_med / 12),
163 + "mois_median_comp": float(comp5["mois"].median())})
164 + retenues = {"par_logement": out["v_log_comp"], "mrb": out["v_mrb_comp"], "capitalisation": out["v_cap_ajuste"]}
165 + out["retenues"] = retenues
166 + out["valeur_ponderee"] = sum(POIDS_INDICATIONS[kk] * v for kk, v in retenues.items())
167 + out["valeur_arrondie"] = round(out["valeur_ponderee"] / 5000) * 5000
168 + out["etendue_pct"] = (max(retenues.values()) - min(retenues.values())) / out["valeur_ponderee"]
169 + out["taux_implicite_final"] = rne / out["valeur_ponderee"]; out["mrb_implicite_final"] = out["valeur_ponderee"] / rbp
170 + out["prix_log_final"] = out["valeur_ponderee"] / n
171 + out["ecart_role"] = out["valeur_ponderee"] / sujet["valeur_role_2025"] - 1
172 + return out
173 +
174 +
175 +# ----------------------------------------------------------------------------- sensibilité
176 +def sensibilite(base: dict, sujet: dict, ind: dict, reg: dict, indic: dict, loyers: pd.DataFrame) -> dict:
177 + rbp_c, rbp_m, rne_c = base["rbp_contrat"], base["rbp_marche"], base["rne"]
178 + d = base["detail"]; dep_fixes = float(d.loc[~d["type"].isin(["imputée"]), "normalise"].sum())
179 + rows = []
180 + for rho in (0.85, 0.90, 0.95, 1.00):
181 + rbp = rbp_m * rho; rbe = fin.revenu_brut_effectif(rbp, cas2.TAUX_VACANCE_PERTES)
182 + rne = fin.rne(rbe, dep_fixes - base["reserve"] + cas2.GESTION_IMPUTEE * rbe, base["reserve"])
183 + taux = reg["a"] + reg["b_rho"] * (rho - 1.0)
184 + rows.append({"rho": rho, "rbp": rbp, "rne": rne, "taux_ajuste": taux, "v_cap_ajuste": fin.capitalisation_directe(rne, taux),
185 + "v_cap_taux_median": fin.capitalisation_directe(rne, ind["taux_median"]), "v_mrb": indic["mrb_comp"] * rbp})
186 + loyers_tab = pd.DataFrame(rows)
187 + taux_tab = pd.DataFrame([{"taux": t, "valeur": fin.capitalisation_directe(rne_c, t)} for t in np.arange(0.0250, 0.0401, 0.0025)])
188 + prix = indic["valeur_arrondie"]
189 + fin_rows = []
190 + for libelle, mf, taux, amort in (("assuré, mise de fonds minimale, 25 ans", mise_de_fonds_minimale_assuree(prix), TAUX_ASSURE, 300),
191 + ("assuré, mise de fonds minimale, 30 ans (premier acheteur)", mise_de_fonds_minimale_assuree(prix), TAUX_ASSURE, 360),
192 + ("assuré, mise de fonds de 10 %", 0.10 * prix, TAUX_ASSURE, 300),
193 + ("non assuré, mise de fonds de 20 %", 0.20 * prix, TAUX_NON_ASSURE, 300),
194 + ("assuré, mise de fonds minimale, taux +100 pb", mise_de_fonds_minimale_assuree(prix), TAUX_ASSURE + 0.01, 300),
195 + ("assuré, mise de fonds minimale, taux +200 pb", mise_de_fonds_minimale_assuree(prix), TAUX_ASSURE + 0.02, 300)):
196 + f = financement_occupant(prix, mf, taux, amort); co = cout_occupation(f, base, loyers, "RC")
197 + fin_rows.append({"scenario": libelle, **{kk: f[kk] for kk in ("mise_de_fonds", "rpv", "pret", "taux", "amort_mois", "paiement_mensuel", "service_dette", "paiement_mensuel_tam")},
198 + "cout_net_mensuel": co["cout_net_mensuel"], "cout_net_mensuel_hors_capital": co["cout_net_mensuel_hors_capital"]})
199 + return {"loyers": loyers_tab, "taux": taux_tab, "financement": pd.DataFrame(fin_rows)}
200 +
201 +
202 +# ----------------------------------------------------------------------------- exercices (solutions/ch32.tex)
203 +def exercices() -> dict:
204 + ex = {}
205 + # 32.1 — duplex : MRB, RNE, taux, identité R = (1 − v)(1 − d)/MRB
206 + P, rbp, v, d = 585_000.0, 31_200.0, 0.03, 0.38
207 + rbe = fin.revenu_brut_effectif(rbp, v); rne = rbe * (1 - d)
208 + ex["1"] = {"prix": P, "rbp": rbp, "rbe": rbe, "rne": rne, "mrb": fin.multiplicateur_revenu_brut(P, rbp), "taux": fin.taux_global_extrait(rne, P),
209 + "identite": (1 - v) * (1 - d) / fin.multiplicateur_revenu_brut(P, rbp), "prix_par_logement": P / 2}
210 + # 32.2 — mise de fonds minimale assurée pour trois prix ; prêt ; au-delà du plafond
211 + ex["2"] = {str(p): {"mise": mise_de_fonds_minimale_assuree(p), "pret": p - mise_de_fonds_minimale_assuree(p) if p <= PLAFOND_ASSURABLE else float("nan"),
212 + "mise_pct": mise_de_fonds_minimale_assuree(p) / p} for p in (450_000.0, 843_000.0, 1_200_000.0, 1_600_000.0)}
213 + ex["2"]["mf20_1600000"] = 0.20 * 1_600_000
214 + # 32.3 — décomposition du taux : R_c = R_m − (1 − ρ)(1 − v)/MRB_m (dépenses fixes) et R_c = ρ R_m (dépenses proportionnelles)
215 + P3, lm, lc, n3, v3, dfix = 717_000.0, 1_430.0, 1_215.0, 3, 0.024, 17_500.0
216 + rbp_m, rbp_c = lm * n3 * 12, lc * n3 * 12; rho = rbp_c / rbp_m
217 + rne_m, rne_c = rbp_m * (1 - v3) - dfix, rbp_c * (1 - v3) - dfix
218 + ex["3"] = {"rho": rho, "rbp_m": rbp_m, "rbp_c": rbp_c, "rne_m": rne_m, "rne_c": rne_c, "R_m": rne_m / P3, "R_c": rne_c / P3, "mrb_m": P3 / rbp_m,
219 + "terme": (1 - rho) * (1 - v3) / (P3 / rbp_m), "R_c_prop": rho * rne_m / P3, "ecart_pb": (rne_m - rne_c) / P3 * 1e4}
220 + # 32.4 — coût d'occupation si l'acheteur reprend le 2e plutôt que le RC (cas 2, prix retenu, financement assuré minimal)
221 + r = cas2.tout(); base, loyers, sujet = r["base"], r["loyers"], r["sujet"]
222 + c = r["comparables"]; comp = selection_comparables(c, sujet, base); comp5 = comp[comp["retenu"]]
223 + g = decomposition_taux(c); reg = regression_taux_loyer_relatif(g); indic = indications(base, sujet, r["indicateurs"], comp5, reg, c)
224 + prix = indic["valeur_arrondie"]
225 + f = financement_occupant(prix, mise_de_fonds_minimale_assuree(prix), TAUX_ASSURE)
226 + ex["4"] = {"prix": prix, "financement": f, "RC": cout_occupation(f, base, loyers, "RC"), "2e": cout_occupation(f, base, loyers, "2e")}
227 + # 32.5 — régression taux ~ ρ sur les 127 ventes et application au sujet
228 + ex["5"] = {"reg": reg, "taux_ajuste": indic["taux_ajuste"], "v_cap_ajuste": indic["v_cap_ajuste"], "v_cap_127": indic["v_cap_127"],
229 + "taux_a_rho_1": reg["a"], "rho_sujet": indic["rho_sujet"], "taux_median": r["indicateurs"]["taux_median"]}
230 + # 32.6 — pondérations alternatives
231 + ret = indic["retenues"]
232 + alt = {"retenue (40/35/25)": POIDS_INDICATIONS, "égale (1/3 chacune)": {k: 1 / 3 for k in ret}, "revenu dominant (20/20/60)": {"par_logement": 0.2, "mrb": 0.2, "capitalisation": 0.6},
233 + "comparaison seule (50/50/0)": {"par_logement": 0.5, "mrb": 0.5, "capitalisation": 0.0}}
234 + ex["6"] = {"indications": ret, "ponderations": {lib: sum(w[k] * ret[k] for k in ret) for lib, w in alt.items()}}
235 + # 32.7 — valeur actualisée de l'écart de loyer du RC selon la date de reprise (r = 6 %, croissance TAL 3,1 %, marché 3,5 %)
236 + lc0, lm0, g_tal, g_m, rr = 1_047.0, 1_430.0, 0.031, 0.035, 0.06
237 + def va_ecart(annees_avant_reprise: int, horizon: int = 10) -> float:
238 + va = 0.0
239 + for t in range(1, horizon + 1):
240 + if t <= annees_avant_reprise:
241 + ecart = (lm0 * (1 + g_m) ** (t - 1) - lc0 * (1 + g_tal) ** (t - 1)) * 12
242 + va += ecart / (1 + rr) ** t
243 + return va
244 + ex["7"] = {"va_1an": va_ecart(1), "va_3ans": va_ecart(3), "va_10ans": va_ecart(10), "ecart_annee1": (lm0 - lc0) * 12}
245 + return ex
246 +
247 +
248 +# ----------------------------------------------------------------------------- tout
249 +def calculer() -> dict:
250 + r = cas2.tout()
251 + base, sujet, loyers, ind, c = r["base"], r["sujet"], r["loyers"], r["indicateurs"], r["comparables"]
252 + par_taille = indicateurs_par_groupe(c, "nb_logements"); par_quartier = indicateurs_par_groupe(c, "quartier")
253 + g = decomposition_taux(c); reg = regression_taux_loyer_relatif(g)
254 + comp = selection_comparables(c, sujet, base); comp5 = comp[comp["retenu"]]
255 + indic = indications(base, sujet, ind, comp5, reg, c)
256 + rbe_m = base["rbe_marche"]; d = base["detail"]
257 + dep_m = float(d.loc[~d["type"].isin(["imputée", "réserve"]), "normalise"].sum()) + cas2.GESTION_IMPUTEE * rbe_m
258 + rne_marche = fin.rne(rbe_m, dep_m, base["reserve"])
259 + prime = prime_occupant(indic["valeur_ponderee"], base["rne"], rne_marche)
260 + prix = indic["valeur_arrondie"]
261 + fi = financement_occupant(prix, mise_de_fonds_minimale_assuree(prix), TAUX_ASSURE)
262 + occ = cout_occupation(fi, base, loyers, "RC")
263 + sens = sensibilite(base, sujet, ind, reg, indic, loyers)
264 + return {**r, "par_taille": par_taille, "par_quartier": par_quartier, "decomposition": g, "regression": reg, "candidats": comp, "comparables5": comp5,
265 + "indications": indic, "rne_marche": rne_marche, "prime": prime, "financement": fi, "occupation": occ, "sensibilite": sens}
266 +
267 +
268 +def _d(x): return f"{x:,.0f} $".replace(",", " ")
269 +def _p(x, k=2): return f"{100 * x:.{k}f} %"
270 +
271 +
272 +if __name__ == "__main__":
273 + R = calculer(); b = R["base"]; ind = R["indicateurs"]; I = R["indications"]; reg = R["regression"]
274 + print("== Cas 2 — revenus et RNE (traite.cas2)")
275 + print(f"RBP contrat {_d(b['rbp_contrat'])} ; RBP marché {_d(b['rbp_marche'])} ; écart {_d(b['ecart_marche'])} ({_p(b['ecart_marche']/b['rbp_marche'],1)} du marché) ; ρ sujet = {I['rho_sujet']:.4f}")
276 + print(f"RBE {b['rbe']:.2f} ; dépenses hors réserve {b['depenses_hors_reserve']:.2f} ; réserve {b['reserve']:.0f} ; RNE {b['rne']:.4f} ; ratio {_p(b['ratio_depenses'],1)} ; RNE aux loyers de marché {R['rne_marche']:.2f}")
277 + print(R["base"]["detail"][["poste", "montant_annuel", "normalise"]].to_string(index=False))
278 + print("\n== 127 ventes de 2-3 logements (4 quartiers, 24 mois)")
279 + print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in ind.items()})
280 + print(R["par_taille"].round(4).to_string()); print(R["par_quartier"].round(4).to_string())
281 + g = R["decomposition"]
282 + print(f"ρ médian {g['loyer_relatif'].median():.3f} ; taux contrat médian {_p(g['taux_global'].median())} ; taux au marché médian {_p(g['taux_marche'].median())} ; MRB marché médian {g['mrb_marche'].median():.2f}")
283 + print(f"Régression taux = a + b(ρ−1) : a = {_p(reg['a'],3)}, b = {reg['b_rho']:.4f} (R² {reg['r2']:.3f}, σ {_p(reg['sigma'],3)}) ; avec état et taille : a = {_p(reg['a2'],3)}, b_ρ = {reg['b2_rho']:.4f}, b_état = {reg['b2_etat']:.5f}, b_n = {reg['b2_nb']:.5f}, R² {reg['r2_2']:.3f}")
284 + print(f"ln MRB = {reg['mrb_a']:.4f} + {reg['mrb_b_rho']:.4f}(ρ−1), R² {reg['mrb_r2']:.3f} → MRB à ρ=1 : {np.exp(reg['mrb_a']):.2f}")
285 + print("\n== Comparables triplex Limoilou (distance d'attributs)")
286 + cols = ["id_vente", "date_vente", "superficie_habitable_m2", "annee_construction", "etat_1a5", "stationnement", "loyer_mensuel_moyen_contrat", "loyer_mensuel_moyen_marche", "loyer_relatif", "revenu_brut_potentiel", "prix_vente", "mrb", "taux_global", "prix_par_logement", "prix_m2", "mois", "distance", "retenu"]
287 + print(f"candidats : {len(R['candidats'])}"); print(R["candidats"][cols].assign(date_vente=lambda x: x["date_vente"].dt.date).head(8).round(3).to_string(index=False))
288 + print("\n== Indications")
289 + for k, v in I.items():
290 + if k != "retenues": print(f"{k:22s} {v:,.4f}" if isinstance(v, float) else f"{k:22s} {v}")
291 + print("retenues", {k: round(v) for k, v in I["retenues"].items()}, "poids", POIDS_INDICATIONS)
292 + print("\n== Prime d'occupation (R* = 5 %)"); print({k: round(v, 4) for k, v in R["prime"].items()})
293 + print("\n== Financement de l'acheteur-occupant (prix arrondi)"); print({k: round(v, 4) for k, v in R["financement"].items()})
294 + print({k: (round(v, 2) if isinstance(v, float) else v) for k, v in R["occupation"].items()})
295 + print("\n== Sensibilité"); S = R["sensibilite"]
296 + print(S["loyers"].round(4).to_string(index=False)); print(S["taux"].round(2).to_string(index=False)); print(S["financement"].round(2).to_string(index=False))
297 + print("\n== Exercices"); E = exercices()
298 + for k, v in E.items(): print(k, v if k not in ("4", "5") else {kk: (vv if not isinstance(vv, dict) else {a: (round(bb, 4) if isinstance(bb, float) else bb) for a, bb in vv.items()}) for kk, vv in v.items()})
modified analysis/python/figures/ch12_figures.py +1 −1
@@ -55,7 +55,7 @@ def fig_erreur(r):
55 55 b.plot(100 * e, 100 * e, color=palette.cat[2], ls=":", lw=1.2, label="proportionnel (prix au m², β = 1)")
56 56 mel = r["mel"]
57 57 b.plot([mel["ecart_pct"]], [mel["sujet_brut_pct"]], "o", color=palette.ink, ms=4)
58 − b.annotate("sujet brut (190 m²)\nvs habitable (165 m²)", (mel["ecart_pct"], mel["sujet_brut_pct"]), xytext=(-8, -22), textcoords="offset points", ha="right", fontsize=7.2)
58 + b.annotate("sujet brut (190 m²)\nvs habitable (165 m²)", (mel["ecart_pct"], mel["sujet_brut_pct"]), xytext=(-10, -34), textcoords="offset points", ha="right", fontsize=7.2)
59 59 b.axhline(0, color=palette.ink2, lw=0.6); b.axvline(0, color=palette.ink2, lw=0.6)
60 60 b.set_xlabel("Erreur relative sur la superficie (%)"); b.set_ylabel("Erreur relative sur la valeur (%)")
61 61 b.legend(loc="lower right", fontsize=7.2); b.set_title("(b) Propagation : erreur sur S → erreur sur V", loc="left")
modified analysis/python/figures/ch14_figures.py +10 −7
@@ -23,13 +23,14 @@ def fig_avant_apres(R):
23 23 w = b[m]; ax.scatter(w["superficie_habitable_m2"], w["prix_vente"].clip(lower=1), s=9 if z == 1 else 16, color=col, alpha=0.8, label=f"{lab} (n = {len(w)})", zorder=z)
24 24 ax.set_yscale("log"); ax.set_xscale("log")
25 25 ax.set_xlabel("Superficie habitable saisie (m², échelle log)"); ax.set_ylabel("Prix déclaré ($, échelle log)")
26 − ax.set_title("Jeu brut : 618 inscriptions", loc="left"); ax.legend(fontsize=6.8, loc="lower right")
26 + ax.set_title("Jeu brut : 618 inscriptions", loc="left"); ax.legend(fontsize=6.8, loc="center left", bbox_to_anchor=(0.02, 0.5))
27 27 ax.annotate("prix symboliques (1 $)", (120, 1.6), fontsize=7, color=palette.ink2, xytext=(0, 8), textcoords="offset points")
28 28 ax.annotate("superficies en pi²", (1600, 3.5e5), fontsize=7, color=palette.ink2, ha="center", xytext=(0, 10), textcoords="offset points")
29 29 ax = axes[1]; d = P["propre"]
30 30 ax.scatter(d["superficie_habitable_m2"], d["prix_vente"], s=9, color=palette.cat[0], alpha=0.7)
31 31 ax.set_xlabel("Superficie habitable (m²)"); ax.set_ylabel("Prix net de meubles ($)")
32 32 ax.yaxis.set_major_formatter(FuncFormatter(fmt_dollars)); ax.set_title(f"Jeu nettoyé : {len(d)} ventes", loc="left")
33 + fig.subplots_adjust(wspace=0.42)
33 34 savefig(fig, "ch14/ch14-fig-avant-apres.pdf")
34 35
35 36 def fig_scores(R):
@@ -44,7 +45,7 @@ def fig_scores(R):
44 45 ax.set_title(titre, loc="left", fontsize=8.5); ax.set_xlabel("score (tronqué à ±8)"); ax.set_ylabel("ventes")
45 46 ax.text(0.02, 0.95, f"|z| > 3,5 : {n_fl} ventes\n(dont {nb_hors} au-delà de ±8)", transform=ax.transAxes, va="top", fontsize=7.5, color=palette.ink)
46 47 ax.set_xlim(-8.5, 8.5)
47 − fig.suptitle("Secteur Hull, ln du prix au m², après R1–R3 (n = %d, dont 2 prix à 1 $)" % len(x), fontsize=8.5, x=0.01, ha="left")
48 + fig.suptitle("Secteur Hull, ln du prix au m², après R1–R3 (n = %d, dont 2 prix à 1 $)" % len(x), fontsize=8.5, x=0.01, y=1.04, ha="left")
48 49 savefig(fig, "ch14/ch14-fig-scores-robustes.pdf")
49 50
50 51 def fig_levier_cook(R):
@@ -57,7 +58,7 @@ def fig_levier_cook(R):
57 58 for s in (-3, 3): ax.axhline(s, color=palette.ink2, ls="--", lw=0.9)
58 59 ax.axvline(2 * k / n, color=palette.ink2, ls=":", lw=0.9)
59 60 ax.text(2 * k / n, ax.get_ylim()[1] * 0.95 if False else 3.6, f" levier = 2k/n = {2*k/n:.3f}", fontsize=7.5, color=palette.ink2, va="top")
60 − ax.text(0.0, 3.1, " |t| = 3", fontsize=7.5, color=palette.ink2)
61 + ax.text(0.009, 2.55, "|t| = 3", fontsize=7.5, color=palette.ink2)
61 62 excl = E["exclus"]
62 63 for _, r in excl.iterrows():
63 64 ax.annotate(r["id_inscription"], (r["levier"], r["t_ext"]), xytext=(8, -4), textcoords="offset points", fontsize=7.5, color=palette.ink)
@@ -82,8 +83,8 @@ def fig_geocodage(R):
82 83 ypos = np.arange(len(tab))[::-1]
83 84 ax.barh(ypos + 0.18, tab["pct_voisins_conserves"], height=0.36, color=palette.cat[0], label="8 plus proches voisins conservés")
84 85 ax.barh(ypos - 0.18, 100 - tab["pct_rayon_change"], height=0.36, color=palette.cat[1], label="appartenance au rayon de 3 km inchangée")
85 − ax.set_yticks(ypos); ax.set_yticklabels([t.replace(" (", "\n(") for t in tab.index], fontsize=6.8)
86 − ax.set_xlabel("% des 600 ventes"); ax.set_xlim(0, 100); ax.legend(fontsize=6.6, loc="lower left")
86 + ax.set_yticks(ypos); ax.set_yticklabels([t.replace(" (", "\n(") for t in tab.index], fontsize=6.8); ax.yaxis.tick_right()
87 + ax.set_xlabel("% des 600 ventes"); ax.set_xlim(0, 100); ax.legend(fontsize=6.6, loc="upper center", bbox_to_anchor=(0.5, -0.22), ncol=1)
87 88 ax.set_title("Conséquences sur le voisinage", loc="left", fontsize=8.5)
88 89 savefig(fig, "ch14/ch14-fig-geocodage.pdf")
89 90
@@ -95,13 +96,15 @@ def fig_sensibilite(R):
95 96 w = T[T["seuil_t"] == zt]
96 97 ax.plot(w["seuil_mad"], w["faux_positifs"], marker="o", color=palette.cat[i], label=f"faux positifs, seuil $|t|$ = {zt}")
97 98 ax.plot(w["seuil_mad"], w["liees_detectees"], marker="s", ls="--", color=palette.cat[i], label=f"ventes liées détectées (sur 6), seuil $|t|$ = {zt}")
98 − ax.set_xlabel("Seuil du score robuste |z|"); ax.set_ylabel("Nombre de ventes"); ax.legend(fontsize=6.6); ax.set_title("Exclusions S1 + E1", loc="left", fontsize=8.5)
99 + ax.set_xlabel("Seuil du score robuste |z|"); ax.set_ylabel("Nombre de ventes"); ax.set_title("Exclusions S1 + E1", loc="left", fontsize=8.5)
100 + hnd, lab = ax.get_legend_handles_labels(); fig.legend(hnd, lab, loc="upper center", bbox_to_anchor=(0.5, -0.06), ncol=2, fontsize=6.8)
99 101 ax = axes[1]
100 102 for i, zt in enumerate([2.5, 3.0]):
101 103 w = T[T["seuil_t"] == zt]
102 104 ax.plot(w["seuil_mad"], w["sigma"], marker="o", color=palette.cat[i], label=f"seuil $|t|$ = {zt}")
103 − ax.axhline(0.0830, color=palette.ink2, ls=":", lw=0.9); ax.text(2.52, 0.0832, "σ du jeu propre (0,083)", fontsize=7, color=palette.ink2)
105 + ax.axhline(0.0830, color=palette.ink2, ls=":", lw=0.9); ax.text(3.05, 0.08255, "σ du jeu propre (0,083)", fontsize=7, color=palette.ink2)
104 106 ax.set_xlabel("Seuil du score robuste |z|"); ax.set_ylabel("σ du modèle hédonique"); ax.legend(fontsize=7); ax.set_title("Écart-type résiduel", loc="left", fontsize=8.5)
107 + fig.subplots_adjust(wspace=0.38)
105 108 savefig(fig, "ch14/ch14-fig-sensibilite-seuils.pdf")
106 109
107 110 def main():
added analysis/python/figures/ch15_figures.py +132 −0
@@ -0,0 +1,132 @@
1 +#!/usr/bin/env python3
2 +"""Figures du chapitre 15 — Études de marché et segmentation. Produit dans assets/figures/ch15/ :
3 + ch15-fig-mois-inventaire.pdf mois d'inventaire par RMR, T3 2026 (chiffres APCIQ cités ; calculs de l'auteur) — données réelles
4 + ch15-fig-dendrogramme.pdf dendrogramme de Ward des 600 ventes du cas 1 (attributs + coordonnées) — SYNTHÉTIQUE
5 + ch15-fig-silhouette.pdf silhouette moyenne selon k et jeu de variables ; profil de silhouette à k = 5 — SYNTHÉTIQUE
6 + ch15-fig-carte-segments.pdf carte fictive des segments k-moyennes (k = 2 et k = 5), petits multiples — SYNTHÉTIQUE
7 + ch15-fig-coefficients-secteurs.pdf coefficients hédoniques par secteur avec IC à 95 % (HC1) et coefficient commun — SYNTHÉTIQUE
8 + ch15-fig-loyers-rosemont.pdf structure des loyers et de l'univers locatif, zone SCHL 9 Rosemont vs RMR, oct. 2025 — données réelles
9 +Usage (racine) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch15_figures.py"""
10 +from __future__ import annotations
11 +import sys, pathlib
12 +ROOT = pathlib.Path(__file__).resolve().parents[3]
13 +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
14 +import numpy as np, pandas as pd
15 +import matplotlib.pyplot as plt
16 +from matplotlib.ticker import FuncFormatter
17 +from scipy.cluster.hierarchy import dendrogram
18 +from traite.style import setup, palette, savefig, LARGEUR, fmt_milliers
19 +from traite import hedonique as h
20 +import ch15_segmentation as c15
21 +
22 +def fig_mois_inventaire(r):
23 + ind = r["indicateurs"].copy()
24 + ind["court"] = ind["territoire"].str.replace("RMR de ", "", regex=False).str.replace("Province de Québec", "Province")
25 + ind = ind.sort_values("mois_inventaire", ascending=True)
26 + fig, ax = plt.subplots(figsize=(LARGEUR, 3.3))
27 + ax.axvspan(0, 4, color=palette.cat[1], alpha=0.08, lw=0); ax.axvspan(4, 6, color=palette.grid, alpha=0.35, lw=0); ax.axvspan(6, 8, color=palette.cat[2], alpha=0.08, lw=0)
28 + ax.barh(ind["court"], ind["mois_inventaire"], color=palette.brand, height=0.6)
29 + for y, (mi, dj) in enumerate(zip(ind["mois_inventaire"], ind["delai_unif_j"])):
30 + lab = f"{mi:.1f} mois" + (f" (délai unifam. {int(dj)} j)" if not np.isnan(dj) else "")
31 + ax.text(mi + 0.08, y, lab, va="center", ha="left", fontsize=8, color=palette.ink)
32 + ax.set_xlim(0, 8.6); ax.set_xlabel("Mois d'inventaire = inscriptions en vigueur / ventes mensuelles du trimestre")
33 + yt = len(ind) + 0.55
34 + ax.text(2, yt, "favorable aux vendeurs (< 4 mois, usage)", ha="center", va="center", fontsize=7.5, color=palette.ink2)
35 + ax.text(5, yt, "équilibré (4 à 6)", ha="center", va="center", fontsize=7.5, color=palette.ink2)
36 + ax.text(7.2, yt, "favorable aux acheteurs (> 6)", ha="center", va="center", fontsize=7.5, color=palette.ink2)
37 + ax.set_ylim(-0.6, len(ind) + 1.0); ax.grid(axis="y", visible=False)
38 + savefig(fig, "ch15/ch15-fig-mois-inventaire.pdf")
39 +
40 +def fig_dendrogramme(r):
41 + L = r["classification"]["linkage"]
42 + fig, ax = plt.subplots(figsize=(LARGEUR, 3.4))
43 + d = dendrogram(L, truncate_mode="lastp", p=30, ax=ax, color_threshold=0, above_threshold_color=palette.brand, leaf_rotation=90, leaf_font_size=7, no_labels=False)
44 + # hauteurs de coupe pour k = 2 et k = 5 (distance de fusion des dernières agrégations)
45 + hauteurs = L[:, 2]; n = L.shape[0] + 1
46 + for k, col, lab in [(2, palette.cat[2], "k = 2"), (5, palette.cat[1], "k = 5")]:
47 + y = 0.5 * (hauteurs[n - k - 1] + hauteurs[n - k])
48 + ax.axhline(y, color=col, ls="--", lw=1.1); ax.text(ax.get_xlim()[1] * 0.995, y, lab, ha="right", va="bottom", fontsize=8, color=col)
49 + ax.set_ylabel("Distance de fusion (critère de Ward)"); ax.set_xlabel("Grappes terminales (effectif entre parenthèses) — 600 ventes synthétiques")
50 + ax.grid(axis="x", visible=False)
51 + savefig(fig, "ch15/ch15-fig-dendrogramme.pdf")
52 +
53 +def fig_silhouette(r):
54 + v = r["ventes"]
55 + fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 3.2), gridspec_kw={"width_ratios": [1.1, 1]})
56 + ax = axes[0]
57 + for i, (jeu, lab) in enumerate([("attributs", "attributs seuls"), ("coordonnees", "coordonnées seules"), ("attributs+coordonnees", "attributs + coordonnées")]):
58 + Z = c15.caracteristiques_segmentation(v, jeu); t = c15.classifier(Z)["tableau"]
59 + ax.plot(t["k"], t["silhouette_kmeans"], marker="o", color=palette.cat[i], label=lab)
60 + ax.set_xlabel("Nombre de segments $k$ (k-moyennes)"); ax.set_ylabel("Silhouette moyenne $\\bar s$"); ax.set_ylim(0, 0.8); ax.legend(loc="lower left")
61 + ax.set_title("(a) silhouette moyenne selon $k$", loc="left")
62 + ax = axes[1]
63 + Z = r["Z"].values; lab5 = r["labels_kmeans5"]
64 + from sklearn.metrics import silhouette_samples
65 + s = silhouette_samples(Z, lab5); y0 = 0
66 + for g in range(5):
67 + sg = np.sort(s[lab5 == g])[::-1]; yy = np.arange(y0, y0 + len(sg))
68 + ax.barh(yy, sg, height=1.0, color=palette.cat[g], lw=0)
69 + ax.text(-0.02, y0 + len(sg) / 2, f"{g + 1}", ha="right", va="center", fontsize=8); y0 += len(sg) + 8
70 + ax.axvline(s.mean(), color=palette.ink2, ls="--", lw=1); ax.text(s.mean() + 0.01, y0, f"$\\bar s$ = {s.mean():.2f}", fontsize=8, va="top", color=palette.ink2)
71 + ax.set_xlim(-0.25, 0.75); ax.set_yticks([]); ax.set_xlabel("Silhouette $s(i)$ de chaque vente"); ax.set_title("(b) profil à $k$ = 5 (attributs + coordonnées)", loc="left")
72 + ax.grid(axis="y", visible=False)
73 + savefig(fig, "ch15/ch15-fig-silhouette.pdf")
74 +
75 +def fig_carte_segments(r):
76 + v = r["ventes"]; s = r["sujet"]
77 + xy = h.coordonnees_km(v["lat"].values, v["lon"].values); sxy = h.coordonnees_km(np.array([s["lat"]]), np.array([s["lon"]]), lat0=float(v["lat"].mean()), lon0=float(v["lon"].mean()))
78 + lab2 = r["labels_kmeans"]; lab5 = r["labels_kmeans5"]
79 + panneaux = [("(a) $k$ = 2, « Est »", lab2 == (np.bincount(lab2).argmin()))] + [(f"({c}) $k$ = 5, seg. {g + 1}", lab5 == g) for c, g in zip("bcdef", range(5))]
80 + fig, axes = plt.subplots(2, 3, figsize=(LARGEUR, 4.0), sharex=True, sharey=True)
81 + for ax, (titre, m) in zip(axes.ravel(), panneaux):
82 + ax.scatter(xy[~m, 0], xy[~m, 1], s=5, color=palette.grid, lw=0)
83 + ax.scatter(xy[m, 0], xy[m, 1], s=7, color=palette.brand, lw=0)
84 + ax.scatter(sxy[0, 0], sxy[0, 1], marker="*", s=70, color=palette.cat[2], edgecolor=palette.ink, lw=0.4, zorder=5)
85 + ax.set_title(f"{titre} ($n$ = {int(m.sum())})", loc="left", fontsize=8); ax.set_aspect("equal"); ax.grid(visible=False)
86 + fig.subplots_adjust(hspace=0.35, wspace=0.08)
87 + for ax in axes[1]: ax.set_xlabel("km (est)")
88 + for ax in axes[:, 0]: ax.set_ylabel("km (nord)")
89 + savefig(fig, "ch15/ch15-fig-carte-segments.pdf")
90 +
91 +def fig_coefficients_secteurs(r):
92 + ch = r["chow"]["secteur"]; ref = r["reference"]["coef"]
93 + vars_ = [("ln_S", "ln(superficie)"), ("qualite", "qualité (échelon)"), ("age", "âge (année)"), ("mois", "tendance (mois)")]
94 + ordre = ["Hull", "Gatineau", "Aylmer", "Buckingham", "Masson-Angers"]
95 + fig, axes = plt.subplots(1, 4, figsize=(LARGEUR, 2.9), sharey=True)
96 + for ax, (vv, titre) in zip(axes, vars_):
97 + for i, sct in enumerate(ordre):
98 + d = ch["par_segment"][sct]; b = d["coef"][vv]; se = d["se_hc1"][vv]
99 + ax.errorbar(b, i, xerr=1.96 * se, fmt="o", color=palette.brand, ecolor=palette.brand, capsize=2, ms=4, lw=1.1)
100 + ax.axvline(ref.loc[vv, "coef"], color=palette.cat[2], ls="--", lw=1.1)
101 + ax.set_title(titre, loc="left", fontsize=8.5); ax.grid(axis="y", visible=False)
102 + ax.ticklabel_format(axis="x", style="plain", useOffset=False)
103 + axes[0].set_yticks(range(len(ordre))); axes[0].set_yticklabels([f"{s} ($n$ = {ch['par_segment'][s]['n']})" for s in ordre]); axes[0].invert_yaxis()
104 + fig.text(0.5, -0.04, "Coefficient estimé par secteur (point) avec intervalle à 95 % (HC1) ; tireté : coefficient commun du modèle de référence", ha="center", fontsize=8, color=palette.ink2)
105 + savefig(fig, "ch15/ch15-fig-coefficients-secteurs.pdf")
106 +
107 +def fig_loyers_rosemont(r):
108 + Z9 = c15.SCHL_ZONE9; types = ["studio", "1 ch.", "2 ch.", "3 ch. +", "tous"]
109 + rmr = {"studio": 1005, "1 ch.": 1131, "2 ch.": 1346, "3 ch. +": 1626, "tous": 1290} # SCHL tableau 1.1.2, Montréal (RMR), 2025
110 + fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 3.2), gridspec_kw={"width_ratios": [1.25, 1]})
111 + ax = axes[0]; x = np.arange(len(types)); w = 0.38
112 + b1 = ax.bar(x - w / 2, [Z9["loyer_2025"][t] for t in types], w, color=palette.brand, label="zone 9 Rosemont/La Petite-Patrie")
113 + b2 = ax.bar(x + w / 2, [rmr[t] for t in types], w, color=palette.cat[1], label="RMR de Montréal")
114 + for bars in (b1, b2):
115 + for b in bars: ax.text(b.get_x() + b.get_width() / 2, b.get_height() + 25, fmt_milliers(b.get_height()), ha="center", va="bottom", fontsize=6.5, rotation=90, color=palette.ink)
116 + ax.set_xticks(x); ax.set_xticklabels(types); ax.set_ylabel("Loyer moyen, octobre 2025 ($/mois)"); ax.set_ylim(0, 2300); ax.legend(loc="upper left", fontsize=7.5)
117 + ax.set_title("(a) loyer moyen par taille de logement", loc="left"); ax.grid(axis="x", visible=False)
118 + ax = axes[1]; t4 = types[:4]; u = [Z9["univers_2025"][t] for t in t4]; tot = Z9["univers_2025"]["tous"]
119 + bars = ax.bar(t4, u, color=palette.brand, width=0.6)
120 + for b, val in zip(bars, u): ax.text(b.get_x() + b.get_width() / 2, val + 300, f"{fmt_milliers(val)}\n({100 * val / tot:.0f} %)", ha="center", va="bottom", fontsize=7.5)
121 + ax.set_ylim(0, 27000); ax.set_ylabel("Logements (univers ELL)"); ax.yaxis.set_major_formatter(FuncFormatter(fmt_milliers))
122 + ax.set_title(f"(b) univers zone 9 : {fmt_milliers(tot)} log.", loc="left"); ax.grid(axis="x", visible=False)
123 + fig.subplots_adjust(wspace=0.35)
124 + savefig(fig, "ch15/ch15-fig-loyers-rosemont.pdf")
125 +
126 +def main():
127 + setup(); r = c15.tout()
128 + fig_mois_inventaire(r); fig_dendrogramme(r); fig_silhouette(r); fig_carte_segments(r); fig_coefficients_secteurs(r); fig_loyers_rosemont(r)
129 + print("Figures du chapitre 15 écrites dans", ROOT / "assets/figures/ch15")
130 +
131 +if __name__ == "__main__":
132 + main()
modified analysis/python/figures/ch22_ecarts.py +1 −1
@@ -45,7 +45,7 @@ def main():
45 45 ax.text(r["ecart"] * 100 + (0.6 if r["ecart"] >= 0 else -0.6), k, f"{r['ecart']*100:+.1f} %", va="center",
46 46 ha="left" if r["ecart"] >= 0 else "right", fontsize=7.2, color=palette.ink)
47 47 ax.set_yticks(y); ax.set_yticklabels([COURT.get(p, p) for p in V["poste"]], fontsize=7.6)
48 − ax.set_xlim(-22, 26); ax.set_xlabel("Écart du métré de contrôle par rapport au devis (%) : (quantité recalculée − quantité du devis) / quantité du devis")
48 + ax.set_xlim(-22, 26); ax.set_xlabel("Écart du métré de contrôle par rapport au devis (%) : (quantité recalculée − quantité du devis) / devis")
49 49 ax.grid(axis="y", visible=False)
50 50 # séparateurs de corps
51 51 prev = None
added analysis/python/figures/ch23_ipcb_divisions.py +38 −0
@@ -0,0 +1,38 @@
1 +#!/usr/bin/env python3
2 +"""Figure ch23 — IPCB Montréal, maison individuelle, par division de travaux (Statistique Canada 18-10-0289-01), données RÉELLES :
3 +variation de l'indice de chaque division entre le T1 2020 et le T4 2022 (flambée) et entre le T3 2025 et le T3 2026 (dernière année).
4 +Les divisions sont des indices de prix, non des contributions pondérées (les poids du panier ne sont pas publiés dans le tableau).
5 +Sortie : assets/figures/ch23/ch23-fig-ipcb-divisions.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 ch23_structure_couts as cas
12 +
13 +COURT = {"Bois, plastiques et composites": "Bois, plastiques, composites", "Chauffage, ventilation et conditionnement d’air": "CVCA",
14 + "Isolation thermique et étanchéité": "Isolation et étanchéité", "Sécurité et protection électronique": "Sécurité électronique",
15 + "Services d’utilités": "Services d'utilités", "Agrégat des divisions": "Ensemble (agrégat)"}
16 +
17 +def main():
18 + setup()
19 + df_div = cas.ipcb_long(("Montréal, Québec",), ("Maison individuelle [621112]",), None)
20 + dv = cas.divisions_maison(df_div).iloc[::-1]
21 + y = np.arange(len(dv)); h = 0.38
22 + fig, ax = plt.subplots(figsize=(LARGEUR, 4.6))
23 + ax.barh(y + h / 2, dv["var_2020_2022"] * 100, height=h, color=palette.cat[0], label="T1 2020 → T4 2022")
24 + ax.barh(y - h / 2, dv["var_aa"] * 100, height=h, color=palette.cat[1], label="T3 2025 → T3 2026")
25 + ax.set_yticks(y); ax.set_yticklabels([COURT.get(d, d) for d in dv["division"]], fontsize=7.5)
26 + for yi, (a, b, d) in enumerate(zip(dv["var_2020_2022"], dv["var_aa"], dv["division"])):
27 + ax.text(a * 100 + 1, yi + h / 2, f"{a*100:+.0f} %", va="center", fontsize=6.8, color=palette.ink)
28 + ax.text(b * 100 + 1, yi - h / 2, f"{b*100:+.1f} %", va="center", fontsize=6.8, color=palette.ink)
29 + if d == "Agrégat des divisions":
30 + ax.get_yticklabels()[yi].set_fontweight("bold")
31 + ax.axvline(0, color=palette.ink2, lw=0.6)
32 + ax.set_xlabel("Variation de l'indice de division (%)"); ax.set_xlim(-2, 100)
33 + ax.legend(loc="lower right", fontsize=7.5)
34 + ax.grid(axis="y", visible=False)
35 + out = savefig(fig, "ch23/ch23-fig-ipcb-divisions.pdf"); print("OK", out)
36 +
37 +if __name__ == "__main__":
38 + main()
added analysis/python/figures/ch23_ipcb_types.py +44 −0
@@ -0,0 +1,44 @@
1 +#!/usr/bin/env python3
2 +"""Figure ch23 — IPCB (Statistique Canada 18-10-0289-01, 2023 = 100), données RÉELLES lues dans data/raw :
3 +(a) variation sur quatre trimestres des séries résidentielles de Montréal par type d'immeuble, T1 2018 → T3 2026 ;
4 +(b) niveau de l'indice « bâtiments résidentiels » : Montréal, Québec (depuis T1 2023), Ottawa–Gatineau (partie ontarienne)
5 + et composite de quinze RMR, T1 2017 → T3 2026.
6 +Sortie : assets/figures/ch23/ch23-fig-ipcb-types.pdf"""
7 +import sys, pathlib
8 +ROOT = pathlib.Path(__file__).resolve().parents[3]
9 +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
10 +import matplotlib.pyplot as plt, matplotlib.dates as mdates, pandas as pd
11 +from traite.style import setup, palette, savefig, LARGEUR
12 +import ch23_structure_couts as cas
13 +
14 +TYPES = {"Maison individuelle [621112]": "Maison individuelle", "Maison en rangée [621112]": "Maison en rangée",
15 + "Immeuble à hauteur restreinte (moins de cinq étages) [62111]": "Immeuble < 5 étages", "Tour d'habitations (cinq étages ou plus) [621111]": "Tour (≥ 5 étages)"}
16 +GEOS = {"Montréal, Québec": "Montréal", "Québec, Québec": "Québec (depuis 2023)", "Ottawa–Gatineau, partie ontarienne, Ontario/Québec": "Ottawa–Gatineau (partie ont.)",
17 + "Composite de quinze régions métropolitaines de recensement": "Composite 15 RMR"}
18 +
19 +def main():
20 + setup()
21 + df = cas.variations_annuelles(cas.ipcb_long())
22 + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.4), gridspec_kw={"width_ratios": [1.05, 1]})
23 + m = df[(df["geo"] == "Montréal, Québec") & (df["date"] >= "2018-01-01")]
24 + for i, (t, lab) in enumerate(TYPES.items()):
25 + s = m[m["type"] == t]
26 + ax1.plot(s["date"], s["var_aa"] * 100, color=palette.cat[i], lw=2.0 if t.startswith("Maison indiv") else 1.3, label=lab)
27 + ax1.axhline(0, color=palette.ink2, lw=0.6)
28 + ax1.set_ylabel("Variation sur quatre trimestres (%)"); ax1.set_title("(a) Montréal, par type d'immeuble", loc="left")
29 + ax1.legend(loc="upper right", fontsize=7.5)
30 + ax1.xaxis.set_major_locator(mdates.YearLocator(2)); ax1.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
31 + r = df[(df["type"] == "Bâtiments résidentiels [621]") & (df["date"] >= "2017-01-01")]
32 + for i, (g, lab) in enumerate(GEOS.items()):
33 + s = r[r["geo"] == g]
34 + ax2.plot(s["date"], s["indice"], color=palette.cat[i], lw=2.0 if g.startswith("Montréal") else 1.3, ls="--" if g.startswith("Composite") else "-", label=lab)
35 + ax2.axhline(100, color=palette.ink2, lw=0.6, ls=":"); ax2.text(pd.Timestamp("2017-03-01"), 101.5, "2023 = 100", fontsize=7, color=palette.ink2)
36 + ax2.set_ylabel("Indice, bâtiments résidentiels (2023 = 100)"); ax2.set_title("(b) Résidentiel, par région", loc="left")
37 + ax2.legend(loc="upper left", fontsize=7.5)
38 + ax2.xaxis.set_major_locator(mdates.YearLocator(2)); ax2.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
39 + ax2.set_ylim(50, 130)
40 + fig.tight_layout(w_pad=1.5)
41 + out = savefig(fig, "ch23/ch23-fig-ipcb-types.pdf"); print("OK", out)
42 +
43 +if __name__ == "__main__":
44 + main()
added analysis/python/figures/ch23_sensibilite.py +40 −0
@@ -0,0 +1,40 @@
1 +#!/usr/bin/env python3
2 +"""Figure ch23 — sensibilité (DONNÉES SYNTHÉTIQUES, cas 1 ; taux documenté Banque du Canada V80691335) :
3 +(a) intérêts intérimaires selon la durée du chantier et le taux (tirage uniforme, L·i·T/2) ;
4 +(b) tornade du coût neuf : durée, taux intérimaire, indirects hors financement, hausse salariale (λ = 40 %), incitatif.
5 +Sortie : assets/figures/ch23/ch23-fig-sensibilite.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_milliers
12 +import ch21_cout_neuf as c21, ch23_structure_couts as cas
13 +
14 +def main():
15 + setup()
16 + devis, sujet = c21.charger(); cn = c21.cout_neuf_sujet(devis); i_aff = cas.taux_affiche_5ans()
17 + fc = cas.financement_cas1(devis, cn, i_aff); base = fc["base_hors_financement"]
18 + mois = np.arange(3, 25); taux = [0.04, 0.05, i_aff, 0.07, 0.09]
19 + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.4), gridspec_kw={"width_ratios": [1, 1.25]})
20 + for k, t in enumerate(taux):
21 + ax1.plot(mois, [cas.fin.interets_intermediaires(base, t, m / 12)["interets"] / 1000 for m in mois], color=palette.cat[k], lw=2.0 if abs(t - i_aff) < 1e-6 else 1.2, label=f"{t*100:.2f} %".replace(".", ","))
22 + ax1.axhline(fc["forfait"] / 1000, color=palette.ink2, lw=0.8, ls="--"); ax1.text(3.2, fc["forfait"] / 1000 + 0.3, f"forfait du devis {fc['forfait']/1000:.1f} k$".replace(".", ","), fontsize=6.8, color=palette.ink2, ha="left", va="bottom")
23 + ax1.set_xlabel("Durée du chantier (mois)"); ax1.set_ylabel("Intérêts intérimaires (k$)"); ax1.set_title("(a) Intérêts selon durée et taux", loc="left")
24 + ax1.legend(title="Taux annuel", fontsize=7, title_fontsize=7, loc="upper left")
25 + torn = cas.tornade(devis, cn, i_aff); base_cn = torn[0]["base"]
26 + torn = sorted(torn, key=lambda o: abs(o["haut"] - o["bas"]))
27 + y = np.arange(len(torn))
28 + for yi, o in zip(y, torn):
29 + lo, hi = min(o["bas"], o["haut"]), max(o["bas"], o["haut"])
30 + ax2.barh(yi, (hi - lo) / 1000, left=lo / 1000, color=palette.cat[0], height=0.55)
31 + ax2.text(lo / 1000 - 1, yi, f"{lo/1000:.0f}", ha="right", va="center", fontsize=7); ax2.text(hi / 1000 + 1, yi, f"{hi/1000:.0f}", ha="left", va="center", fontsize=7)
32 + ax2.axvline(base_cn / 1000, color=palette.ink2, lw=0.8); ax2.text(base_cn / 1000, -0.95, f"base {base_cn/1000:.1f} k$".replace(".", ","), ha="center", va="bottom", fontsize=7, color=palette.ink2); ax2.set_ylim(-1.1, len(torn) - 0.5)
33 + ax2.set_yticks(y); ax2.set_yticklabels([f"{o['param']}\n{o['etiquette']}" for o in torn], fontsize=7)
34 + ax2.set_xlabel("Coût neuf (k$)"); ax2.set_title("(b) Tornade du coût neuf", loc="left"); ax2.set_xlim(365, 430)
35 + ax2.xaxis.set_major_formatter(FuncFormatter(fmt_milliers)); ax2.grid(axis="y", visible=False)
36 + fig.tight_layout(w_pad=2.0)
37 + out = savefig(fig, "ch23/ch23-fig-sensibilite.pdf"); print("OK", out)
38 +
39 +if __name__ == "__main__":
40 + main()
added analysis/python/figures/ch23_structure.py +35 −0
@@ -0,0 +1,35 @@
1 +#!/usr/bin/env python3
2 +"""Figure ch23 — cascade de formation du coût neuf du sujet du cas 1 (DONNÉES SYNTHÉTIQUES, graine 20261031) :
3 +coûts directs par corps de métier, puis chaque poste indirect, l'incitatif, puis TPS et TVQ sur le bâtiment seul
4 +(sans remboursement : la JVM du bien, terrain compris, dépasse les seuils). Sortie : assets/figures/ch23/ch23-fig-structure-couts.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 matplotlib.pyplot as plt
9 +from matplotlib.ticker import FuncFormatter
10 +from traite.style import setup, palette, savefig, LARGEUR, fmt_milliers
11 +import ch21_cout_neuf as c21, ch23_structure_couts as cas
12 +
13 +def main():
14 + setup()
15 + devis, sujet = c21.charger(); cn = c21.cout_neuf_sujet(devis); g = c21.regrouper(devis); ri = cas.ratios_indirects(devis, cn)
16 + etapes = [(k, float(g.loc[k, "montant"]), palette.cat[0]) for k in c21.ORDRE_CORPS if k != "Coûts indirects"]
17 + etapes += [(p.replace(" (salaires)", "").replace(" (arpentage, sol)", ""), float(m), palette.cat[1]) for p, m in zip(ri["poste"], ri["montant"])]
18 + etapes += [("Incitatif (10 % de D+I)", cn["incitatif"], palette.cat[2]), ("TPS 5 %", cn["total"] * cas.TPS, palette.cat[3]), ("TVQ 9,975 %", cn["total"] * cas.TVQ, palette.cat[3])]
19 + fig, ax = plt.subplots(figsize=(LARGEUR, 4.3))
20 + cum = 0.0; labels = []
21 + for i, (lab, m, col) in enumerate(etapes):
22 + ax.barh(i, m / 1000, left=cum / 1000, color=col, height=0.7)
23 + ax.text((cum + m) / 1000 + 3, i, f"{m/1000:,.1f}".replace(",", " "), va="center", fontsize=6.8, color=palette.ink)
24 + cum += m; labels.append(lab)
25 + for lvl, nom in [(cn["direct"], f"D = {cn['direct']/1000:.1f} k$"), (cn["direct"] + cn["indirects"], f"D + I = {(cn['direct']+cn['indirects'])/1000:.1f} k$"),
26 + (cn["total"], f"C_N = {cn['total']/1000:.1f} k$"), (cn["total"] * (1 + cas.TPS + cas.TVQ), f"TTC = {cn['total']*(1+cas.TPS+cas.TVQ)/1000:.1f} k$")]:
27 + ax.axvline(lvl / 1000, color=palette.ink2, lw=0.6, ls=":")
28 + ax.text(lvl / 1000, -0.75, nom.split(" = ")[0], ha="center", va="bottom", fontsize=7, color=palette.ink2)
29 + ax.set_yticks(range(len(etapes))); ax.set_yticklabels(labels, fontsize=7.5); ax.set_ylim(len(etapes) - 0.5, -1.6)
30 + ax.set_xlabel("Milliers de dollars (cumul)"); ax.xaxis.set_major_formatter(FuncFormatter(fmt_milliers)); ax.set_xlim(0, 490)
31 + ax.grid(axis="y", visible=False)
32 + out = savefig(fig, "ch23/ch23-fig-structure-couts.pdf"); print("OK", out)
33 +
34 +if __name__ == "__main__":
35 + main()
added analysis/python/figures/ch24_composantes.py +40 −0
@@ -0,0 +1,40 @@
1 +#!/usr/bin/env python3
2 +"""Figure ch24 — composantes de courte durée du sujet du cas 1 : coût neuf et dépréciation ventilée par composante
3 +(fiche d'état du ch. 11, DONNÉES SYNTHÉTIQUES, graine 20261031). Sortie : assets/figures/ch24/ch24-fig-composantes.pdf"""
4 +import sys, pathlib
5 +ROOT = pathlib.Path(__file__).resolve().parents[3]
6 +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
7 +import matplotlib.pyplot as plt
8 +from matplotlib.patches import Patch
9 +from traite.style import setup, palette, savefig, LARGEUR, fmt_dollars
10 +import ch24_depreciation as cas
11 +
12 +
13 +def main():
14 + setup()
15 + d = cas.charger()
16 + var = cas.variantes_physique(d["cn"], d["fiche"], d["travaux"])
17 + ve = var["ventilee"]; f = d["fiche"].set_index("composante")
18 + noms = list(ve["courte_duree"].keys())
19 + couts = [float(f.loc[n, "cout_neuf"]) for n in noms]; deps = [ve["courte_duree"][n] for n in noms]
20 + ordre = sorted(range(len(noms)), key=lambda i: couts[i])
21 + fig, ax = plt.subplots(figsize=(LARGEUR, 4.3))
22 + y = list(range(len(ordre)))
23 + ax.barh(y, [couts[i] for i in ordre], color=palette.seq[0], height=0.7)
24 + ax.barh(y, [deps[i] for i in ordre], color=palette.cat[1], height=0.7)
25 + etiquettes = [n.replace(" (brique et fibrociment)", "").replace(" (entrée 200 A, filage cuivre, éclairage)", "").replace(" (PVC, double vitrage)", " (PVC)")
26 + .replace("Ventilateur récupérateur de chaleur (VRC)", "VRC").replace(" (2 salles de bain)", "").replace(" (bardeaux d'asphalte)", "") for n in noms]
27 + ax.set_yticks(y); ax.set_yticklabels([etiquettes[i] for i in ordre], fontsize=7.5)
28 + for k, i in enumerate(ordre):
29 + ax.text(couts[i] + 500, k, f"{fmt_dollars(deps[i])} ({deps[i]/couts[i]*100:.0f} %)".replace("$", r"\$"), va="center", fontsize=7, color=palette.ink)
30 + ax.set_xlim(0, max(couts) * 1.35); ax.xaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars))
31 + ax.set_xlabel("Coût neuf de la composante (dollars de 2026) et dépréciation imputée"); ax.grid(axis="y", visible=False)
32 + tot_c = sum(couts); tot_d = sum(deps)
33 + ax.set_title(f"18 composantes : coût neuf {fmt_dollars(tot_c)}, dépréciation {fmt_dollars(tot_d)} ({tot_d/tot_c*100:.1f} %)".replace("$", r"\$"), loc="left", fontsize=8.5)
34 + ax.legend(handles=[Patch(color=palette.seq[0], label="Coût neuf (devis majoré)"), Patch(color=palette.cat[1], label="Dépréciation (âge effectif / vie utile)")],
35 + loc="lower right", fontsize=7.5)
36 + out = savefig(fig, "ch24/ch24-fig-composantes.pdf"); print("OK", out)
37 +
38 +
39 +if __name__ == "__main__":
40 + main()
added analysis/python/figures/ch24_courbes.py +58 −0
@@ -0,0 +1,58 @@
1 +#!/usr/bin/env python3
2 +"""Figure ch24 — courbes de dépréciation du bâtiment en fonction de l'âge (DONNÉES SYNTHÉTIQUES, cas 1, graine 20261031) :
3 +(a) taux extraits du marché pour les 600 ventes, médianes par tranche d'âge et ajustement quadratique ;
4 +(b) profils comparés : âge-vie linéaire (60 ans), extraction, courbe hédonique et profil « vrai » du générateur convertis en
5 +dépréciation du bâtiment (part du terrain α), avec les deux lectures du sujet à 28 ans.
6 +Sortie : assets/figures/ch24/ch24-fig-courbes.pdf"""
7 +import sys, pathlib
8 +ROOT = pathlib.Path(__file__).resolve().parents[3]
9 +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
10 +import numpy as np, matplotlib.pyplot as plt
11 +from traite.style import setup, palette, savefig, LARGEUR
12 +import ch24_depreciation as cas
13 +
14 +
15 +def main():
16 + setup()
17 + d = cas.charger()
18 + w, vl = cas.extraction(d["ventes"], d["modele"], d["cn"]["total"])
19 + aj = cas.ajuster_profil(w); q = aj["quadratique"]
20 + vlr = cas.valeur_terrain_retenue(vl)
21 + pr = cas.profils(d["modele"], d["sujet"], vlr["retenue"], q)
22 + var = cas.variantes_physique(d["cn"], d["fiche"], d["travaux"])
23 + fig, (a, b) = plt.subplots(1, 2, figsize=(LARGEUR, 3.1), gridspec_kw={"wspace": 0.28})
24 + # (a) nuage + médianes par tranche + ajustement quadratique
25 + a.scatter(w["age"], w["taux"] * 100, s=7, color=palette.cat[0], alpha=0.25, linewidths=0, label="Ventes (600)")
26 + tr = cas.tranches(w)
27 + mids = [5, 15, 25, 35, 45, 55, 75]
28 + a.plot(mids, tr["median"].values * 100, "o", color=palette.cat[1], ms=5, label="Médiane par tranche de 10 ans")
29 + ages = np.arange(0, 100)
30 + a.plot(ages, cas.taux_quadratique(q, ages) * 100, color=palette.cat[1], lw=1.8, label="Ajustement quadratique")
31 + a.plot(ages, np.minimum(ages / 60, 1) * 100, color=palette.ink2, lw=1.2, ls="--", label="Âge-vie linéaire (60 ans)")
32 + a.set_xlim(0, 100); a.set_ylim(-30, 100)
33 + a.set_xlabel("Âge chronologique (ans)"); a.set_ylabel("Dépréciation extraite (% du coût neuf)")
34 + a.set_title("(a) Extraction de marché, 600 ventes", loc="left", fontsize=9)
35 + a.legend(loc="upper left", fontsize=7)
36 + # (b) profils comparés
37 + ag = pr["ages"]
38 + b.plot(ag, pr["age_vie"] * 100, color=palette.ink2, lw=1.2, ls="--", label="Âge-vie linéaire (60 ans)")
39 + b.plot(ag, pr["extraction"] * 100, color=palette.cat[1], lw=1.8, label="Extraction de marché (quadratique)")
40 + b.plot(ag, pr["hedonique_bat"] * 100, color=palette.cat[0], lw=1.8, label="Hédonique, converti au bâtiment")
41 + b.plot(ag, pr["generateur_bat"] * 100, color=palette.cat[2], lw=1.6, ls=":", label="Profil du générateur, converti")
42 + b.legend(loc="upper left", fontsize=7)
43 + # sujet à 28 ans
44 + b.plot([28], [var["ventilee"]["taux"] * 100], "D", color=palette.cat[5], ms=6)
45 + b.annotate(f"Sujet, ventilée : {var['ventilee']['taux']*100:.1f} %", (28, var["ventilee"]["taux"] * 100), xytext=(40, 58), fontsize=7.5, color=palette.ink,
46 + arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6))
47 + t28 = float(cas.taux_quadratique(q, 28))
48 + b.plot([28], [t28 * 100], "o", color=palette.cat[1], ms=6)
49 + b.annotate(f"Sujet, extraction : {t28*100:.1f} %", (28, t28 * 100), xytext=(36, 6), fontsize=7.5, color=palette.ink,
50 + arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6))
51 + b.set_xlim(0, 82); b.set_ylim(0, 100)
52 + b.set_xlabel("Âge (ans)"); b.set_ylabel("Dépréciation du bâtiment (% du coût neuf)")
53 + b.set_title(f"(b) Profils comparés (part du terrain α = {pr['alpha']:.2f})", loc="left", fontsize=9)
54 + out = savefig(fig, "ch24/ch24-fig-courbes.pdf"); print("OK", out)
55 +
56 +
57 +if __name__ == "__main__":
58 + main()
added analysis/python/figures/ch24_decomposition.py +60 −0
@@ -0,0 +1,60 @@
1 +#!/usr/bin/env python3
2 +"""Figure ch24 — décomposition de la valeur par le coût du sujet du cas 1 (DONNÉES SYNTHÉTIQUES, graine 20261031) :
3 +(a) lecture ventilée (ch. 11) : coût neuf − curable − courte durée − longue durée − désuétude fonctionnelle ;
4 +(b) lecture retenue : coût neuf − dépréciation extraite du marché − désuétude fonctionnelle + valeur du terrain = indication.
5 +Sortie : assets/figures/ch24/ch24-fig-decomposition.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 matplotlib.pyplot as plt
10 +from traite.style import setup, palette, savefig, LARGEUR, fmt_dollars
11 +
12 +esc = lambda t: t.replace("$", r"\$")
13 +import ch24_depreciation as cas
14 +
15 +
16 +def cascade(ax, etapes, titre):
17 + """etapes : liste de (nom, montant, type) avec type ∈ {'total', 'moins', 'plus', 'resultat'}."""
18 + cum = 0.0; xs = [];
19 + for i, (nom, m, t) in enumerate(etapes):
20 + if t == "total":
21 + ax.bar(i, m, color=palette.cat[0], width=0.62); cum = m; top = m
22 + elif t == "moins":
23 + ax.bar(i, -m, bottom=cum, color=palette.cat[1], width=0.62); cum -= m; top = cum + m
24 + elif t == "plus":
25 + ax.bar(i, m, bottom=cum, color=palette.cat[2], width=0.62); cum += m; top = cum
26 + else:
27 + ax.bar(i, m, color=palette.cat[4], width=0.62); top = m
28 + signe = "−" if t == "moins" else ("+" if t == "plus" else "")
29 + ax.text(i, top + 9000, esc(f"{signe}{fmt_dollars(m)}"), ha="center", va="bottom", fontsize=7, color=palette.ink)
30 + xs.append(nom)
31 + if i < len(etapes) - 1 and t != "resultat":
32 + ax.plot([i + 0.31, i + 1 - 0.31], [cum, cum], color=palette.ink2, lw=0.6)
33 + ax.set_xticks(range(len(etapes))); ax.set_xticklabels(xs, fontsize=7.5, rotation=0)
34 + ax.yaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars))
35 + ax.set_ylim(0, 760000); ax.grid(axis="x", visible=False)
36 + ax.set_title(esc(titre), loc="left", fontsize=9)
37 +
38 +
39 +def main():
40 + setup()
41 + d = cas.charger()
42 + var = cas.variantes_physique(d["cn"], d["fiche"], d["travaux"])
43 + se = cas.carence_salle_eau(d["devis"], d["cn"])
44 + w, vl = cas.extraction(d["ventes"], d["modele"], d["cn"]["total"])
45 + q = cas.ajuster_profil(w)["quadratique"]; vlr = cas.valeur_terrain_retenue(vl)
46 + vs = cas.valeur_sujet(d["cn"], vlr["retenue"], q, se["desuetude"], var)
47 + ve = var["ventilee"]; cn = d["cn"]["total"]
48 + fig, (a, b) = plt.subplots(2, 1, figsize=(LARGEUR, 5.6), gridspec_kw={"hspace": 0.42})
49 + cascade(a, [("Coût neuf", cn, "total"), ("Curable", ve["curable"], "moins"), ("Courte durée", sum(ve["courte_duree"].values()), "moins"),
50 + ("Longue durée", ve["longue_duree"], "moins"), ("Fonctionnelle", se["desuetude"], "moins"),
51 + ("Contribution\ndu bâtiment", vs["A_ventilee"]["contribution_batiment"], "resultat")],
52 + f"(a) Lecture ventilée : dépréciation totale {vs['A_ventilee']['taux_depreciation']*100:.1f} %")
53 + cascade(b, [("Coût neuf", cn, "total"), ("Extraite (28 ans)", vs["d_extraite"], "moins"), ("Fonctionnelle", se["desuetude"], "moins"),
54 + ("Terrain", vlr["retenue"], "plus"), ("Indication\npar le coût", vs["B_extraction"]["valeur"], "resultat")],
55 + f"(b) Lecture retenue : dépréciation {vs['B_extraction']['taux_depreciation']*100:.1f} %, valeur {fmt_dollars(vs['valeur_arrondie'])}")
56 + out = savefig(fig, "ch24/ch24-fig-decomposition.pdf"); print("OK", out)
57 +
58 +
59 +if __name__ == "__main__":
60 + main()
added analysis/python/figures/ch24_sensibilite.py +47 −0
@@ -0,0 +1,47 @@
1 +#!/usr/bin/env python3
2 +"""Figure ch24 — sensibilité (DONNÉES SYNTHÉTIQUES, cas 1, graine 20261031) : (a) taux de dépréciation âge-vie modifiée selon la vie
3 +économique et l'âge effectif ; (b) tornade de l'indication par le coût du sujet. Sortie : assets/figures/ch24/ch24-fig-sensibilite.pdf"""
4 +import sys, pathlib
5 +ROOT = pathlib.Path(__file__).resolve().parents[3]
6 +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
7 +import numpy as np, matplotlib.pyplot as plt
8 +from traite.style import setup, palette, savefig, LARGEUR, fmt_dollars
9 +import ch24_depreciation as cas
10 +
11 +
12 +def main():
13 + setup()
14 + d = cas.charger()
15 + var = cas.variantes_physique(d["cn"], d["fiche"], d["travaux"])
16 + se = cas.carence_salle_eau(d["devis"], d["cn"])
17 + w, vl = cas.extraction(d["ventes"], d["modele"], d["cn"]["total"])
18 + q = cas.ajuster_profil(w)["quadratique"]; vlr = cas.valeur_terrain_retenue(vl)
19 + vs = cas.valeur_sujet(d["cn"], vlr["retenue"], q, se["desuetude"], var)
20 + sx = cas.sensibilite_extraction(d["ventes"], d["modele"], d["cn"]["total"])
21 + to = cas.tornade(d["cn"], vlr["retenue"], vs["d_extraite"], se["desuetude"], var, sx)
22 + vies = (50, 60, 70, 80, 100); ages = (20, 25, 29, 33, 37)
23 + G = cas.grille_age_vie(d["cn"], var["curable"], vies, ages).values * 100
24 + fig, (a, b) = plt.subplots(2, 1, figsize=(LARGEUR, 5.4), gridspec_kw={"hspace": 0.5, "height_ratios": [1.15, 1]})
25 + im = a.imshow(G, cmap=palette.cmap_seq(), aspect="auto", origin="upper", vmin=15, vmax=80)
26 + a.set_xticks(range(len(ages))); a.set_xticklabels([str(x) for x in ages]); a.set_yticks(range(len(vies))); a.set_yticklabels([str(v) for v in vies])
27 + a.set_xlabel("Âge effectif de la structure (ans)"); a.set_ylabel("Vie économique (ans)"); a.grid(False)
28 + for i in range(len(vies)):
29 + for j in range(len(ages)):
30 + a.text(j, i, f"{G[i, j]:.0f}", ha="center", va="center", fontsize=7.5, color="white" if G[i, j] > 45 else palette.ink)
31 + a.set_title("(a) Âge-vie modifiée, % du coût neuf", loc="left", fontsize=9)
32 + base = to[0]["base"]
33 + ordre = sorted(range(len(to)), key=lambda i: abs(to[i]["haut"] - to[i]["bas"]))
34 + for k, i in enumerate(ordre):
35 + o = to[i]; lo, hi = min(o["bas"], o["haut"]), max(o["bas"], o["haut"])
36 + b.barh(k, hi - lo, left=lo, color=palette.cat[0] if k < len(ordre) - 1 else palette.cat[1], height=0.6)
37 + b.text(lo - 3000, k, fmt_dollars(lo).replace("$", r"\$"), ha="right", va="center", fontsize=7, color=palette.ink)
38 + b.text(hi + 3000, k, fmt_dollars(hi).replace("$", r"\$"), ha="left", va="center", fontsize=7, color=palette.ink)
39 + b.axvline(base, color=palette.ink2, lw=0.8, ls="--")
40 + b.set_yticks(range(len(ordre))); b.set_yticklabels([to[i]["param"].replace(" au lieu de l'extraction", "") for i in ordre], fontsize=7.5)
41 + b.set_xlim(400000, 680000); b.xaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars)); b.grid(axis="y", visible=False)
42 + b.set_xlabel(f"Indication par le coût (base {fmt_dollars(base)})".replace("$", r"\$")); b.set_title("(b) Tornade de l'indication par le coût", loc="left", fontsize=9)
43 + out = savefig(fig, "ch24/ch24-fig-sensibilite.pdf"); print("OK", out)
44 +
45 +
46 +if __name__ == "__main__":
47 + main()
added analysis/python/figures/ch25_figures.py +126 −0
@@ -0,0 +1,126 @@
1 +#!/usr/bin/env python3
2 +"""Figures du chapitre 25 — Évaluation du terrain (DONNÉES SYNTHÉTIQUES, graine 20261031).
3 +Sorties (assets/figures/ch25/) :
4 + ch25-fig-valeur-unitaire-superficie.pdf — valeur unitaire ($/m²) des terrains vacants selon la superficie, desservis ou non, et courbes du modèle ;
5 + ch25-fig-carte-terrains.pdf — carte synthétique des 200 ventes de terrains, du sujet et des cinq comparables retenus ;
6 + ch25-fig-lotissement.pdf — flux annuels du lotissement et valeur actualisée cumulée ; tornade de sensibilité ;
7 + ch25-fig-residuelle-sensibilite.pdf — technique résiduelle du terrain (cas 3) : V_L selon le taux du terrain pour trois coûts dépréciés.
8 +Usage (racine) : python3 analysis/python/figures/ch25_figures.py"""
9 +from __future__ import annotations
10 +import sys, pathlib
11 +import numpy as np
12 +ROOT = pathlib.Path(__file__).resolve().parents[3]
13 +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
14 +import matplotlib.pyplot as plt
15 +from matplotlib.lines import Line2D
16 +from traite.style import setup, palette, savefig, LARGEUR, fmt_dollars, fmt_milliers
17 +import ch25_terrain as cas
18 +
19 +
20 +def fig_valeur_unitaire(S):
21 + c = S["comparaison"]; df = c["terrains"]; m = c["modele"]; b = m["beta"]
22 + fig, ax = plt.subplots(figsize=(LARGEUR, 3.4))
23 + for val, lab, col, mk in [(1, "Terrain desservi (aqueduc et égout)", palette.cat[0], "o"), (0, "Terrain non desservi", palette.cat[1], "^")]:
24 + d = df[df["desservi"] == val]
25 + ax.scatter(d["superficie_m2"], d["prix_m2"], s=14, alpha=0.65, color=col, marker=mk, label=lab, linewidths=0)
26 + T = np.linspace(320, 9000, 300)
27 + base = np.exp(b["const"] + b["ln_T"] * np.log(T / 560.0)) * m["duan"] # Hull, plat, régulier, façade 18 m, 1 logement, 2024-07-01
28 + ax.plot(T, base / T, color=palette.cat[0], lw=1.6, label=f"Modèle, desservi (élasticité {b['ln_T']:.2f})")
29 + ax.plot(T, base * np.exp(b["non_desservi"]) / T, color=palette.cat[1], lw=1.6, ls="--", label="Modèle, non desservi")
30 + vr = S["valeur_retenue"] / 620.0
31 + ax.scatter([620], [vr], marker="*", s=120, color=palette.cat[3], zorder=5, edgecolor=palette.ink, linewidths=0.5)
32 + ax.annotate(f"Sujet du cas 1 : 620 m², {fmt_dollars(vr)}/m² (valeur retenue)", (620, vr), xytext=(1000, vr + 90), fontsize=8,
33 + arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6), color=palette.ink)
34 + ax.set_xscale("log"); ax.set_xlim(300, 10000); ax.set_ylim(0, 800)
35 + ax.set_xticks([300, 500, 1000, 2000, 5000, 10000]); ax.set_xticklabels(["300", "500", "1 000", "2 000", "5 000", "10 000"])
36 + ax.set_xlabel("Superficie du terrain (m², échelle logarithmique)"); ax.set_ylabel("Prix de vente par m² ($)")
37 + ax.legend(loc="upper right", fontsize=7.5)
38 + savefig(fig, "ch25/ch25-fig-valeur-unitaire-superficie.pdf")
39 +
40 +
41 +def fig_carte(S):
42 + c = S["comparaison"]; df = c["terrains"]; comps = c["selection"]["comparables"]; suj = c["sujet"]
43 + fig, ax = plt.subplots(figsize=(LARGEUR, 4.2))
44 + d1 = df[df["desservi"] == 1]; d0 = df[df["desservi"] == 0]
45 + ax.scatter(d1["lon"], d1["lat"], s=12, color=palette.ink2, alpha=0.45, linewidths=0, label="Vente de terrain desservi")
46 + ax.scatter(d0["lon"], d0["lat"], s=16, facecolors="none", edgecolors=palette.ink2, alpha=0.7, linewidths=0.7, label="Vente de terrain non desservi")
47 + ax.scatter(comps["lon"], comps["lat"], s=42, color=palette.cat[1], edgecolor=palette.ink, linewidths=0.5, zorder=4, label="Comparables retenus (5)")
48 + ax.scatter([suj["lon"]], [suj["lat"]], marker="*", s=170, color=palette.cat[3], edgecolor=palette.ink, linewidths=0.6, zorder=5, label="Sujet (cas 1)")
49 + for _, r in comps.iterrows():
50 + ax.annotate(r["id_vente"], (r["lon"], r["lat"]), xytext=(4, 3), textcoords="offset points", fontsize=7, color=palette.ink)
51 + # rayon de recherche de 4 km (approximation locale : 1° lat ≈ 111 km, 1° lon ≈ 78 km)
52 + th = np.linspace(0, 2 * np.pi, 200)
53 + ax.plot(suj["lon"] + 4 / 78 * np.cos(th), suj["lat"] + 4 / 111 * np.sin(th), color=palette.cat[3], lw=0.8, ls=":")
54 + for sct, (la, lo) in cas.CENTROIDES.items():
55 + ax.text(lo, la + 0.030, sct, ha="center", fontsize=8, color=palette.ink, fontweight="semibold")
56 + ax.set_xlabel("Longitude (coordonnées fictives)"); ax.set_ylabel("Latitude (coordonnées fictives)")
57 + ax.set_aspect(111 / 78); ax.legend(loc="lower right", fontsize=7.5)
58 + ax.set_xlim(-75.92, -75.33); ax.set_ylim(45.34, 45.64)
59 + savefig(fig, "ch25/ch25-fig-carte-terrains.pdf")
60 +
61 +
62 +def fig_lotissement(S):
63 + L = S["lotissement"]; det = L["detail"]; p = L["params"]; m = S["comparaison"]["modele"]
64 + fig, (a1, a2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.4), gridspec_kw={"width_ratios": [1.1, 1]})
65 + yrs = det["annee"].values
66 + couts = -(det["infrastructure"] + det["honoraires"] + det["taxes"] + det["frais_vente"] + det["profit"]).values
67 + a1.bar(yrs, det["revenus"].values, color=palette.cat[0], width=0.6, label="Produit des ventes de lots")
68 + a1.bar(yrs, couts, color=palette.cat[1], width=0.6, label="Coûts, frais et profit du promoteur")
69 + r = p["taux"]; cum = np.cumsum([f / (1 + r) ** t for t, f in zip(yrs, L["flux"])])
70 + a1.plot(yrs, cum, color=palette.cat[3], marker="o", lw=1.6, label="Valeur actualisée cumulée (10 %)")
71 + for t, v in zip(yrs, cum):
72 + a1.annotate(fmt_dollars(v), (t, v), xytext=(0, 6), textcoords="offset points", ha="center", fontsize=7, color=palette.ink)
73 + a1.axhline(0, color=palette.ink2, lw=0.6)
74 + a1.set_xticks(yrs); a1.set_xticklabels([f"Année {t}" for t in yrs]); a1.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p_: fmt_milliers(x / 1000)))
75 + a1.set_ylabel("Milliers de dollars"); a1.set_title("(a) Flux du lotissement de 12 lots", loc="left", fontsize=9)
76 + a1.legend(fontsize=7, loc="upper left")
77 + sl = cas.sensibilite_lotissement(m); base = sl["base"]
78 + items = [("Absorption : 2 à 6 lots/an", sl["absorption"][2], sl["absorption"][6]),
79 + ("Prix des lots : −10 % / +10 %", sl["prix"][-0.10], sl["prix"][0.10]),
80 + ("Infrastructures : 80 000 / 50 000 $ par lot", sl["infrastructure"][80_000], sl["infrastructure"][50_000]),
81 + ("Profit du promoteur : 20 % / 10 %", sl["profit"][0.20], sl["profit"][0.10]),
82 + ("Taux d'actualisation : 15 % / 8 %", sl["taux"][0.15], sl["taux"][0.08])]
83 + items.sort(key=lambda x: abs(x[2] - x[1]), reverse=True)
84 + for k, (lab, lo, hi) in enumerate(items):
85 + a2.barh(k, lo - base, left=base, color=palette.cat[1], height=0.55)
86 + a2.barh(k, hi - base, left=base, color=palette.cat[0], height=0.55)
87 + a2.text(min(lo, hi) - 8000, k, fmt_milliers((min(lo, hi)) / 1000), va="center", ha="right", fontsize=7, color=palette.ink)
88 + a2.text(max(lo, hi) + 8000, k, fmt_milliers((max(lo, hi)) / 1000), va="center", ha="left", fontsize=7, color=palette.ink)
89 + a2.set_yticks(range(len(items))); a2.set_yticklabels([i[0] for i in items], fontsize=7.5)
90 + a2.axvline(base, color=palette.ink, lw=0.8)
91 + a2.set_xlim(base - 420_000, base + 420_000); a2.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p_: fmt_milliers(x / 1000)))
92 + a2.set_xlabel("Valeur du site brut (milliers de dollars)"); a2.set_title(f"(b) Sensibilité autour de {fmt_dollars(base)}", loc="left", fontsize=9)
93 + a2.grid(axis="y", visible=False); a2.invert_yaxis()
94 + savefig(fig, "ch25/ch25-fig-lotissement.pdf")
95 +
96 +
97 +def fig_residuelle(S):
98 + t3 = S["cas3"]; rs = S["residuelle"]
99 + fig, ax = plt.subplots(figsize=(LARGEUR, 3.3))
100 + RL = np.linspace(0.03, 0.05, 81)
101 + for d, col, ls in [(0.44, palette.cat[1], "--"), (0.52, palette.cat[0], "-"), (0.60, palette.cat[2], "-.")]:
102 + VL = [cas.residuelle_cas3({"taux_depreciation": d, "R_L": r}, t3)["V_L"] for r in RL]
103 + CB = cas.residuelle_cas3({"taux_depreciation": d}, t3)["C_B"]
104 + ax.plot(RL * 100, VL, color=col, ls=ls, lw=1.6, label=f"Dépréciation {d*100:.0f} % — $C_B$ = {fmt_dollars(CB)}")
105 + ax.axhline(0, color=palette.ink2, lw=0.6)
106 + ax.scatter([rs["R_L"] * 100], [rs["V_L"]], s=60, color=palette.cat[3], zorder=5, edgecolor=palette.ink, linewidths=0.5)
107 + ax.annotate(f"Cas de base : $R_L$ = 3,75 %, n = 40 ans\n$V_L$ = {fmt_dollars(rs['V_L'])}", (rs["R_L"] * 100, rs["V_L"]), xytext=(4.2, 900_000), fontsize=8,
108 + arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6))
109 + ext = rs["V_cap"] - rs["C_B"]
110 + ax.axhline(ext, color=palette.ink2, lw=0.8, ls=":")
111 + ax.text(3.02, ext + 25_000, f"Par extraction sur la capitalisation directe : $V$ − $C_B$ = {fmt_dollars(ext)}", fontsize=7.5, color=palette.ink2)
112 + ax.set_xlabel("Taux de rendement du terrain $R_L$ (%)"); ax.set_ylabel("Valeur résiduelle du terrain $V_L$ ($)")
113 + ax.yaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars)); ax.set_ylim(-300_000, 1_300_000)
114 + ax.legend(loc="upper right", fontsize=7.5)
115 + savefig(fig, "ch25/ch25-fig-residuelle-sensibilite.pdf")
116 +
117 +
118 +def main():
119 + setup()
120 + S = cas.synthese()
121 + fig_valeur_unitaire(S); fig_carte(S); fig_lotissement(S); fig_residuelle(S)
122 + print("Figures ch25 écrites dans", ROOT / "assets/figures/ch25")
123 +
124 +
125 +if __name__ == "__main__":
126 + main()
added analysis/python/figures/ch26_figures.py +104 −0
@@ -0,0 +1,104 @@
1 +#!/usr/bin/env python3
2 +"""Chapitre 26 — figures (4) : loyer contractuel vs loyer de marché par logement (cas 3), distribution de l'ancienneté et
3 +survie sous rotation, valeur actuelle de l'écart par logement selon la rotation, profil mensuel du coût d'électricité au
4 +tarif D (normalisation). Données synthétiques (cas 3) et hypothèses de travail déclarées (profil de consommation).
5 +Idempotent ; écrit dans assets/figures/ch26/ch26-fig-<slug>.pdf.
6 +Usage (racine) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch26_figures.py"""
7 +from __future__ import annotations
8 +import sys, pathlib
9 +import numpy as np, pandas as pd
10 +import matplotlib.pyplot as plt
11 +from matplotlib.ticker import FuncFormatter
12 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
13 +from traite.style import setup, palette, savefig, LARGEUR, DEMI, fmt_dollars
14 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))
15 +from ch26_etat_locatif import calculer, survie
16 +
17 +dollars = FuncFormatter(fmt_dollars)
18 +SYNTH = "DONNÉES SYNTHÉTIQUES"
19 +
20 +
21 +def fig_contrat_marche(res):
22 + t = res["ind"]["tableau"].sort_values(["anciennete", "logement"]).reset_index(drop=True)
23 + fig, ax = plt.subplots(figsize=(LARGEUR, 3.6))
24 + y = np.arange(len(t)); h = 0.38
25 + ax.barh(y + h / 2, t["loyer_mensuel_marche"], height=h, color=palette.cat[1], label="loyer de marché estimé", zorder=3)
26 + ax.barh(y - h / 2, t["loyer_mensuel_contrat"], height=h, color=palette.cat[0], label="loyer contractuel", zorder=3)
27 + for i, row in t.iterrows():
28 + ax.text(row["loyer_mensuel_marche"] + 15, y[i] + h / 2, f"−{row['ecart_pct']*100:.1f} %".replace(".", ","), va="center", fontsize=7.5, color=palette.ink)
29 + ax.set_yticks(y); ax.set_yticklabels([f"{l} ({p}) — {a} an{'s' if a > 1 else ''}" for l, p, a in zip(t["logement"], t["pieces"], t["anciennete"])], fontsize=7.5)
30 + ax.invert_yaxis(); ax.xaxis.set_major_formatter(dollars); ax.set_xlim(0, 1950)
31 + ax.set_xlabel("Loyer mensuel ($)"); ax.set_ylabel("Logement (type) — ancienneté du locataire")
32 + ax.set_title("Cas 3 : loyer contractuel et loyer de marché par logement, du bail le plus récent au plus ancien", loc="left")
33 + ax.legend(loc="lower right"); ax.grid(axis="y", visible=False)
34 + ax.text(0.99, -0.16, SYNTH, transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2)
35 + return savefig(fig, "ch26/ch26-fig-contrat-marche.pdf")
36 +
37 +
38 +def fig_anciennete(res):
39 + t = res["ind"]["tableau"]
40 + fig, (a1, a2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.1), gridspec_kw={"width_ratios": [1.15, 1]})
41 + # (a) distribution de l'ancienneté (part des logements) et courbes de survie (1 − ρ)^t — même échelle de proportions, pas de double axe
42 + anc = t["anciennete"].values; bins = np.arange(0, 21, 2)
43 + a1.hist(anc, bins=bins, weights=np.full(len(anc), 1 / len(anc)), color=palette.cat[0], alpha=0.85, edgecolor=palette.surface, zorder=3, label="part des 12 logements (ancienneté observée)")
44 + tt = np.linspace(0, 20, 200)
45 + for rho, c, ls, lab in ((0.085, palette.cat[2], "-", "survie $(1-\\rho)^t$, ρ = 8,5 % (SCHL, 6–19 log., Montréal 2025)"), (0.094, palette.cat[3], "--", "survie, ρ = 9,4 % (SCHL, RMR Montréal 2025)"), (0.15, palette.cat[1], ":", "survie, ρ = 15 % (hypothèse du cas 3)")):
46 + a1.plot(tt, survie(rho, tt), color=c, linestyle=ls, linewidth=1.4, label=lab)
47 + a1.set_xlabel("Ancienneté du locataire (années)"); a1.set_ylabel("Proportion"); a1.set_ylim(0, 1.05); a1.set_xlim(0, 20)
48 + a1.set_title("(a) Ancienneté observée et survie théorique", loc="left")
49 + a1.legend(loc="upper center", bbox_to_anchor=(0.5, -0.24), fontsize=6.6, ncol=1)
50 + # (b) écart mensuel selon l'ancienneté, par type
51 + marq = {"3½": "o", "4½": "s", "5½": "^"}
52 + for i, (p, m) in enumerate(marq.items()):
53 + s = t[t["pieces"] == p]
54 + a2.scatter(s["anciennete"], s["ecart_mensuel"], marker=m, s=34, color=palette.cat[i], label=p, zorder=3)
55 + for _, row in t.iterrows():
56 + a2.annotate(row["logement"], (row["anciennete"], row["ecart_mensuel"]), xytext=(4, 3), textcoords="offset points", fontsize=6.5, color=palette.ink2)
57 + a2.yaxis.set_major_formatter(dollars); a2.set_xlabel("Ancienneté du locataire (années)"); a2.set_ylabel("Écart marché − contrat ($/mois)")
58 + a2.set_title("(b) Écart mensuel selon l'ancienneté", loc="left"); a2.legend(title="Type", loc="upper left", fontsize=7)
59 + a2.set_xlim(0, 20); a2.set_ylim(0, 360)
60 + a2.legend(title="Type", loc="upper left", fontsize=7, bbox_to_anchor=(0.0, -0.24), ncol=3)
61 + fig.text(0.99, -0.30, SYNTH, ha="right", va="bottom", fontsize=7, color=palette.ink2)
62 + fig.tight_layout()
63 + return savefig(fig, "ch26/ch26-fig-anciennete.pdf")
64 +
65 +
66 +def fig_va_ecart(res):
67 + va = res["va"].sort_values("anciennete").reset_index(drop=True)
68 + fig, ax = plt.subplots(figsize=(LARGEUR, 3.3))
69 + x = np.arange(len(va)); w = 0.27
70 + series = [("va_rho_0.085", "ρ = 8,5 %", palette.cat[2]), ("va_rho_0.094", "ρ = 9,4 %", palette.cat[3]), ("va_rho_0.150", "ρ = 15 %", palette.cat[1])]
71 + for k, (col, lab, c) in enumerate(series):
72 + tot = va[col].sum()
73 + ax.bar(x + (k - 1) * w, va[col], width=w, color=c, label=f"{lab} — total {fmt_dollars(tot)}", zorder=3)
74 + ax.set_xticks(x); ax.set_xticklabels([f"{l}\n{a} an{'s' if a > 1 else ''}" for l, a in zip(va["logement"], va["anciennete"])], fontsize=7.5)
75 + ax.yaxis.set_major_formatter(dollars); ax.set_ylabel("VA espérée de l'écart ($)"); ax.set_xlabel("Logement — ancienneté du locataire")
76 + ax.set_title("Cas 3 : valeur actuelle de l'écart contractuel/marché de chaque logement selon la rotation ρ (r = 7,25 %, g = 3,5 %)", loc="left")
77 + ax.legend(loc="upper left", fontsize=7.5)
78 + ax.text(0.99, -0.26, SYNTH, transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2)
79 + return savefig(fig, "ch26/ch26-fig-va-ecart.pdf")
80 +
81 +
82 +def fig_normalisation(res):
83 + n = res["norm"]; tab = n["tableau"]
84 + fig, ax = plt.subplots(figsize=(LARGEUR, 3.0))
85 + x = np.arange(12)
86 + ax.bar(x, tab["redevance"], color=palette.cat[2], label="redevance d'abonnement (46,154 ¢/jour)", zorder=3)
87 + ax.bar(x, tab["cout_tranche1"], bottom=tab["redevance"], color=palette.cat[0], label="1re tranche (≤ 40 kWh/jour, 7,065 ¢/kWh)", zorder=3)
88 + ax.bar(x, tab["cout_tranche2"], bottom=tab["redevance"] + tab["cout_tranche1"], color=palette.cat[1], label="2e tranche (> 40 kWh/jour, 11,142 ¢/kWh)", zorder=3)
89 + ax.axhline(n["cout_mensuel_moyen"], color=palette.ink, linestyle="--", linewidth=1.0)
90 + ax.text(11.45, n["cout_mensuel_moyen"] + 3, f"moyenne {fmt_dollars(n['cout_mensuel_moyen'])}/mois", ha="right", fontsize=7.5)
91 + for i, k in enumerate(tab["kwh"]):
92 + ax.text(i, tab["total"].iloc[i] + 3, f"{int(k)} kWh", ha="center", fontsize=6.5, color=palette.ink2)
93 + ax.set_xticks(x); ax.set_xticklabels(tab["mois"]); ax.yaxis.set_major_formatter(dollars); ax.set_ylim(0, 205)
94 + ax.set_ylabel("Coût mensuel hors taxes ($)")
95 + ax.set_title("Coût de l'électricité d'un 4½ chauffé à l'électricité au tarif D (1er avril 2026), profil de 12 000 kWh/an", loc="left")
96 + ax.legend(loc="upper right", fontsize=7.2, ncol=1)
97 + ax.text(0.99, -0.20, "HYPOTHÈSE DE TRAVAIL (profil de consommation) — structure tarifaire : Hydro-Québec", transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2)
98 + return savefig(fig, "ch26/ch26-fig-normalisation.pdf")
99 +
100 +
101 +if __name__ == "__main__":
102 + setup(); res = calculer()
103 + for f in (fig_contrat_marche, fig_anciennete, fig_va_ecart, fig_normalisation):
104 + print(f(res))
added analysis/python/figures/ch27_figures.py +139 −0
@@ -0,0 +1,139 @@
1 +#!/usr/bin/env python3
2 +"""Chapitre 27 — figures (4) :
3 + ch27-fig-inoccupation-rmr.pdf : (a) inoccupation SCHL par RMR, oct. 2024 et 2025 ; (b) inoccupation selon la taille de l'immeuble, RMR et île de Montréal,
4 + oct. 2025 — DONNÉES RÉELLES (SCHL, Enquête sur les logements locatifs, tableaux 1.1.1 et 1.3.1), citées avec attribution.
5 + ch27-fig-cascade.pdf : cascade RBP au marché → RBP contractuel → loyers effectifs → RBE, cas 3 (SYNTHÉTIQUE).
6 + ch27-fig-trajectoires.pdf : trajectoires du RBP du cas 3 : TAL seul, rotation 15 % (base), rotation 9,4 % (SCHL), marché (SYNTHÉTIQUE).
7 + ch27-fig-sensibilite.pdf : (a) inoccupation frictionnelle v_f = ρ·d/12 ; (b) RBE et valeur du cas 3 selon v (SYNTHÉTIQUE).
8 +Idempotent ; écrit dans assets/figures/ch27/. Usage (racine) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch27_figures.py"""
9 +from __future__ import annotations
10 +import sys, pathlib
11 +import numpy as np
12 +import matplotlib.pyplot as plt
13 +from matplotlib.ticker import FuncFormatter
14 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
15 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))
16 +from traite.style import setup, palette, savefig, LARGEUR, fmt_milliers
17 +from traite import finance as fin
18 +import ch27_revenus as c
19 +
20 +pct1 = FuncFormatter(lambda v, p: f"{v:.1f}".replace(".", ",") + " %")
21 +pct0 = FuncFormatter(lambda v, p: f"{v:.0f} %")
22 +def _f1(v): return f"{v:.1f}".replace(".", ",")
23 +
24 +
25 +def fig_inoccupation_rmr(res):
26 + s = res["schl_rmr"]; t = res["schl_taille"]
27 + noms = {"Montréal": "Montréal", "Québec": "Québec", "Gatineau (partie QC)": "Gatineau\n(partie QC)", "Sherbrooke": "Sher-\nbrooke", "Trois-Rivières": "Trois-\nRivières", "Saguenay": "Sague-\nnay"}
28 + fig, (a, b) = plt.subplots(1, 2, figsize=(LARGEUR, 3.3), gridspec_kw={"width_ratios": [1.15, 1]})
29 + x = np.arange(len(s)); w = 0.38
30 + a.bar(x - w / 2, s["inocc_2024"], w, color=palette.cat[1], label="Octobre 2024")
31 + a.bar(x + w / 2, s["inocc_2025"], w, color=palette.cat[0], label="Octobre 2025")
32 + for xi, v in zip(x + w / 2, s["inocc_2025"]): a.text(xi, v + 0.06, _f1(v), ha="center", va="bottom", fontsize=7)
33 + for xi, v in zip(x - w / 2, s["inocc_2024"]): a.text(xi, v + 0.06, _f1(v), ha="center", va="bottom", fontsize=6.5, color=palette.ink2)
34 + a.set_xticks(x); a.set_xticklabels([noms[i] for i in s.index], fontsize=7)
35 + a.set_ylabel("Taux d'inoccupation, tous les appartements (%)"); a.set_ylim(0, 4.6); a.yaxis.set_major_formatter(pct0)
36 + a.legend(loc="upper right", fontsize=7); a.set_title("(a) Six RMR du Québec", loc="left", fontsize=8.5)
37 + tailles = ["3 à 5", "6 à 19", "20 à 49", "50 à 99", "100 et +", "Tous"]
38 + ile = t[t["zone"] == "Île de Montréal"]["inocc_2025"].values; rmr = t[t["zone"] == "RMR de Montréal"]["inocc_2025"].values
39 + x2 = np.arange(len(tailles))
40 + b.bar(x2 - w / 2, rmr, w, color=palette.cat[0], label="RMR de Montréal")
41 + b.bar(x2 + w / 2, ile, w, color=palette.cat[2], label="Île de Montréal")
42 + for xi, v in zip(x2 - w / 2, rmr): b.text(xi, v + 0.06, _f1(v), ha="center", va="bottom", fontsize=6.5)
43 + for xi, v in zip(x2 + w / 2, ile): b.text(xi, v + 0.06, _f1(v), ha="center", va="bottom", fontsize=6.5, color=palette.ink2)
44 + b.set_xticks(x2); b.set_xticklabels(tailles, fontsize=7); b.set_xlabel("Logements dans l'immeuble")
45 + b.set_ylim(0, 6.6); b.yaxis.set_major_formatter(pct0); b.legend(loc="upper left", fontsize=7)
46 + b.set_title("(b) Selon la taille de l'immeuble, octobre 2025", loc="left", fontsize=8.5)
47 + fig.tight_layout(w_pad=1.5)
48 + return savefig(fig, "ch27/ch27-fig-inoccupation-rmr.pdf")
49 +
50 +
51 +def fig_cascade(res):
52 + r = res["cas3"]
53 + etapes = [("RBP aux loyers\nde marché", r["rbp_marche"], "total"), ("Déficit de loyer\n(baux en place)", -r["deficit_loyer"], "delta"),
54 + ("RBP\ncontractuel", r["rbp_contrat"], "total"), ("Inoccupation et\nmauvaises créances", -r["vacance_pertes"], "delta"),
55 + ("Loyers\neffectifs", r["loyers_effectifs"], "total"), ("Autres\nrevenus", r["autres_revenus"], "delta"), ("Revenu brut\neffectif (RBE)", r["rbe"], "total")]
56 + fig, ax = plt.subplots(figsize=(LARGEUR, 3.5))
57 + cum = 0.0
58 + for i, (lab, val, kind) in enumerate(etapes):
59 + if kind == "total":
60 + ax.bar(i, val / 1000, 0.62, color=palette.cat[0] if i in (0, 2) else palette.brand2 if i == 6 else palette.cat[2], zorder=3)
61 + ax.text(i, val / 1000 + 2.5, fmt_milliers(val) + " $", ha="center", va="bottom", fontsize=7.5)
62 + cum = val
63 + else:
64 + bas = cum + min(val, 0.0)
65 + ax.bar(i, abs(val) / 1000, 0.62, bottom=bas / 1000, color=palette.cat[3] if val < 0 else palette.cat[4], zorder=3)
66 + ax.text(i, (cum + val) / 1000 - 2.5 if val < 0 else (cum + val) / 1000 + 2.5, ("−" if val < 0 else "+") + fmt_milliers(abs(val)) + " $",
67 + ha="center", va="top" if val < 0 else "bottom", fontsize=7.5)
68 + cum += val
69 + if i < len(etapes) - 1:
70 + ax.plot([i + 0.31, i + 1 - 0.31], [cum / 1000, cum / 1000], color=palette.ink2, lw=0.6, ls=":", zorder=2)
71 + ax.set_xticks(range(len(etapes))); ax.set_xticklabels([e[0] for e in etapes], fontsize=6.8); ax.set_xlim(-0.55, len(etapes) - 0.45)
72 + ax.set_ylim(140, 195); ax.set_ylabel("Milliers de dollars par année")
73 + ax.yaxis.set_major_formatter(FuncFormatter(lambda v, p: fmt_milliers(v)))
74 + ax.set_title("Cas 3 : du revenu brut potentiel au revenu brut effectif (première année, 2026-2027)", loc="left")
75 + ax.text(0.99, -0.26, "DONNÉES SYNTHÉTIQUES", transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2)
76 + return savefig(fig, "ch27/ch27-fig-cascade.pdf")
77 +
78 +
79 +def fig_trajectoires(res):
80 + t = res["trajectoires"]["table"]; h = res["trajectoires"]["hyp"]; T = res["trajectoires"]["T"]
81 + t = t[t["annee"] <= T]
82 + fig, ax = plt.subplots(figsize=(LARGEUR, 3.3))
83 + x = t["exercice"].values
84 + ax.fill_between(x, t["tal_seul"] / 1000, t["marche"] / 1000, color=palette.grid, alpha=0.8, label="Écart entre l'indexation seule et le marché")
85 + ax.plot(x, t["marche"] / 1000, color=palette.cat[1], marker="s", ms=3.2, label=f"RBP au loyer de marché (indice : +{h['croissance_loyers_marche']*100:.1f} %/an)".replace(".", ","))
86 + ax.plot(x, t["base_rot15"] / 1000, color=palette.cat[0], marker="o", ms=3.2, label=f"Baux en place : TAL {h['croissance_loyers_contrat']*100:.1f} % + rotation {h['rotation_annuelle']*100:.0f} % (base)".replace(".", ","))
87 + ax.plot(x, t["rot_schl_094"] / 1000, color=palette.cat[2], marker="^", ms=3.2, ls="--", label="Baux en place : TAL 3,1 % + rotation 9,4 % (SCHL, RMR 2025)")
88 + ax.plot(x, t["tal_seul"] / 1000, color=palette.cat[3], ls=":", lw=1.4, label="Baux en place : indexation TAL 3,1 % seule (rotation nulle)")
89 + for k, col, dy in (("tal_seul", palette.cat[3], -6), ("marche", palette.cat[1], 4)):
90 + ax.text(x[-1] + 0.15, t[k].iloc[-1] / 1000 + dy / 10, fmt_milliers(t[k].iloc[-1]) + " $", fontsize=7, color=palette.ink, va="center")
91 + ax.set_xticks(x); ax.set_xlabel("Exercice (1er septembre → 31 août)"); ax.set_ylabel("Revenu brut potentiel (milliers de $)")
92 + ax.yaxis.set_major_formatter(FuncFormatter(lambda v, p: fmt_milliers(v)))
93 + ax.set_xlim(x[0] - 0.3, x[-1] + 1.4); ax.legend(loc="lower right", fontsize=7)
94 + ax.set_title("Cas 3 : quatre trajectoires du revenu brut potentiel sur dix ans", loc="left")
95 + ax.text(0.99, -0.22, "DONNÉES SYNTHÉTIQUES", transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2)
96 + return savefig(fig, "ch27/ch27-fig-trajectoires.pdf")
97 +
98 +
99 +def fig_sensibilite(res):
100 + s = res["schl_rmr"]; base = res["cas3"]
101 + fig, (a, b) = plt.subplots(1, 2, figsize=(LARGEUR, 3.2))
102 + d = np.linspace(0, 3, 61)
103 + rots = [(0.05, "ρ = 5 %"), (0.094, "ρ = 9,4 % (Montréal 2025)"), (0.13, "ρ = 13 % (Québec 2025)"), (0.162, "ρ = 16,2 % (Gatineau 2025)"), (0.25, "ρ = 25 %")]
104 + for i, (rho, lab) in enumerate(rots):
105 + a.plot(d, [fin.taux_vacance_frictionnelle(rho, x) * 100 for x in d], color=palette.cat[i], label=lab)
106 + a.axhline(s.loc["Montréal", "inocc_2025"], color=palette.ink2, lw=0.7, ls=":")
107 + a.text(0.05, s.loc["Montréal", "inocc_2025"] + 0.08, "inoccupation SCHL, RMR de Montréal, oct. 2025 : 2,9 %", fontsize=6.8, color=palette.ink2)
108 + a.scatter([1.0], [base["v_frictionnel_dossier"] * 100], color=palette.ink, marker="*", s=70, zorder=4)
109 + a.annotate("dossier du cas 3 :\nρ = 15 %, d = 1 mois\n→ 1,25 %", (1.0, base["v_frictionnel_dossier"] * 100), xytext=(1.25, 0.35), fontsize=6.8,
110 + arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6))
111 + a.set_xlabel("Durée moyenne de vacance entre deux baux, d (mois)"); a.set_ylabel("Inoccupation frictionnelle v_f = ρ·d/12 (%)")
112 + a.set_ylim(0, 6.5); a.yaxis.set_major_formatter(pct1); a.legend(fontsize=6.5, loc="upper left")
113 + a.set_title("(a) Rotation, durée de vacance et inoccupation", loc="left", fontsize=8.5)
114 + sv = c.sensibilite_v(base, taux=tuple(sorted(set(np.round(np.arange(0.01, 0.0801, 0.0025), 4)) | {0.029, 0.039, 0.059})))
115 + b.plot(sv["v"] * 100, sv["valeur"] / 1000, color=palette.cat[0])
116 + b.scatter([base["taux_vacance_pertes"] * 100], [sv.loc[np.isclose(sv["v"], base["taux_vacance_pertes"]), "valeur"].iloc[0] / 1000], color=palette.ink, marker="*", s=70, zorder=4)
117 + for v0, lab, yt in ((0.029, "SCHL RMR\n2,9 %", 1856), (0.039, "dossier\n3,9 %", 1886), (0.059, "+2 points\n5,9 %", 1856)):
118 + y = sv.loc[np.isclose(sv["v"], v0), "valeur"].iloc[0] / 1000
119 + b.plot([v0 * 100, v0 * 100], [1850, y], color=palette.ink2, lw=0.6, ls=":")
120 + b.text(v0 * 100, yt, lab, fontsize=6.5, ha="center", va="bottom", color=palette.ink2)
121 + b.set_xlabel("Provision pour inoccupation et pertes, v (% du RBP)"); b.set_ylabel("Valeur par capitalisation directe (milliers de $)")
122 + b.xaxis.set_major_formatter(pct0); b.yaxis.set_major_formatter(FuncFormatter(lambda v, p: fmt_milliers(v)))
123 + b.set_ylim(1850, 2160)
124 + b.text(0.97, 0.95, f"pente : {fmt_milliers(-sv.attrs['dV_dv_pt'])} $ par point de v\n(R = {sv.attrs['R']*100:.3f} %)".replace(".", ","), transform=b.transAxes, ha="right", va="top", fontsize=7)
125 + b.set_title("(b) Cas 3 : valeur selon la provision v", loc="left", fontsize=8.5)
126 + fig.tight_layout(w_pad=1.6)
127 + fig.text(0.99, -0.02, "Panneau (b) : DONNÉES SYNTHÉTIQUES", ha="right", va="bottom", fontsize=7, color=palette.ink2)
128 + return savefig(fig, "ch27/ch27-fig-sensibilite.pdf")
129 +
130 +
131 +def main():
132 + setup()
133 + res = c.calculer()
134 + for f in (fig_inoccupation_rmr, fig_cascade, fig_trajectoires, fig_sensibilite):
135 + print("OK", f(res))
136 +
137 +
138 +if __name__ == "__main__":
139 + main()
added analysis/python/figures/ch31_levier.py +37 −0
@@ -0,0 +1,37 @@
1 +#!/usr/bin/env python3
2 +"""Ch. 31, figure « levier » (DONNÉES SYNTHÉTIQUES, cas 3) : TRI des fonds propres sur dix ans et rendement courant de la
3 +première année (cash-on-cash) en fonction du ratio prêt-valeur, achat à la valeur DCF (2 208 566 $), prêt à 4,12 % sur 480 mois ;
4 +repères : rendement sans levier 7,25 %, taux de capitalisation implicite 4,07 %, RPV retenu 66,8 % (RCD 1,20) et RPV 75 %.
5 +Sortie : ch31-fig-levier.pdf."""
6 +from __future__ import annotations
7 +import sys, pathlib
8 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
9 +import numpy as np, matplotlib.pyplot as plt
10 +from traite.style import setup, palette, savefig, LARGEUR
11 +from cases import ch31_financement as c
12 +
13 +def main():
14 + setup()
15 + r = c.base(); fi = c.financement(r)
16 + t = c.tri_fp_courbe(r, np.linspace(0.0, 0.85, 52))
17 + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.0))
18 + x = t["rpv"] * 100
19 + ax1.plot(x, t["tri_fp"] * 100, color=palette.cat[0], lw=1.8, label="TRI des fonds propres (10 ans)")
20 + ax1.axhline(fi["tri_sans_levier"] * 100, color=palette.ink2, lw=0.8, ls="--"); ax1.text(1, 7.25 + 0.3, "sans levier : 7,25 %", fontsize=7.5, color=palette.ink)
21 + ax1.axhline(fi["k_annuel"] * 100, color=palette.cat[2], lw=0.8, ls=":"); ax1.text(1, fi["k_annuel"] * 100 + 0.3, "coût effectif de la dette k ≈ 4,06 %", fontsize=7.5, color=palette.ink)
22 + ax1.axvline(fi["rpv"] * 100, color=palette.cat[1], lw=0.8); ax1.text(fi["rpv"] * 100 - 1, 15.6, "RPV retenu\n66,8 % (RCD 1,20)", fontsize=7, ha="right", color=palette.ink)
23 + ax1.plot([fi["rpv"] * 100], [fi["tri_fp"] * 100], "o", color=palette.cat[1], ms=4.5)
24 + ax1.set_xlabel("Ratio prêt-valeur (%)"); ax1.set_ylabel("Rendement (%)"); ax1.set_xlim(0, 87); ax1.set_ylim(0, 17)
25 + ax1.set_title("Rendement total : levier positif", loc="left")
26 + ax2.plot(x, t["cash_on_cash_1"] * 100, color=palette.cat[0], lw=1.8)
27 + ax2.axhline(fi["R_dcf"] * 100, color=palette.ink2, lw=0.8, ls="--"); ax2.text(1, fi["R_dcf"] * 100 + 0.15, "R = RNE$_1$/V = 4,07 %", fontsize=7.5, color=palette.ink)
28 + ax2.axhline(0, color=palette.ink2, lw=0.6)
29 + ax2.axvline(fi["rpv"] * 100, color=palette.cat[1], lw=0.8)
30 + ax2.plot([fi["rpv"] * 100], [fi["cash_on_cash_1"] * 100], "o", color=palette.cat[1], ms=4.5)
31 + ax2.annotate("2,05 %", xy=(fi["rpv"] * 100, fi["cash_on_cash_1"] * 100), xytext=(fi["rpv"] * 100 - 22, 1.2), fontsize=7.5, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6))
32 + ax2.set_xlabel("Ratio prêt-valeur (%)"); ax2.set_ylabel("Rendement courant, année 1 (%)"); ax2.set_xlim(0, 87); ax2.set_ylim(-2.5, 5)
33 + ax2.set_title("Rendement courant : levier négatif (R < f)", loc="left")
34 + savefig(fig, "ch31/ch31-fig-levier.pdf")
35 +
36 +if __name__ == "__main__":
37 + main()
added analysis/python/figures/ch31_service_dette.py +36 −0
@@ -0,0 +1,36 @@
1 +#!/usr/bin/env python3
2 +"""Ch. 31, figure « service-dette » (DONNÉES SYNTHÉTIQUES, cas 3) : service de la dette annuel du prêt de 1 475 959 $ et ratio de
3 +couverture sur le RNE de l'année 1 (89 956 $) en fonction du taux nominal (composition semestrielle), pour des amortissements de
4 +25, 30 et 40 ans ; repères : taux de base 4,12 %, seuil RCD 1,20 et 1,00. Sortie : ch31-fig-service-dette.pdf."""
5 +from __future__ import annotations
6 +import sys, pathlib
7 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
8 +import numpy as np, matplotlib.pyplot as plt
9 +from traite.style import setup, palette, savefig, LARGEUR
10 +from cases import ch31_financement as c
11 +
12 +def main():
13 + setup()
14 + r = c.base(); fi = c.financement(r)
15 + s = c.sensibilite_service_dette(r, taux=np.arange(0.03, 0.0801, 0.001))
16 + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.0))
17 + for k, (n, lab) in enumerate(((480, "40 ans"), (360, "30 ans"), (300, "25 ans"))):
18 + d = s[s["amortissement_mois"] == n]
19 + ax1.plot(d["taux"] * 100, d["service_dette"] / 1000, color=palette.cat[k], lw=1.6, label=f"amortissement {lab}")
20 + ax2.plot(d["taux"] * 100, d["rcd_1"], color=palette.cat[k], lw=1.6, label=f"amortissement {lab}")
21 + for ax in (ax1, ax2):
22 + ax.axvline(4.12, color=palette.ink2, lw=0.8, ls="--"); ax.set_xlabel("Taux nominal, composition semestrielle (%)"); ax.set_xlim(3, 8)
23 + ax1.text(4.2, 53, "taux de base 4,12 %", fontsize=7.5, color=palette.ink)
24 + ax1.axhline(fi["rne_1"] / 1000, color=palette.cat[1], lw=0.8, ls=":"); ax1.text(6.6, fi["rne_1"] / 1000 + 2, "RNE$_1$ = 89 956 $", fontsize=7.5, color=palette.ink)
25 + ax1.plot([4.12], [fi["service_dette"] / 1000], "o", color=palette.cat[0], ms=4.5)
26 + ax1.set_ylabel("Service de la dette (milliers de $/an)"); ax1.set_ylim(50, 150); ax1.legend(loc="upper left", fontsize=7.2, bbox_to_anchor=(0.0, 1.0))
27 + ax1.set_title("Service de la dette du prêt retenu", loc="left")
28 + ax2.axhline(1.20, color=palette.cat[1], lw=0.8, ls=":"); ax2.text(6.4, 1.22, "RCD minimal 1,20", fontsize=7.5, color=palette.ink)
29 + ax2.axhline(1.00, color=palette.ink2, lw=0.8, ls=":"); ax2.text(6.4, 1.02, "RCD 1,00 (point mort)", fontsize=7.5, color=palette.ink)
30 + ax2.plot([4.12], [1.20], "o", color=palette.cat[0], ms=4.5)
31 + ax2.set_ylabel("Ratio de couverture, année 1"); ax2.set_ylim(0.6, 1.5)
32 + ax2.set_title("Couverture de la dette", loc="left")
33 + savefig(fig, "ch31/ch31-fig-service-dette.pdf")
34 +
35 +if __name__ == "__main__":
36 + main()
added analysis/python/figures/ch31_solde.py +41 −0
@@ -0,0 +1,41 @@
1 +#!/usr/bin/env python3
2 +"""Ch. 31, figure « solde » (DONNÉES SYNTHÉTIQUES, cas 3) : trajectoire du solde du prêt de 1 475 959 $ à 4,12 % (composition
3 +semestrielle) amorti sur 480 mois, avec les soldes au terme (60 mois) et à la revente (120 mois), et décomposition annuelle du
4 +service de la dette en intérêts et capital. Sortie : ch31-fig-solde.pdf."""
5 +from __future__ import annotations
6 +import sys, pathlib
7 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
8 +import numpy as np, matplotlib.pyplot as plt
9 +from matplotlib.ticker import FuncFormatter
10 +from traite.style import setup, palette, savefig, LARGEUR, fmt_milliers
11 +from cases import ch31_financement as c
12 +
13 +def main():
14 + setup()
15 + r = c.base(); h = r["financement"]["hypotheque"]
16 + dec = c.decomposition_annuelle(h)
17 + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.0), gridspec_kw={"width_ratios": [1.1, 1]})
18 + k = np.arange(0, h.amortissement_mois + 1)
19 + solde = np.array([h.solde(int(x)) for x in k]) / 1000
20 + ax1.plot(k / 12, solde, color=palette.cat[0], lw=1.8)
21 + # convention américaine pour comparaison
22 + import traite.finance as fin
23 + hus = fin.Hypotheque(h.capital, h.taux_nominal, h.amortissement_mois, composition=12)
24 + ax1.plot(k / 12, np.array([hus.solde(int(x)) for x in k]) / 1000, color=palette.cat[2], lw=1.0, ls="--")
25 + for m, lab, dx, dy in ((60, "fin du terme (60 mois)", 4.0, -330), (120, "revente (120 mois)", 6.0, -620)):
26 + s = h.solde(m) / 1000
27 + ax1.plot([m / 12], [s], "o", color=palette.cat[1], ms=4)
28 + ax1.annotate(f"{lab}\n{fmt_milliers(s*1000)} $", xy=(m / 12, s), xytext=(m / 12 + dx, s + dy), fontsize=7.5, color=palette.ink,
29 + arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6))
30 + ax1.text(1.0, 180, "trait plein : semestriel (Canada)\npointillé : mensuel (É.-U.)", fontsize=7.2, color=palette.ink)
31 + ax1.set_xlabel("Années écoulées"); ax1.set_ylabel("Solde du prêt (milliers de $)"); ax1.set_xlim(0, 40); ax1.set_ylim(0, 1600)
32 + ax1.set_title("Trajectoire du solde", loc="left")
33 + ax2.bar(dec["annee"], dec["interet"] / 1000, color=palette.cat[1], width=0.8, label="Intérêts")
34 + ax2.bar(dec["annee"], dec["capital"] / 1000, bottom=dec["interet"] / 1000, color=palette.cat[0], width=0.8, label="Capital remboursé")
35 + ax2.axvline(5.5, color=palette.ink2, lw=0.7, ls=":"); ax2.text(6.0, 77.5, "fin du terme", fontsize=7, color=palette.ink2)
36 + ax2.set_xlabel("Année du prêt"); ax2.set_ylabel("Service de la dette (milliers de $)"); ax2.set_xlim(0.3, 40.7); ax2.set_ylim(0, 100)
37 + ax2.legend(loc="upper right", fontsize=7.5, ncol=2); ax2.set_title("Intérêts et capital par année", loc="left")
38 + savefig(fig, "ch31/ch31-fig-solde.pdf")
39 +
40 +if __name__ == "__main__":
41 + main()
added analysis/python/figures/ch31_taux_bdc.py +48 −0
@@ -0,0 +1,48 @@
1 +#!/usr/bin/env python3
2 +"""Ch. 31, figure « taux-bdc » (DONNÉES RÉELLES, Banque du Canada, API Valet, téléchargées le 2026-10-31) : taux effectifs des
3 +fonds avancés par les banques à charte pour les prêts hypothécaires résidentiels à taux fixe de 5 ans et plus, assurés
4 +(V122667780) et non assurés (V122667786), mensuels, janvier 2013 à août 2026 ; taux hypothécaire conventionnel 5 ans affiché
5 +(V80691335, moyennes mensuelles) et taux cible du financement à un jour (V39079). Sortie : ch31-fig-taux-bdc.pdf."""
6 +from __future__ import annotations
7 +import sys, pathlib, io
8 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
9 +import pandas as pd, matplotlib.pyplot as plt
10 +from traite.style import setup, palette, savefig, LARGEUR, ROOT
11 +
12 +RAW = ROOT / "data/raw/bdc_valet"
13 +
14 +def lire_serie(nom: str) -> pd.Series:
15 + txt = (RAW / f"{nom}.csv").read_text(encoding="utf-8-sig")
16 + obs = txt[txt.index('"OBSERVATIONS"'):].split("\n", 1)[1]
17 + df = pd.read_csv(io.StringIO(obs)); df.columns = ["date"] + list(df.columns[1:])
18 + df["date"] = pd.to_datetime(df["date"])
19 + s = pd.to_numeric(df.iloc[:, 1], errors="coerce"); s.index = df["date"]
20 + return s.dropna()
21 +
22 +def lire_groupe() -> pd.DataFrame:
23 + txt = (RAW / "A4_RATES_MORTGAGES.csv").read_text(encoding="utf-8-sig")
24 + obs = txt[txt.index('"OBSERVATIONS"'):].split("\n", 1)[1]
25 + df = pd.read_csv(io.StringIO(obs)); df = df.rename(columns={df.columns[0]: "date"})
26 + df["date"] = pd.to_datetime(df["date"]); df = df.set_index("date").apply(pd.to_numeric, errors="coerce")
27 + return df
28 +
29 +def main():
30 + setup()
31 + g = lire_groupe()
32 + assure = g["V122667780"].dropna(); non_assure = g["V122667786"].dropna()
33 + affiche = lire_serie("V80691335")["2013-01-01":].resample("MS").mean()
34 + directeur = lire_serie("V39079")["2013-01-01":].resample("MS").last()
35 + fig, ax = plt.subplots(figsize=(LARGEUR, 3.3))
36 + ax.plot(affiche.index, affiche.values, color=palette.cat[2], lw=1.1, label="Taux conventionnel 5 ans affiché (V80691335)")
37 + ax.plot(non_assure.index, non_assure.values, color=palette.cat[1], lw=1.6, label="Fonds avancés, non assurés, fixe 5 ans et plus (V122667786)")
38 + ax.plot(assure.index, assure.values, color=palette.cat[0], lw=1.6, label="Fonds avancés, assurés, fixe 5 ans et plus (V122667780)")
39 + ax.step(directeur.index, directeur.values, where="post", color=palette.ink2, lw=1.0, label="Taux cible du financement à un jour (V39079)")
40 + for s, lab, dy in ((assure, "4,12 %", -0.55), (non_assure, "4,58 %", 0.35), (affiche, "6,19 %", 0.3)):
41 + ax.annotate(lab, xy=(s.index[-1], s.iloc[-1]), xytext=(s.index[-1] + pd.DateOffset(months=4), s.iloc[-1] + dy), fontsize=7.5, color=palette.ink)
42 + ax.annotate("2,50 %", xy=(directeur.index[-1], directeur.iloc[-1]), xytext=(directeur.index[-1] + pd.DateOffset(months=4), directeur.iloc[-1] - 0.1), fontsize=7.5, color=palette.ink)
43 + ax.set_ylabel("Taux (%)"); ax.set_ylim(0, 8.5); ax.set_xlim(pd.Timestamp("2013-01-01"), pd.Timestamp("2027-09-01"))
44 + ax.legend(loc="upper left", fontsize=7.2)
45 + savefig(fig, "ch31/ch31-fig-taux-bdc.pdf")
46 +
47 +if __name__ == "__main__":
48 + main()
modified analysis/python/tests/test_ch11.py +8 −0
@@ -174,3 +174,11 @@ class TestExercices:
174 174 assert s.loc[(0.25, 3), "taux"] == pytest.approx(0.5977, abs=5e-5)
175 175 assert s.loc[(0.75, 3), "age_effectif_structure"] == pytest.approx(29.5)
176 176 assert s.loc[(0.25, 3), "age_effectif_structure"] == pytest.approx(28.5)
177 + assert s.loc[(0.25, 3), "depreciation"] == pytest.approx(237631.21, abs=0.01)
178 + assert s.loc[(0.75, 3), "depreciation"] == pytest.approx(202500.46, abs=0.01)
179 + assert s.loc[(0.25, 3), "depreciation"] - s.loc[(0.75, 3), "depreciation"] == pytest.approx(35130.75, abs=0.01)
180 + d25 = cas.depreciation(data["f"], data["trav"], data["cn"], 0.25); d75 = cas.depreciation(data["f"], data["trav"], data["cn"], 0.75)
181 + assert d25["courte_duree"]["Revêtement extérieur (brique et fibrociment)"] - d75["courte_duree"]["Revêtement extérieur (brique et fibrociment)"] == pytest.approx(8568.74, abs=0.01)
182 + assert d25["courte_duree"]["Armoires de cuisine et comptoirs"] - d75["courte_duree"]["Armoires de cuisine et comptoirs"] == pytest.approx(5000.84, abs=0.01)
183 + assert d25["courte_duree"]["Fenêtres (PVC, double vitrage)"] - d75["courte_duree"]["Fenêtres (PVC, double vitrage)"] == pytest.approx(4834.14, abs=0.01)
184 + assert d25["longue_duree"] - d75["longue_duree"] == pytest.approx(-2759.49, abs=0.01)
added analysis/python/tests/test_ch14.py +185 −0
@@ -0,0 +1,185 @@
1 +"""Chapitre 14 — Nettoyage, géocodage et qualité des données : reproductibilité du jeu brut, identités des diagnostics,
2 +chiffres cités dans le texte et dans les solutions (tolérances déclarées)."""
3 +import sys, pathlib, math
4 +import numpy as np, pandas as pd, pytest
5 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))
6 +import ch14_nettoyage as c14
7 +from traite import cas1, hedonique as h
8 +
9 +@pytest.fixture(scope="module")
10 +def R():
11 + return c14.main(ecrire=False, verbose=False)
12 +
13 +# ----------------------------------------------------------------------------- jeu brut
14 +
15 +def test_brut_reproductible_et_identique_au_fichier(R):
16 + g1 = c14.generer_brut(); g2 = c14.generer_brut()
17 + pd.testing.assert_frame_equal(g1["brut"], g2["brut"])
18 + disque = c14.charger_brut()
19 + assert len(disque["brut"]) == 618 and len(disque["verite"]) == 127 and len(disque["role"]) == 600
20 + b = g1["brut"]; d = disque["brut"].copy()
21 + for c in ("date_promesse", "date_acte"): d[c] = d[c].dt.strftime("%Y-%m-%d")
22 + pd.testing.assert_frame_equal(b.reset_index(drop=True), d[b.columns].reset_index(drop=True), check_dtype=False)
23 +
24 +def test_anomalies_injectees(R):
25 + v = R["verite"]["anomalie"].value_counts()
26 + attendu = {"terrain_manquant": 20, "doublon": 18, "qualite_manquante": 15, "superficie_pi2": 12, "dates_inversees": 10, "annee_manquante": 10,
27 + "coord_nulles": 9, "meubles_inclus": 8, "vente_liee_declaree": 8, "coord_hors_zone": 6, "vente_liee_non_declaree": 6, "prix_symbolique": 5}
28 + assert v.to_dict() == attendu
29 + b = R["brut"]
30 + assert (b["prix_vente"] == 1).sum() == 5 and (b["superficie_habitable_m2"] > 450).sum() == 12
31 + assert b["superficie_terrain_m2"].isna().sum() == 20 and b["qualite_1a5"].isna().sum() == 15 and b["annee_construction"].isna().sum() == 10
32 + assert ((b["lat"] == 0) & (b["lon"] == 0)).sum() == 9
33 + assert (pd.to_datetime(b["date_acte"]) < pd.to_datetime(b["date_promesse"])).sum() == 10
34 + # les lignes touchées (hors doublons) sont disjointes
35 + sans_dup = R["verite"][R["verite"]["anomalie"] != "doublon"]
36 + assert sans_dup["id_inscription"].is_unique and len(sans_dup) == 109
37 +
38 +# ----------------------------------------------------------------------------- pipeline
39 +
40 +def test_entonnoir_et_journal(R):
41 + P = R["pipeline"]
42 + assert P["etapes"] == [("0 lecture", 618), ("R1 doublons", 600), ("R4 prix symbolique", 595), ("R7 non représentatives", 587),
43 + ("I1 imputation", 587), ("S1 scores robustes", 581), ("E1 régression", 579)]
44 + j = P["journal"]; g = j.groupby(["etape", "action"]).size().to_dict()
45 + assert g == {("E1", "exclusion"): 2, ("G1", "imputation"): 15, ("I1", "imputation"): 45, ("R1", "exclusion"): 18, ("R2", "correction"): 10,
46 + ("R3", "correction"): 8, ("R4", "exclusion"): 5, ("R5", "correction"): 12, ("R6", "correction"): 15, ("R7", "exclusion"): 8, ("S1", "exclusion"): 6}
47 + assert len(j) == 144
48 + # chaque anomalie est traitée par l'étape prévue ; les deux ventes liées non déclarées restantes sont prises par la régression
49 + jv = j.merge(R["verite"], on="id_inscription", how="left"); ct = pd.crosstab(jv["etape"], jv["anomalie"].fillna("propre"))
50 + # R1 conserve l'inscription la plus récente (la copie) et exclut l'originale : on vérifie par l'unité d'origine
51 + orig = dict(zip(R["brut"]["id_inscription"], R["brut"]["id_vente_origine"]))
52 + dup_units = set(R["verite"].loc[R["verite"]["anomalie"] == "doublon", "id_vente_origine"])
53 + assert {orig[i] for i in j.loc[j["etape"] == "R1", "id_inscription"]} == dup_units and len(dup_units) == 18
54 + assert ct.loc["R4", "prix_symbolique"] == 5 and ct.loc["R5", "superficie_pi2"] == 12 and ct.loc["R7", "vente_liee_declaree"] == 8
55 + assert ct.loc["S1", "vente_liee_non_declaree"] == 4 and ct.loc["E1", "vente_liee_non_declaree"] == 2 and ct.loc["S1", "propre"] == 2
56 + assert set(P["regression"]["exclus"]["id_inscription"]) == {"B0039", "B0410"}
57 + assert set(P["scores"]["exclus"]["id_inscription"]) == {"B0003", "B0006", "B0021", "B0105", "B0173", "B0558"}
58 +
59 +def test_corrections_restituent_les_valeurs_propres(R):
60 + """Après R2, R3, R5 les valeurs corrigées coïncident avec le jeu propre pour les lignes concernées."""
61 + P = R["pipeline"]; d = P["propre"].set_index("id_vente_origine"); v = cas1.charger()["ventes"].set_index("id_vente")
62 + ver = R["verite"]
63 + for code, col in [("superficie_pi2", "superficie_habitable_m2"), ("meubles_inclus", "prix_vente")]:
64 + ids = [i for i in ver.loc[ver["anomalie"] == code, "id_vente_origine"] if i in d.index]
65 + assert len(ids) >= 7
66 + np.testing.assert_allclose(d.loc[ids, col].astype(float), v.loc[ids, col].astype(float), rtol=0, atol=0.51)
67 + ids = [i for i in ver.loc[ver["anomalie"] == "dates_inversees", "id_vente_origine"] if i in d.index]
68 + assert (pd.to_datetime(d.loc[ids, "date_vente"]).values == pd.to_datetime(v.loc[ids, "date_vente"]).values).all()
69 +
70 +def test_iterations_regression(R):
71 + it = R["pipeline"]["regression"]["iterations"]
72 + assert [x["n"] for x in it] == [581, 579] and [x["exclus"] for x in it] == [2, 0]
73 + assert it[0]["sigma"] == pytest.approx(0.0897, abs=5e-4) and it[1]["sigma"] == pytest.approx(0.0837, abs=5e-4)
74 + assert it[0]["cook_max"] < 1 and it[0]["n_cook_4n"] == 25 and it[0]["n_levier_2kn"] == 18
75 +
76 +# ----------------------------------------------------------------------------- identités des diagnostics
77 +
78 +def test_identites_levier_cook():
79 + v = cas1.charger()["ventes"]; X = cas1.matrice(v).values; y = np.log(v["prix_vente"].values)
80 + dg = c14.diagnostics(X, y)
81 + assert dg["somme_h"] == pytest.approx(dg["k"], rel=1e-9) # tr(H) = k
82 + assert (dg["h"] > 0).all() and (dg["h"] < 1).all()
83 + i = int(np.argmax(dg["cook"]))
84 + assert dg["cook"][i] == pytest.approx(c14.cook_direct(X, y, i), rel=1e-6) # D_i = r_i² h/(k(1−h)) = (β−β_(i))'X'X(β−β_(i))/(k s²)
85 + # résidu externe = résidu interne × sqrt((n−k−1)/(n−k−r²))
86 + n, k = dg["n"], dg["k"]; r = dg["r_int"]
87 + np.testing.assert_allclose(dg["t_ext"], r * np.sqrt((n - k - 1) / (n - k - r ** 2)), rtol=1e-9)
88 +
89 +def test_constante_mad_et_score():
90 + from scipy.stats import norm
91 + assert 1 / norm.ppf(0.75) == pytest.approx(1.4826, abs=1e-4)
92 + rng = np.random.default_rng(1); x = rng.normal(0, 2, 200000)
93 + mad = np.median(np.abs(x - np.median(x)))
94 + assert 1.4826 * mad == pytest.approx(2.0, rel=0.01) # convergence vers σ
95 + z = c14.z_robuste(np.array([100.0, 101, 99, 100, 102, 98, 100, 1000]))
96 + assert z[-1] > 100 and abs(z[:-1]).max() < 2.5 # une valeur extrême n'écrase pas les autres
97 +
98 +def test_masquage(R):
99 + t = R["masquage"]["tableau"]
100 + assert t.loc["Hull", "n"] == 193 and t.loc["Hull", "n_classique"] == 2 and t.loc["Hull", "n_robuste"] == 10 and t.loc["Hull", "n_prix_1"] == 2
101 + assert t.loc["Hull", "ecart_type"] == pytest.approx(1.395, abs=2e-3) and t.loc["Hull", "mad_x1.4826"] == pytest.approx(0.181, abs=2e-3)
102 + assert (t["n_robuste"] >= t["n_classique"]).all()
103 +
104 +# ----------------------------------------------------------------------------- effet sur le modèle
105 +
106 +def test_modeles_avant_apres(R):
107 + t = R["modeles"]["tableau"]
108 + brut = t.loc["brut (lignes complètes)"]; net = t.loc["après régression E1 (nettoyé)"]; ref = t.loc["jeu propre de référence"]
109 + assert brut["n"] == 573 and brut["R2"] == pytest.approx(0.039, abs=2e-3) and brut["sigma"] == pytest.approx(1.231, abs=2e-3)
110 + assert t.loc["après règles R1–R7 (+G1, I1)", "sigma"] == pytest.approx(0.1132, abs=5e-4) and t.loc["après scores robustes S1", "sigma"] == pytest.approx(0.0897, abs=5e-4)
111 + assert net["n"] == 579 and net["sigma"] == pytest.approx(0.0837, abs=5e-4) and net["R2"] == pytest.approx(0.850, abs=2e-3)
112 + assert ref["n"] == 600 and ref["sigma"] == pytest.approx(0.0830, abs=5e-4) and ref["R2"] == pytest.approx(0.853, abs=2e-3)
113 + assert net["ln_S"] == pytest.approx(0.5335, abs=5e-4) and ref["ln_S"] == pytest.approx(0.5391, abs=5e-4)
114 + assert net["prediction_sujet"] == pytest.approx(637017, abs=5) and ref["prediction_sujet"] == pytest.approx(637953, abs=5)
115 + assert brut["prediction_sujet"] == pytest.approx(565471, abs=5)
116 + # le modèle nettoyé retrouve la référence : tous les coefficients à moins de 0,5 erreur-type
117 + e = R["modeles"]["ecarts"]["ecart_en_se"].abs()
118 + assert e.max() < 0.5 and e.idxmax() in ("ln_S", "piscine")
119 +
120 +# ----------------------------------------------------------------------------- géocodage, appariement, imputation, sensibilité
121 +
122 +def test_geocodage(R):
123 + G = R["geocodage"]; t = G["tableau"]
124 + assert G["n_dans_rayon"] == 103
125 + assert list(t["erreur_mediane_m"].round(0)) == [6, 18, 47, 177, 1199]
126 + assert t.loc["point d'adresse", "pct_voisins_conserves"] == pytest.approx(99.1, abs=0.1)
127 + assert t.loc["centroïde de code postal (urbain)", "pct_voisins_conserves"] == pytest.approx(76.1, abs=0.1)
128 + assert t.loc["centroïde de code postal (périurbain)", "pct_voisins_conserves"] == pytest.approx(17.6, abs=0.1)
129 + assert t.loc["centroïde de code postal (périurbain)", "pct_rayon_change"] == pytest.approx(6.7, abs=0.1)
130 + assert t["erreur_mediane_m"].is_monotonic_increasing and t["pct_voisins_conserves"].is_monotonic_decreasing
131 + # erreur médiane théorique d'une loi de Rayleigh : σ·sqrt(2 ln 2) = 1,1774 σ
132 + np.testing.assert_allclose(t["erreur_mediane_m"] / (1000 * t["sigma_km"]), 1.1774, rtol=0.06)
133 + assert abs(t.loc["point d'adresse", "beta_dist"] - G["beta_dist_ref"]) < 1e-3
134 +
135 +def test_appariement(R):
136 + A = R["appariement"]
137 + assert A["taux"].to_dict() == {"1 exact": 367, "2 normalisé": 198, "3 attributs": 45, "non apparié": 8}
138 + assert A["pct"].to_dict() == {"1 exact": 59.4, "2 normalisé": 32.0, "3 attributs": 7.3, "non apparié": 1.3}
139 + assert A["ambigus"] == 3 and A["faux_appariements"] == 0
140 + assert c14.normaliser_matricule("05 94 6466 4") == "059464664" and c14.normaliser_matricule(float("nan")) == ""
141 +
142 +def test_imputations(R):
143 + I = R["imputations"]
144 + assert list(I.index) == ["suppression", "mediane", "regression", "indicateur"]
145 + assert I.loc["suppression", "n"] == 535 and (I.loc[["mediane", "regression", "indicateur"], "n"] == 579).all()
146 + assert I.loc["suppression", "ln_T"] == pytest.approx(0.0969, abs=5e-4) and I.loc["mediane", "ln_T"] == pytest.approx(0.0941, abs=5e-4)
147 + assert I.loc["regression", "ln_T"] == pytest.approx(0.0940, abs=5e-4) and I.loc["indicateur", "ln_T"] == pytest.approx(0.0945, abs=5e-4)
148 + assert I.loc["suppression", "prediction_sujet"] == pytest.approx(640921, abs=5) and I.loc["indicateur", "prediction_sujet"] == pytest.approx(638354, abs=5)
149 + assert I.loc["indicateur", "coef_manq_terrain"] == pytest.approx(-0.0215, abs=5e-4)
150 +
151 +def test_sensibilite_seuils(R):
152 + T = R["sensibilite"].set_index(["seuil_mad", "seuil_t"])
153 + assert len(T) == 12 and (T["liees_detectees"] == 6).all()
154 + assert T.loc[(3.5, 3.0), "faux_positifs"] == 2 and T.loc[(2.5, 2.5), "faux_positifs"] == 22 and T.loc[(4.0, 3.0), "faux_positifs"] == 0
155 + assert T.loc[(3.5, 3.0), "n_final"] == 579 and T.loc[(2.5, 2.5), "n_final"] == 546 and T.loc[(4.0, 3.5), "n_final"] == 581
156 + assert T.loc[(2.5, 2.5), "sigma"] == pytest.approx(0.0783, abs=5e-4) and T.loc[(4.0, 3.0), "sigma"] == pytest.approx(0.0838, abs=5e-4)
157 + assert T["prediction_sujet"].min() > 633000 and T["prediction_sujet"].max() < 639000
158 +
159 +# ----------------------------------------------------------------------------- solutions des exercices
160 +
161 +def test_solutions_exercices():
162 + # ex. 14.1 : scores robustes de neuf prix au m²
163 + x = np.array([3150.0, 3320, 3080, 3410, 3260, 3190, 3370, 3240, 5980])
164 + med = np.median(x); mad = np.median(np.abs(x - med))
165 + assert med == 3260 and mad == 110
166 + z = (x - med) / (1.4826 * mad)
167 + assert z[-1] == pytest.approx(16.68, abs=0.01) and abs(z[:-1]).max() == pytest.approx(1.10, abs=0.01)
168 + zc = (x - x.mean()) / x.std(ddof=1)
169 + assert x.mean() == pytest.approx(3555.6, abs=0.1) and x.std(ddof=1) == pytest.approx(915.2, abs=0.5) and zc[-1] == pytest.approx(2.65, abs=0.01)
170 + # ex. 14.2 : conversion 1 776 pi²
171 + assert 1776 / 10.7639 == pytest.approx(165.0, abs=0.05)
172 + # ex. 14.3 : levier et Cook
173 + n, k, s = 600, 16, 0.083; e, hii = -0.25, 0.04
174 + r = e / (s * math.sqrt(1 - hii)); D = r ** 2 / k * hii / (1 - hii)
175 + assert r == pytest.approx(-3.074, abs=0.002) and D == pytest.approx(0.0246, abs=0.0002) and D > 4 / n
176 + t = r * math.sqrt((n - k - 1) / (n - k - r ** 2)); assert t == pytest.approx(-3.097, abs=0.002)
177 + # ex. 14.4 : contamination à 50 % du marché
178 + eps, delta = 0.05, math.log(0.5)
179 + assert eps * delta == pytest.approx(-0.0347, abs=1e-4) and (math.exp(eps * delta) - 1) == pytest.approx(-0.0341, abs=1e-4)
180 + assert math.sqrt(0.085 ** 2 + eps * (1 - eps) * delta ** 2) == pytest.approx(0.173, abs=1e-3)
181 + # ex. 14.5 : poids de Fellegi–Sunter
182 + m = [0.98, 0.95, 0.90]; u = [0.001, 0.05, 0.20]
183 + w_acc = [math.log2(a / b) for a, b in zip(m, u)]; w_dis = [math.log2((1 - a) / (1 - b)) for a, b in zip(m, u)]
184 + assert sum(w_acc) == pytest.approx(16.35, abs=0.05) and (w_acc[0] + w_acc[1] + w_dis[2]) == pytest.approx(11.2, abs=0.05)
185 + assert (w_dis[0] + w_acc[1] + w_acc[2]) == pytest.approx(0.78, abs=0.05)
modified analysis/python/tests/test_ch22.py +41 −38
@@ -43,7 +43,7 @@ class TestGeometrie:
43 43 e = data["e"]
44 44 assert e["aire_au_sol"] == pytest.approx(95.04, abs=1e-9)
45 45 assert e["perimetre"] == pytest.approx(39.84, abs=1e-9)
46 − assert e["garage"] == pytest.approx(24.55, abs=0.005) and e["aire_sous_sol"] == pytest.approx(70.49, abs=0.005)
46 + assert e["garage"] == pytest.approx(24.95, abs=0.005) and e["aire_sous_sol"] == pytest.approx(70.09, abs=0.005)
47 47 # invariance du périmètre d'une forme en L découpée dans un rectangle
48 48 L, W, gx, gy = cas.PLAN["L"], cas.PLAN["W"], cas.PLAN["gx"], cas.PLAN["gy"]
49 49 perim_L = (L - gx) + gy + gx + (W - gy) + L + W # parcours de la forme en L
@@ -53,26 +53,26 @@ class TestGeometrie:
53 53 S = data["S"]
54 54 assert S["aire_etages_mefq"] == pytest.approx(190.08, abs=1e-9)
55 55 assert S["perimetre_etages_mefq"] == pytest.approx(79.68, abs=1e-9)
56 − assert S["habitable"] == pytest.approx(165.53, abs=0.005)
56 + assert S["habitable"] == pytest.approx(165.13, abs=0.005)
57 57 assert round(S["habitable"]) == data["sujet"]["superficie_habitable_m2"]
58 − assert S["nette_hors_sol"] == pytest.approx(135.73, abs=Q)
59 − assert S["nette_rez"] == pytest.approx(58.93, abs=Q) and S["nette_etage"] == pytest.approx(76.81, abs=Q)
60 − assert S["sous_sol_net"] == pytest.approx(62.96, abs=Q) and S["sous_sol_fini_net"] == pytest.approx(53.83, abs=Q)
58 + assert S["nette_hors_sol"] == pytest.approx(135.37, abs=Q)
59 + assert S["nette_rez"] == pytest.approx(58.56, abs=Q) and S["nette_etage"] == pytest.approx(76.81, abs=Q)
60 + assert S["sous_sol_net"] == pytest.approx(58.65, abs=Q) and S["sous_sol_fini_net"] == pytest.approx(49.31, abs=Q)
61 61 assert S["aire_etages_mefq"] * c21.PI2_PAR_M2 == pytest.approx(2046, abs=0.6)
62 − assert S["habitable"] * c21.PI2_PAR_M2 == pytest.approx(1782, abs=0.6)
62 + assert S["habitable"] * c21.PI2_PAR_M2 == pytest.approx(1777, abs=0.6)
63 63
64 64 def test_pieces_couvrent_les_niveaux(self, data):
65 65 pcs = data["S"]["pieces"]
66 66 assert pcs.loc[pcs["niveau"] == "rez", "brute"].sum() == pytest.approx(95.04, abs=1e-6)
67 67 assert pcs.loc[pcs["niveau"] == "etage", "brute"].sum() == pytest.approx(95.04, abs=1e-6)
68 − assert pcs.loc[pcs["niveau"] == "soussol", "brute"].sum() == pytest.approx(70.49, abs=0.01)
68 + assert pcs.loc[pcs["niveau"] == "soussol", "brute"].sum() == pytest.approx(70.09, abs=0.01)
69 69 assert (pcs["nette"] > 0).all()
70 70
71 71 def test_cloisons(self):
72 − assert cas.cloisons("rez") == pytest.approx(21.62, abs=Q)
72 + assert cas.cloisons("rez") == pytest.approx(21.52, abs=Q)
73 73 assert cas.cloisons("etage") == pytest.approx(47.94, abs=Q)
74 − assert cas.cloisons("soussol") == pytest.approx(17.12, abs=Q)
75 − assert cas.separation_garage() == pytest.approx(10.16, abs=1e-9)
74 + assert cas.cloisons("soussol") == pytest.approx(20.80, abs=Q)
75 + assert cas.separation_garage() == pytest.approx(10.26, abs=1e-9)
76 76
77 77 def test_formules_elementaires(self):
78 78 # aire décalée d'un rectangle = formule exacte
@@ -88,11 +88,11 @@ class TestGeometrie:
88 88 class TestQuantites:
89 89 def test_mesures_citees(self, data):
90 90 q = data["q"]
91 − attendu = {"Excavation et remblai": 409.81, "Fondations béton (semelles, murs)": 36.74, "Dalle de sous-sol": 95.04,
92 − "Drain français et imperméabilisation": 42.24, "Charpente murs et planchers": 325.05, "Fermes de toit": 111.62,
91 + attendu = {"Excavation et remblai": 408.94, "Fondations béton (semelles, murs)": 36.79, "Dalle de sous-sol": 95.04,
92 + "Drain français et imperméabilisation": 42.24, "Charpente murs et planchers": 324.65, "Fermes de toit": 111.62,
93 93 "Couverture bardeaux d'asphalte": 124.79, "Revêtement extérieur (brique 40 % / fibrociment 60 %)": 238.79,
94 − "Isolation et pare-vapeur": 419.18, "Gypse et tirage de joints": 740.32, "Peinture intérieure": 740.32,
95 − "Planchers bois franc": 103.56, "Planchers céramique": 32.17, "Planchers flottants (sous-sol)": 70.49,
94 + "Isolation et pare-vapeur": 419.82, "Gypse et tirage de joints": 733.00, "Peinture intérieure": 733.00,
95 + "Planchers bois franc": 103.56, "Planchers céramique": 31.81, "Planchers flottants (sous-sol)": 70.09,
96 96 "Gouttières et solins": 48.80, "Balcon et perron": 14.10, "Entrée asphaltée": 55.00}
97 97 for k, v in attendu.items():
98 98 assert q[k]["q"] == pytest.approx(v, abs=Q), k
@@ -100,20 +100,20 @@ class TestQuantites:
100 100 def test_details_cites(self, data):
101 101 q = data["q"]
102 102 d = q["Charpente murs et planchers"]["detail"]
103 − assert d["planchers"] == pytest.approx(165.53, abs=Q) and d["murs_brut"] == pytest.approx(194.42, abs=Q)
103 + assert d["planchers"] == pytest.approx(165.13, abs=Q) and d["murs_brut"] == pytest.approx(194.42, abs=Q)
104 104 assert d["ouvertures"] == pytest.approx(34.90, abs=Q) and d["murs_net"] == pytest.approx(159.52, abs=Q)
105 105 f = q["Fondations béton (semelles, murs)"]["detail"]
106 106 assert f["L_axe"] == pytest.approx(38.84, abs=1e-9) and f["V_murs"] == pytest.approx(23.69, abs=Q) and f["V_semelles"] == pytest.approx(6.99, abs=Q)
107 107 x = q["Excavation et remblai"]["detail"]
108 − assert x["A_bas"] == pytest.approx(109.58, abs=Q) and x["A_mi"] == pytest.approx(141.85, abs=Q) and x["A_haut"] == pytest.approx(177.50, abs=Q)
109 − assert x["V_sous_sol"] == pytest.approx(370.27, abs=Q) and x["V_garage"] == pytest.approx(15.24, abs=Q) and x["V_racc"] == pytest.approx(24.30, abs=Q)
108 + assert x["A_bas"] == pytest.approx(109.19, abs=Q) and x["A_mi"] == pytest.approx(141.45, abs=Q) and x["A_haut"] == pytest.approx(177.10, abs=Q)
109 + assert x["V_sous_sol"] == pytest.approx(369.25, abs=Q) and x["V_garage"] == pytest.approx(15.39, abs=Q) and x["V_racc"] == pytest.approx(24.30, abs=Q)
110 110 g = q["Gypse et tirage de joints"]["detail"]
111 − assert g["plafonds"] == pytest.approx(214.11, abs=Q) and g["murs"] == pytest.approx(585.60, abs=Q)
112 − assert g["sep_garage"] == pytest.approx(24.79, abs=Q) and g["ouvertures"] == pytest.approx(84.19, abs=Q)
111 + assert g["plafonds"] == pytest.approx(209.63, abs=Q) and g["murs"] == pytest.approx(582.53, abs=Q)
112 + assert g["sep_garage"] == pytest.approx(25.03, abs=Q) and g["ouvertures"] == pytest.approx(84.19, abs=Q)
113 113 r = q["Revêtement extérieur (brique 40 % / fibrociment 60 %)"]["detail"]
114 114 assert r["murs"] == pytest.approx(223.10, abs=Q) and r["pignons"] == pytest.approx(15.68, abs=Q)
115 115 i = q["Isolation et pare-vapeur"]["detail"]
116 − assert i["fondation"] == pytest.approx(91.37, abs=Q) and i["rive"] == pytest.approx(23.90, abs=Q) and i["sep_murs"] == pytest.approx(24.79, abs=Q)
116 + assert i["fondation"] == pytest.approx(91.37, abs=Q) and i["rive"] == pytest.approx(23.90, abs=Q) and i["sep_murs"] == pytest.approx(25.03, abs=Q)
117 117
118 118 def test_denombrements(self, data):
119 119 q = data["q"]
@@ -134,26 +134,26 @@ class TestVerification:
134 134
135 135 def test_ecarts_cites(self, data):
136 136 V = data["V"].set_index("poste")
137 − assert V.loc["Gypse et tirage de joints", "ecart"] == pytest.approx(0.194, abs=5e-4)
137 + assert V.loc["Gypse et tirage de joints", "ecart"] == pytest.approx(0.182, abs=5e-4)
138 138 assert V.loc["Planchers bois franc", "ecart"] == pytest.approx(0.090, abs=5e-4)
139 − assert V.loc["Planchers céramique", "ecart"] == pytest.approx(-0.081, abs=5e-4)
140 − assert V.loc["Excavation et remblai", "ecart"] == pytest.approx(-0.024, abs=5e-4)
141 − assert V.loc["Fondations béton (semelles, murs)", "ecart"] == pytest.approx(-0.033, abs=5e-4)
142 − assert V.loc["Charpente murs et planchers", "ecart"] == pytest.approx(-0.015, abs=5e-4)
143 − assert V.loc["Gypse et tirage de joints", "delta_montant"] == pytest.approx(3305.12, abs=0.01)
144 − assert V.loc["Peinture intérieure", "delta_montant"] == pytest.approx(1217.61, abs=0.01)
139 + assert V.loc["Planchers céramique", "ecart"] == pytest.approx(-0.091, abs=5e-4)
140 + assert V.loc["Excavation et remblai", "ecart"] == pytest.approx(-0.026, abs=5e-4)
141 + assert V.loc["Fondations béton (semelles, murs)", "ecart"] == pytest.approx(-0.032, abs=5e-4)
142 + assert V.loc["Charpente murs et planchers", "ecart"] == pytest.approx(-0.016, abs=5e-4)
143 + assert V.loc["Gypse et tirage de joints", "delta_montant"] == pytest.approx(3104.20, abs=0.01)
144 + assert V.loc["Peinture intérieure", "delta_montant"] == pytest.approx(1143.59, abs=0.01)
145 145 # planchers finis hors sol : total conforme même si la répartition diffère
146 146 tot_calc = V.loc["Planchers bois franc", "q_calc"] + V.loc["Planchers céramique", "q_calc"]
147 147 tot_devis = V.loc["Planchers bois franc", "q_devis"] + V.loc["Planchers céramique", "q_devis"]
148 − assert tot_calc == pytest.approx(135.73, abs=Q) and tot_devis == 130 and abs(tot_calc / tot_devis - 1) < 0.05
148 + assert tot_calc == pytest.approx(135.37, abs=Q) and tot_devis == 130 and abs(tot_calc / tot_devis - 1) < 0.05
149 149
150 150 def test_effet_metre_controle(self, data):
151 151 eff = cas.effet_metre_controle(data["devis"], data["V"])
152 − assert eff["delta_D"] == pytest.approx(3767.56, abs=0.01)
153 − assert eff["D2"] == pytest.approx(302593.73, abs=0.01)
154 − assert eff["CN2"] == pytest.approx(401711.66, abs=0.01)
155 − assert eff["delta_CN"] == pytest.approx(4144.32, abs=0.01)
156 − assert eff["part_D"] == pytest.approx(0.0126, abs=5e-5) and eff["part_CN"] == pytest.approx(0.0104, abs=5e-5)
152 + assert eff["delta_D"] == pytest.approx(3410.22, abs=0.01)
153 + assert eff["D2"] == pytest.approx(302236.39, abs=0.01)
154 + assert eff["CN2"] == pytest.approx(401318.58, abs=0.01)
155 + assert eff["delta_CN"] == pytest.approx(3751.24, abs=0.01)
156 + assert eff["part_D"] == pytest.approx(0.0114, abs=5e-5) and eff["part_CN"] == pytest.approx(0.0094, abs=5e-5)
157 157 assert eff["mult"] == pytest.approx(1.3304, abs=5e-5)
158 158 # identité : ΔC_N = ΔD × (1 + τE) lorsque les indirects sont fixés en dollars
159 159 assert eff["delta_CN"] == pytest.approx(eff["delta_D"] * 1.10, rel=1e-9)
@@ -201,7 +201,7 @@ class TestExercices:
201 201 assert gx * gy == pytest.approx(22.33, abs=0.005) and hab == pytest.approx(167.99, abs=0.005)
202 202 assert 2 * (L + W) == pytest.approx(39.10, abs=1e-9)
203 203 net = 2 * (L - 0.6) * (W - 0.6) - (gx - 0.3 - 0.075) * (gy - 0.3 - 0.075)
204 − assert net == pytest.approx(144.81, abs=0.01)
204 + assert net == pytest.approx(148.77, abs=0.01)
205 205 assert etages * 10.7639 == pytest.approx(2048.6, abs=0.1) and hab * 10.7639 == pytest.approx(1808.2, abs=0.1)
206 206
207 207 def test_ex2_murs_nets(self):
@@ -225,8 +225,9 @@ class TestExercices:
225 225 Am1 = cas.aire_decalee(A, P, d + 1.0 * h / 2); At1 = cas.aire_decalee(A, P, d + 1.0 * h)
226 226 V1 = cas.volume_prismoidal(Ab, Am1, At1, h)
227 227 assert Ab == pytest.approx(133.59, abs=0.01) and rect == pytest.approx(333.98, abs=0.01)
228 − assert V == pytest.approx(400.88, abs=0.01) and V1 == pytest.approx(474.12, abs=0.01)
229 − assert V / rect - 1 == pytest.approx(0.200, abs=5e-4) and V1 / rect - 1 == pytest.approx(0.420, abs=5e-4)
228 + assert Am == pytest.approx(164.09, abs=0.01) and At == pytest.approx(197.72, abs=0.01)
229 + assert V == pytest.approx(411.53, abs=0.01) and V1 == pytest.approx(499.50, abs=0.01)
230 + assert V / rect - 1 == pytest.approx(0.2322, abs=5e-4) and V1 / rect - 1 == pytest.approx(0.4956, abs=5e-4)
230 231
231 232 def test_ex5_prix_unitaire(self):
232 233 m, w, hpu, s, e = 11.50, 0.12, 0.35, 62.0, 0.80
@@ -257,5 +258,7 @@ class TestExercices:
257 258 ecart = gypse / 300 - 1; assert ecart == pytest.approx(0.1825, abs=5e-4)
258 259 delta = (gypse - 300) * 27.47 + (gypse - 300) * 10.12
259 260 assert delta == pytest.approx(2058.3, abs=0.5)
260 − toit = (L + 0.8) * (W + 0.8) * math.sqrt(1.25); assert toit == pytest.approx(162.19, abs=0.01)
261 − assert toit / 150 - 1 == pytest.approx(0.0813, abs=5e-4)
261 + toit = (L + 0.8) * (W + 0.8) * math.sqrt(1.25); assert toit == pytest.approx(162.16, abs=0.01)
262 + assert toit / 150 - 1 == pytest.approx(0.0811, abs=5e-4)
263 + total = delta + (toit - 150) * 61.44
264 + assert total == pytest.approx(2805.52, abs=0.01) and total * 1.3304 == pytest.approx(3732.46, abs=0.5)
added analysis/python/tests/test_ch23.py +280 −0
@@ -0,0 +1,280 @@
1 +"""Tests du chapitre 23 — coûts directs et indirects : structure du devis du cas 1, financement intérimaire (traite.finance),
2 +taxes de vente, incitatif, IPCB par région/type/division (données réelles), multilogement (cas 3) et exercices.
3 +Tolérances : 0,01 $ sur les montants recalculés depuis le devis ; 0,5 $ (ou 1 $ sur les milliers arrondis) sur les montants cités ;
4 +1e-9 sur les identités ; valeurs IPCB exactes (lecture du CSV brut de Statistique Canada)."""
5 +import sys, pathlib, math
6 +import numpy as np, pytest
7 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))
8 +import ch21_cout_neuf as c21
9 +import ch23_structure_couts as cas
10 +from traite import cout as ct, finance as fin
11 +
12 +
13 +@pytest.fixture(scope="module")
14 +def data():
15 + devis, sujet = c21.charger()
16 + cn = c21.cout_neuf_sujet(devis)
17 + sup = c21.superficies(sujet, devis)
18 + return {"devis": devis, "sujet": sujet, "cn": cn, "sup": sup, "st": cas.structure(devis, cn), "ri": cas.ratios_indirects(devis, cn),
19 + "i": cas.taux_affiche_5ans(), "ipcb": cas.ipcb_long(), "ipcb21": c21.ipcb_montreal()}
20 +
21 +
22 +class TestStructure:
23 + def test_coherence_ch21(self, data):
24 + cn = data["cn"]
25 + assert cn["direct"] == pytest.approx(298826.17, abs=0.01) and cn["indirects"] == pytest.approx(62598.68, abs=0.01)
26 + assert cn["incitatif"] == pytest.approx(36142.49, abs=0.01) and cn["total"] == pytest.approx(397567.33, abs=0.01)
27 + assert cn["taux_indirects"] == pytest.approx(0.2095, abs=5e-5)
28 +
29 + def test_structure_complete(self, data):
30 + st, cn = data["st"], data["cn"]
31 + assert len(st) == 38 and st["nature"].notna().all()
32 + assert st.loc[~st["indirect"], "part_bloc"].sum() == pytest.approx(1.0, abs=1e-9)
33 + assert st.loc[st["indirect"], "part_bloc"].sum() == pytest.approx(1.0, abs=1e-9)
34 + assert st["part_CN"].sum() == pytest.approx((cn["direct"] + cn["indirects"]) / cn["total"], abs=1e-9)
35 + assert st["part_CN"].sum() + cn["incitatif"] / cn["total"] == pytest.approx(1.0, abs=1e-9)
36 + sup_ = st[st["poste"] == "Supervision de chantier (salaires)"].iloc[0]
37 + assert sup_["part_bloc"] == pytest.approx(0.5704, abs=5e-4) and sup_["part_CN"] == pytest.approx(0.0898, abs=5e-4)
38 +
39 + def test_ratios_indirects(self, data):
40 + ri = data["ri"]
41 + attendu = {"Supervision de chantier (salaires)": (35708.66, 11.95, 9.88, 8.98), "Plans et honoraires professionnels": (10119.90, 3.39, 2.80, 2.55),
42 + "Financement intérimaire": (8529.63, 2.85, 2.36, 2.15), "Permis et études (arpentage, sol)": (4481.67, 1.50, 1.24, 1.13),
43 + "Assurances chantier et cautionnement": (3758.82, 1.26, 1.04, 0.95)}
44 + for _, r in ri.iterrows():
45 + m, a, b, c = attendu[r["poste"]]
46 + assert r["montant"] == pytest.approx(m, abs=0.01)
47 + assert r["pct_D"] * 100 == pytest.approx(a, abs=0.005) and r["pct_DI"] * 100 == pytest.approx(b, abs=0.005) and r["pct_CN"] * 100 == pytest.approx(c, abs=0.005)
48 + assert ri["pct_D"].sum() == pytest.approx(0.2095, abs=5e-5) and ri["pct_DI"].sum() == pytest.approx(0.1732, abs=5e-5) and ri["pct_CN"].sum() == pytest.approx(0.1575, abs=5e-5)
49 + assert (ri["pct_D"].sum() - 0.1195) * 100 == pytest.approx(9.00, abs=0.01)
50 +
51 + def test_parts_nature(self, data):
52 + pn = cas.parts_nature(data["st"])
53 + assert pn.sum() == pytest.approx(data["cn"]["direct"], abs=0.01)
54 + assert pn["mixte"] / data["cn"]["direct"] == pytest.approx(0.645, abs=5e-4)
55 + assert pn["matériaux"] / data["cn"]["direct"] == pytest.approx(0.217, abs=5e-4)
56 + assert pn["main-d'œuvre"] / data["cn"]["direct"] == pytest.approx(0.078, abs=5e-4)
57 +
58 +
59 +class TestFinancement:
60 + def test_taux_documentes(self, data):
61 + assert data["i"] == pytest.approx(0.0619, abs=1e-9)
62 + fa = cas.taux_fonds_avances()
63 + assert fa["non_assure_fixe_5ans_plus"] == pytest.approx(0.0458, abs=1e-9) and fa["non_assure_total"] == pytest.approx(0.0434, abs=1e-9)
64 + assert fa["assure_total"] == pytest.approx(0.0419, abs=1e-9) and fa["non_assure_variable"] == pytest.approx(0.0390, abs=1e-9)
65 +
66 + def test_formules_interets(self):
67 + # continu : L i T / 2 ; mensuel sans capitalisation : L i T / 2 × (n + 1) / n ; capitalisation > simple ; mise de fonds d'abord
68 + L, i, T = 100000.0, 0.06, 1.0
69 + c = fin.interets_intermediaires(L, i, T)["interets"]
70 + assert c == pytest.approx(3000.0, abs=1e-9)
71 + m = fin.interets_intermediaires(L, i, T, "mensuel")["interets"]
72 + assert m == pytest.approx(3000.0 * 13 / 12, abs=1e-9)
73 + k = fin.interets_intermediaires(L, i, T, "mensuel", capitaliser=True)["interets"]
74 + assert k > m
75 + e = fin.interets_intermediaires(0.75 * L, i, T, "continu", mise_de_fonds=0.25 * L, cout_total=L)
76 + assert e["t0"] == pytest.approx(0.25, abs=1e-12) and e["interets"] == pytest.approx(0.75 * L * i * 0.75 / 2, abs=1e-9)
77 + assert fin.taux_duree_implicites(3000.0, L) == pytest.approx(0.06, abs=1e-12)
78 + with pytest.raises(ValueError):
79 + fin.interets_intermediaires(L, i, 0.0)
80 +
81 + def test_financement_cas1(self, data):
82 + fc = cas.financement_cas1(data["devis"], data["cn"], data["i"])
83 + assert fc["forfait"] == pytest.approx(8529.63, abs=0.01) and fc["base_hors_financement"] == pytest.approx(352895.22, abs=0.01)
84 + assert fc["interets_continu"] == pytest.approx(5461.05, abs=0.01) and fc["interets_mensuel"] == pytest.approx(6371.23, abs=0.01)
85 + assert fc["interets_capitalise"] == pytest.approx(6426.29, abs=0.01)
86 + assert fc["iT_implicite"] == pytest.approx(0.04834, abs=5e-6) and fc["duree_implicite_mois"] == pytest.approx(9.4, abs=0.05)
87 + assert fc["taux_implicite_6mois"] == pytest.approx(0.0967, abs=5e-5)
88 + cn6 = cas.cout_neuf_avec_financement(data["devis"], data["cn"], fc["interets_continu"])
89 + assert cn6["total"] == pytest.approx(394191.90, abs=0.01) and cn6["total"] - data["cn"]["total"] == pytest.approx(-3375.43, abs=0.01)
90 + # identité : remplacer le forfait par lui-même redonne le coût neuf du ch. 21
91 + assert cas.cout_neuf_avec_financement(data["devis"], data["cn"], fc["forfait"])["total"] == pytest.approx(data["cn"]["total"], rel=1e-12)
92 +
93 + def test_grille(self, data):
94 + fc = cas.financement_cas1(data["devis"], data["cn"], data["i"])
95 + G = cas.grille_financement(fc["base_hors_financement"], [6, 9, 12, 18], [0.04, 0.05, 0.0619, 0.07, 0.09])
96 + assert G[0, 0] == pytest.approx(3529, abs=1) and G[0, 2] == pytest.approx(5461, abs=1) and G[3, 4] == pytest.approx(23820, abs=1)
97 + cn18 = cas.cout_neuf_avec_financement(data["devis"], data["cn"], G[3, 4])["total"]
98 + assert cn18 == pytest.approx(414387, abs=1)
99 + # linéarité en durée et en taux
100 + assert G[2, 0] == pytest.approx(2 * G[0, 0], rel=1e-12) and G[0, 4] == pytest.approx(G[0, 0] * 9 / 4, rel=1e-12)
101 +
102 +
103 +class TestTaxes:
104 + def test_profils(self, data):
105 + tp = cas.taxes_profils(data["cn"]["total"])
106 + occ, prem, inv = (tp.iloc[k] for k in range(3))
107 + assert occ["prix_avant_taxes"] == pytest.approx(577567.33, abs=0.01)
108 + assert occ["tps"] == pytest.approx(28878.37, abs=0.01) and occ["tvq"] == pytest.approx(57612.34, abs=0.01)
109 + assert occ["remb_tps"] == 0.0 and occ["remb_tvq"] == 0.0 and occ["taxes_nettes"] == pytest.approx(86490.71, abs=0.01)
110 + assert occ["cout_ttc"] == pytest.approx(664058, abs=1) and occ["taux_effectif"] == pytest.approx(0.14975, abs=1e-9)
111 + assert prem["remb_tps"] == pytest.approx(prem["tps"], rel=1e-12) and prem["taxes_nettes"] == pytest.approx(57612.34, abs=0.01)
112 + assert prem["cout_ttc"] == pytest.approx(635180, abs=1)
113 + assert inv["taxes_nettes"] == 0.0 and inv["cout_ttc"] == pytest.approx(577567.33, abs=0.01)
114 + assert data["cn"]["total"] * (cas.TPS + cas.TVQ) == pytest.approx(59536, abs=1)
115 +
116 + def test_remboursements(self):
117 + r = cas.remboursement_tps(340000.0)
118 + assert r["remboursement_general"] == pytest.approx(6120.0, abs=1e-9) and not r["interpolation_appliquee"]
119 + assert cas.remboursement_tps(350000.0)["remboursement_general"] == pytest.approx(6300.0, abs=1e-9)
120 + r4 = cas.remboursement_tps(400000.0)
121 + assert r4["remboursement_general"] == pytest.approx(3150.0, abs=1e-9) and r4["interpolation_appliquee"]
122 + assert cas.remboursement_tps(450000.0)["remboursement_general"] == 0.0 and cas.remboursement_tps(600000.0)["remboursement_general"] == 0.0
123 + assert cas.remboursement_tps(900000.0, premier_acheteur=True)["remboursement"] == pytest.approx(45000.0, abs=1e-9)
124 + assert cas.remboursement_tps(1250000.0, premier_acheteur=True)["remboursement"] == pytest.approx(0.5 * 62500.0, abs=1e-9)
125 + assert cas.remboursement_tps(1500000.0, premier_acheteur=True)["remboursement"] == 0.0
126 + assert cas.remboursement_tps(900000.0, premier_acheteur=True, travaux_apres_20mars2025=False)["remboursement"] == 0.0
127 + assert cas.remboursement_tvq(250000.0)["remboursement"] == pytest.approx(4987.5, abs=1e-9)
128 + assert cas.remboursement_tvq(150000.0)["remboursement"] == pytest.approx(0.5 * 0.09975 * 150000.0, abs=1e-9)
129 + assert cas.remboursement_tvq(300000.0)["remboursement"] == 0.0
130 +
131 +
132 +class TestIncitatif:
133 + def test_marges(self):
134 + assert fin.marge_sur_prix(0.10) == pytest.approx(1 / 11, abs=1e-12)
135 + assert fin.marge_sur_cout(fin.marge_sur_prix(0.137)) == pytest.approx(0.137, abs=1e-12)
136 + with pytest.raises(ValueError):
137 + fin.marge_sur_cout(1.0)
138 +
139 + def test_rendement_implicite(self, data):
140 + cn = data["cn"]
141 + rho = cas.incitatif_rendement_implicite(cn["incitatif"], 180000.0, cn["direct"] + cn["indirects"], 0.75)
142 + assert rho == pytest.approx(0.134, abs=5e-4)
143 + assert cas.incitatif_rendement_implicite(cn["incitatif"], 180000.0, cn["direct"] + cn["indirects"], 0.5) == pytest.approx(0.200, abs=5e-4)
144 +
145 + def test_hausse_salariale(self, data):
146 + D = data["cn"]["direct"]
147 + assert cas.effet_hausse_salariale(D, 0.40, 0.08) == pytest.approx(9562, abs=1)
148 + assert cas.effet_hausse_salariale(D, 0.35, 0.08) == pytest.approx(8367, abs=1) and cas.effet_hausse_salariale(D, 0.50, 0.08) == pytest.approx(11953, abs=1)
149 + assert cas.effet_hausse_salariale(D, 0.40, 0.08) * (1 + data["cn"]["taux_indirects"]) * 1.10 == pytest.approx(12722, abs=1)
150 +
151 +
152 +class TestIPCB:
153 + def test_couverture(self, data):
154 + df = data["ipcb"]
155 + q = df[(df["geo"] == "Québec, Québec") & (df["type"] == "Bâtiments résidentiels [621]")]
156 + assert q["periode"].iloc[0] == "2023-01" and len(q) == 15
157 + m = df[(df["geo"] == "Montréal, Québec") & (df["type"] == "Maison individuelle [621112]")]
158 + assert m["periode"].iloc[0] == "2017-01" and m["periode"].iloc[-1] == "2026-07"
159 +
160 + def test_valeurs_tableau(self, data):
161 + df = data["ipcb"]; v = cas.val
162 + assert v(df, "Montréal, Québec", "Bâtiments résidentiels [621]", "2026-07") == 117.8 and v(df, "Montréal, Québec", "Bâtiments résidentiels [621]", "2025-07") == 108.5
163 + assert v(df, "Montréal, Québec", "Maison individuelle [621112]", "2026-07") == 116.5 and v(df, "Montréal, Québec", "Maison en rangée [621112]", "2026-07") == 116.2
164 + assert v(df, "Montréal, Québec", "Immeuble à hauteur restreinte (moins de cinq étages) [62111]", "2026-07") == 118.5
165 + assert v(df, "Montréal, Québec", "Tour d'habitations (cinq étages ou plus) [621111]", "2026-07") == 119.5
166 + assert v(df, "Montréal, Québec", "Immeubles d'appartements", "2026-07") == 118.2 and v(df, "Montréal, Québec", "Bâtiments non résidentiels [622]", "2026-07") == 114.4
167 + assert v(df, "Québec, Québec", "Bâtiments résidentiels [621]", "2026-07") == 124.8 and v(df, "Québec, Québec", "Bâtiments résidentiels [621]", "2023-01") == 98.8
168 + assert v(df, "Ottawa–Gatineau, partie ontarienne, Ontario/Québec", "Bâtiments résidentiels [621]", "2026-07") == 110.8
169 + assert v(df, "Composite de quinze régions métropolitaines de recensement", "Bâtiments résidentiels [621]", "2026-07") == 111.4
170 +
171 + def test_variations(self, data):
172 + tt = cas.tableau_types_regions(data["ipcb"]).set_index(["geo", "type"])
173 + r = tt.loc[("Montréal, Québec", "Bâtiments résidentiels [621]")]
174 + assert r["var_aa"] == pytest.approx(0.0857, abs=5e-4) and r["var_2020"] == pytest.approx(0.620, abs=5e-4) and r["depuis_debut"] == pytest.approx(0.798, abs=5e-4)
175 + assert tt.loc[("Montréal, Québec", "Immeuble à hauteur restreinte (moins de cinq étages) [62111]"), "var_aa"] == pytest.approx(0.092, abs=5e-4)
176 + assert tt.loc[("Québec, Québec", "Bâtiments résidentiels [621]"), "var_aa"] == pytest.approx(0.092, abs=5e-4)
177 + assert tt.loc[("Québec, Québec", "Bâtiments résidentiels [621]"), "depuis_debut"] == pytest.approx(0.263, abs=5e-4)
178 + assert tt.loc[("Ottawa–Gatineau, partie ontarienne, Ontario/Québec", "Bâtiments résidentiels [621]"), "var_aa"] == pytest.approx(0.046, abs=5e-4)
179 + assert tt.loc[("Composite de quinze régions métropolitaines de recensement", "Bâtiments résidentiels [621]"), "var_aa"] == pytest.approx(0.027, abs=5e-4)
180 + va = cas.variations_annuelles(data["ipcb"])
181 + mi = va[(va["geo"] == "Montréal, Québec") & (va["type"] == "Maison individuelle [621112]")]
182 + assert mi.loc[mi["var_aa"].idxmax(), "periode"] == "2022-01" and mi["var_aa"].max() == pytest.approx(0.189, abs=5e-4)
183 + assert mi.loc[mi["var_aa"].idxmin(), "periode"] == "2024-01" and mi["var_aa"].min() == pytest.approx(0.009, abs=5e-4)
184 +
185 + def test_divisions(self):
186 + df_div = cas.ipcb_long(("Montréal, Québec",), ("Maison individuelle [621112]",), None)
187 + dv = cas.divisions_maison(df_div).set_index("division")
188 + assert dv.loc["Bois, plastiques et composites", "var_2020_2022"] == pytest.approx(0.851, abs=5e-4) and dv.loc["Bois, plastiques et composites", "var_aa"] == pytest.approx(0.087, abs=5e-4)
189 + assert dv.loc["Béton", "var_2020_2022"] == pytest.approx(0.290, abs=5e-4) and dv.loc["Béton", "var_aa"] == pytest.approx(0.154, abs=5e-4)
190 + assert dv.loc["Agrégat des divisions", "var_2020_2022"] == pytest.approx(0.360, abs=5e-4) and dv.loc["Agrégat des divisions", "var_aa"] == pytest.approx(0.086, abs=5e-4)
191 + assert dv.loc["Électricité", "var_2020_2022"] == pytest.approx(0.048, abs=5e-4) and dv.loc["Terrassements", "var_aa"] == pytest.approx(0.017, abs=5e-4)
192 + assert dv.loc["Exigences générales", "var_aa"] == pytest.approx(0.062, abs=5e-4) and dv.loc["Maçonnerie", "var_aa"] == pytest.approx(0.116, abs=5e-4)
193 + assert len(dv) == 18 and dv.index[0] == "Bois, plastiques et composites"
194 +
195 +
196 +class TestMultilogement:
197 + def test_cas3(self, data):
198 + ml = cas.multilogement_cas3(data["devis"], data["cn"], data["sup"], data["ipcb21"], data["i"])
199 + assert ml["n_logements"] == 12 and ml["s_locative"] == 825 and ml["s_brute"] == pytest.approx(970.6, abs=0.05)
200 + assert ml["ratio_ipcb"] == pytest.approx(118.5 / 116.5, rel=1e-12) and ml["cu_direct_cas1"] == pytest.approx(1572.77, abs=0.01)
201 + assert ml["cu_direct"] == pytest.approx(1599.77, abs=0.01) and ml["direct"] == pytest.approx(1552717.60, abs=0.01)
202 + assert ml["indirects_detail"]["Supervision de chantier (salaires)"] == pytest.approx(185544.21, abs=0.01)
203 + assert ml["financement"]["t0"] * 12 == pytest.approx(3.0, abs=1e-9) and ml["financement"]["interets"] == pytest.approx(31922.93, abs=0.01)
204 + assert ml["indirects"] == pytest.approx(312868.76, abs=0.01) and ml["taux_indirects"] == pytest.approx(0.2015, abs=5e-5)
205 + assert ml["incitatif"] == pytest.approx(186558.64, abs=0.01) and ml["total"] == pytest.approx(2052145.00, abs=0.01)
206 + assert ml["par_logement"] == pytest.approx(171012, abs=1) and ml["par_m2_locatif"] == pytest.approx(2487, abs=1) and ml["par_m2_brut"] == pytest.approx(2114, abs=1)
207 + assert ml["total"] == pytest.approx(ml["direct"] * (1 + ml["taux_indirects"]) * 1.10, rel=1e-12)
208 + ml18 = cas.multilogement_cas3(data["devis"], data["cn"], data["sup"], data["ipcb21"], 0.07, duree_ans=1.5)
209 + assert ml18["financement"]["interets"] == pytest.approx(54150.37, abs=0.01) and ml18["total"] == pytest.approx(2076595.18, abs=0.01)
210 + assert ml18["par_logement"] == pytest.approx(173050, abs=1)
211 +
212 +
213 +class TestSensibilite:
214 + def test_tornade(self, data):
215 + t = {o["param"][:10]: o for o in cas.tornade(data["devis"], data["cn"], data["i"])}
216 + assert t["Durée du c"]["bas"] == pytest.approx(394192, abs=1) and t["Durée du c"]["haut"] == pytest.approx(406206, abs=1)
217 + assert t["Taux intér"]["bas"] == pytest.approx(395948, abs=1) and t["Taux intér"]["haut"] == pytest.approx(405653, abs=1)
218 + assert t["Indirects "]["bas"] == pytest.approx(381132, abs=1) and t["Indirects "]["haut"] == pytest.approx(414003, abs=1)
219 + assert t["Hausse sal"]["bas"] == pytest.approx(397567, abs=1) and t["Hausse sal"]["haut"] == pytest.approx(408086, abs=1)
220 + assert t["Taux d'inc"]["bas"] == pytest.approx(379496, abs=1) and t["Taux d'inc"]["haut"] == pytest.approx(415639, abs=1)
221 +
222 +
223 +class TestExercices:
224 + """Chiffres des solutions (solutions/ch23.tex)."""
225 +
226 + def test_ex1_classement(self):
227 + D = 12000 + 48000 + 16000 + 2600; I = 9500 + 2800 + 3200 + 7400
228 + assert D == 78600 and I == 22900
229 + assert I / D == pytest.approx(0.2913, abs=5e-5) and I / (D + I) == pytest.approx(0.2256, abs=5e-5)
230 + cn = ct.cout_neuf(D, I / D, 0.10)
231 + assert cn["total"] == pytest.approx(111650.0, abs=1e-6) and cn["incitatif"] == pytest.approx(10150.0, abs=1e-6)
232 +
233 + def test_ex2_interets(self):
234 + c = fin.interets_intermediaires(300000.0, 0.06, 0.75)["interets"]
235 + m = fin.interets_intermediaires(300000.0, 0.06, 0.75, "mensuel")["interets"]
236 + k = fin.interets_intermediaires(300000.0, 0.06, 0.75, "mensuel", capitaliser=True)["interets"]
237 + assert c == pytest.approx(6750.0, abs=1e-9) and m == pytest.approx(7500.0, abs=1e-9) and k == pytest.approx(7600.88, abs=0.01)
238 + assert fin.taux_duree_implicites(6750.0, 300000.0) == pytest.approx(0.045, abs=1e-12)
239 +
240 + def test_ex3_taxes(self):
241 + P = 520000.0
242 + assert P * cas.TPS == pytest.approx(26000.0) and P * cas.TVQ == pytest.approx(51870.0)
243 + assert cas.remboursement_tps(P)["remboursement_general"] == 0.0 and cas.remboursement_tvq(P)["remboursement"] == 0.0
244 + assert cas.remboursement_tps(P, premier_acheteur=True)["remboursement"] == pytest.approx(26000.0)
245 + assert P + 51870.0 == pytest.approx(571870.0) and P + 26000.0 + 51870.0 == pytest.approx(597870.0)
246 + assert cas.remboursement_tps(400000.0)["remboursement_general"] == pytest.approx(3150.0)
247 +
248 + def test_ex4_incitatif(self):
249 + P, VL, DI = 640000.0, 170000.0, 410000.0
250 + E = P - VL - DI
251 + assert E == 60000.0
252 + mc = E / (VL + DI); mp = E / P
253 + assert mc == pytest.approx(0.10345, abs=5e-6) and mp == pytest.approx(0.09375, abs=1e-9)
254 + assert fin.marge_sur_prix(mc) == pytest.approx(mp, abs=1e-12)
255 + assert cas.incitatif_rendement_implicite(E, VL, DI, 10 / 12) == pytest.approx(0.192, abs=5e-4)
256 +
257 + def test_ex5_salaires(self, data):
258 + D = data["cn"]["direct"]; cum = 1.08 * 1.055 - 1
259 + assert cum == pytest.approx(0.1394, abs=5e-5)
260 + dD = cas.effet_hausse_salariale(D, 0.40, cum)
261 + assert dD == pytest.approx(16663, abs=1) and dD / D == pytest.approx(0.0558, abs=5e-5)
262 + assert D * 0.0857 == pytest.approx(25609, abs=1)
263 + assert dD * (1 + data["cn"]["taux_indirects"]) * 1.10 == pytest.approx(22168, abs=1)
264 +
265 + def test_ex6_mise_de_fonds(self, data):
266 + fc = cas.financement_cas1(data["devis"], data["cn"], data["i"]); C = fc["base_hors_financement"]
267 + e = fin.interets_intermediaires(0.75 * C, 0.0619, 1.0, "continu", mise_de_fonds=0.25 * C, cout_total=C)
268 + assert e["t0"] * 12 == pytest.approx(3.0, abs=1e-9) and e["interets"] == pytest.approx(6143.69, abs=0.01)
269 + assert fin.interets_intermediaires(C, 0.0619, 1.0)["interets"] == pytest.approx(10922.11, abs=0.01)
270 + assert fin.interets_intermediaires(0.75 * C, 0.0619, 1.0)["interets"] == pytest.approx(8191.58, abs=0.01)
271 + k = fin.interets_intermediaires(0.75 * C, 0.0619, 1.0, "mensuel", capitaliser=True, mise_de_fonds=0.25 * C, cout_total=C)
272 + assert k["n_mois"] == 9 and k["interets"] == pytest.approx(6921.07, abs=0.01)
273 + assert cas.cout_neuf_avec_financement(data["devis"], data["cn"], e["interets"])["total"] == pytest.approx(394942.80, abs=0.01)
274 +
275 + def test_ex7_multilogement(self, data):
276 + ml18 = cas.multilogement_cas3(data["devis"], data["cn"], data["sup"], data["ipcb21"], 0.07, duree_ans=1.5)
277 + ml = cas.multilogement_cas3(data["devis"], data["cn"], data["sup"], data["ipcb21"], data["i"])
278 + assert ml18["total"] - ml["total"] == pytest.approx(24450.18, abs=0.01)
279 + assert ml18["par_m2_locatif"] == pytest.approx(2517, abs=1) and ml18["taux_indirects"] == pytest.approx(0.2158, abs=5e-5)
280 + assert ml18["financement"]["interets"] / ml18["indirects"] == pytest.approx(0.162, abs=5e-4)
added analysis/python/tests/test_ch26.py +178 −0
@@ -0,0 +1,178 @@
1 +"""Tests du chapitre 26 — baux et loyers (cas 3 et cas 2, données synthétiques).
2 +Identités (formule fermée vs série, survie, durée espérée, loyer économique sur la valeur par capitalisation = loyer
3 +contractuel, cohérence avec le chapitre 2 : VA agrégée 102 188 $ à ρ = 15 %), valeurs citées dans ch26.tex et
4 +solutions/ch26.tex (tolérances déclarées), calendrier des avis, tarif D, présence des figures."""
5 +import sys, pathlib, datetime as dt
6 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
7 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))
8 +import pytest
9 +import numpy as np
10 +from traite import cas3, cas2, finance as fin
11 +from ch26_etat_locatif import (calculer, exercices, va_ecart, va_ecart_serie, va_annuite, survie, esperance_duree, cout_electricite_mensuel,
12 + normalisation_electricite, calendrier, croissance_max_1955, loyer_net_equivalent, TARIF_D, PROFIL_KWH, JOURS)
13 +
14 +ROOT = pathlib.Path(__file__).resolve().parents[3]
15 +
16 +
17 +@pytest.fixture(scope="module")
18 +def res():
19 + return calculer()
20 +
21 +
22 +@pytest.fixture(scope="module")
23 +def ex(res):
24 + return exercices(res)
25 +
26 +
27 +# ----------------------------------------------------------------------------- identités
28 +def test_formule_fermee_egale_serie():
29 + for d, r, g, rho in ((3679.0, 0.0725, 0.035, 0.094), (1000.0, 0.06, 0.02, 0.05), (500.0, 0.09, 0.0, 0.3)):
30 + assert va_ecart(d, r, g, rho) == pytest.approx(va_ecart_serie(d, r, g, rho, T=2000), rel=1e-9)
31 + # sans rotation : capitalisation perpétuelle à r − g ; rotation totale : un seul écart actualisé
32 + assert va_ecart(1000.0, 0.0725, 0.035, 0.0) == pytest.approx(1000.0 / 0.0375)
33 + assert va_ecart(1000.0, 0.0725, 0.035, 1.0) == pytest.approx(1000.0 / 1.0725)
34 + with pytest.raises(ValueError):
35 + va_ecart(1000.0, 0.03, 0.035, 0.0)
36 +
37 +
38 +def test_survie_et_duree():
39 + assert survie(0.15, 0) == 1.0 and survie(0.15, 1) == pytest.approx(0.85)
40 + # E[n] = Σ t ρ (1−ρ)^(t−1) = 1/ρ
41 + rho = 0.094; t = np.arange(1, 5000)
42 + assert np.sum(t * rho * (1 - rho) ** (t - 1)) == pytest.approx(esperance_duree(rho), rel=1e-9)
43 + assert esperance_duree(0.15) == pytest.approx(6.6667, abs=1e-4)
44 + with pytest.raises(ValueError):
45 + esperance_duree(0.0)
46 +
47 +
48 +def test_coherence_chapitre_2(res):
49 + """La somme des VA par logement (ρ = 15 %) redonne la VA agrégée du chapitre 2 (102 188 $) et l'écart net 19 697 $."""
50 + va = res["va"]
51 + assert va["delta1_net"].sum() == pytest.approx(20496 * (1 - 0.039), abs=0.01)
52 + assert va["va_rho_0.150"].sum() == pytest.approx(res["va_agregee_cas"], rel=1e-12)
53 + assert res["va_agregee_cas"] == pytest.approx(102188, abs=1)
54 + assert res["sens"]["sans_rotation"] == pytest.approx(525244, abs=1)
55 + assert va["va_perpetuel"].sum() == pytest.approx(res["sens"]["sans_rotation"], rel=1e-12)
56 +
57 +
58 +def test_loyer_economique_identite(res):
59 + """Sur la valeur par capitalisation directe (RNE/R), le loyer économique est exactement le loyer contractuel."""
60 + e = res["econ_cap"]
61 + assert e["rbp_requis"] == pytest.approx(res["base"]["rbp_contrat"], abs=1e-6)
62 + assert e["loyer_economique_mensuel"] == pytest.approx(e["loyer_contrat_mensuel"], abs=1e-9)
63 + # reconstruction : RNE* = R·V ; RBE* (1 − m) − DE_hg − RR = RNE*
64 + er = res["econ_role"]
65 + assert er["rne_requis"] == pytest.approx(res["R"] * 2_200_000, rel=1e-12)
66 + assert er["rbe_requis"] * 0.96 - er["de_hors_gestion"] - er["reserve"] == pytest.approx(er["rne_requis"], abs=1e-6)
67 + assert er["rbp_requis"] * (1 - 0.039) + res["base"]["autres_revenus"] == pytest.approx(er["rbe_requis"], abs=1e-6)
68 +
69 +
70 +def test_tarif_d_structure():
71 + c = cout_electricite_mensuel(1900, 31)
72 + assert c["tranche1_kwh"] == 1240 and c["tranche2_kwh"] == 660
73 + assert c["total"] == pytest.approx(31 * 0.46154 + 1240 * 0.07065 + 660 * 0.11142, abs=1e-9)
74 + c0 = cout_electricite_mensuel(0, 30)
75 + assert c0["total"] == pytest.approx(30 * 0.46154)
76 + n = normalisation_electricite()
77 + assert n["tableau"]["total"].sum() == pytest.approx(n["cout_annuel"]) and n["kwh_annuel"] == 12000
78 + assert n["tableau"]["tranche1_kwh"].sum() + n["tableau"]["tranche2_kwh"].sum() == pytest.approx(12000)
79 + assert n["loyer_nu"] == pytest.approx(n["loyer_chauffe"] - n["cout_mensuel_moyen"])
80 + assert sum(JOURS) == 365 and sum(PROFIL_KWH) == 12000
81 +
82 +
83 +def test_calendrier():
84 + c = calendrier(dt.date(2027, 6, 30), 12)
85 + assert c["avis_modification_debut"] == dt.date(2026, 12, 30) and c["avis_modification_fin"] == dt.date(2027, 3, 30)
86 + assert c["avis_reprise_eviction_fin"] == dt.date(2026, 12, 30) and c["debut_bail_suivant"] == dt.date(2027, 7, 1)
87 + assert c["nouveau_locataire_fixation_10j"] == dt.date(2027, 7, 11) and c["nouveau_locataire_sans_avis_2mois"] == dt.date(2027, 9, 1)
88 + c6 = calendrier(dt.date(2026, 12, 31), 6)
89 + assert c6["avis_modification_debut"] == dt.date(2026, 10, 31) and c6["avis_modification_fin"] == dt.date(2026, 11, 30)
90 + assert c6["avis_reprise_eviction_fin"] == dt.date(2026, 11, 30)
91 +
92 +
93 +# ----------------------------------------------------------------------------- valeurs citées dans le texte
94 +def test_valeurs_citees_etat_locatif(res):
95 + ind = res["ind"]
96 + assert ind["rbp_contrat"] == 165540 and ind["rbp_marche"] == 186036 and ind["ecart_marche"] == 20496
97 + assert ind["ecart_pondere"] == pytest.approx(0.1102, abs=5e-5)
98 + assert ind["ratio_pondere"] == pytest.approx(0.890, abs=5e-4)
99 + assert ind["ratio_moyen_simple"] == pytest.approx(0.884, abs=5e-4)
100 + assert ind["ecart_mensuel_moyen"] == pytest.approx(142.33, abs=0.01)
101 + assert ind["anciennete_moyenne"] == pytest.approx(6.33, abs=0.01) and ind["anciennete_mediane"] == 6.5 and ind["anciennete_max"] == 18
102 + assert ind["nb_sous_marche_5"] == 8 and ind["nb_sous_marche_10"] == 6 and ind["nb_sous_marche_20"] == 2
103 + assert ind["part_ecart_top3"] == pytest.approx(0.442, abs=5e-4)
104 + assert ind["loyer_moyen_contrat"] == pytest.approx(1149.58, abs=0.01) and ind["loyer_moyen_marche"] == pytest.approx(1291.92, abs=0.01)
105 + assert ind["loyer_m2_contrat_moyen"] == pytest.approx(16.72, abs=0.01) and ind["loyer_m2_marche_moyen"] == pytest.approx(18.79, abs=0.01)
106 + t = ind["tableau"].set_index("logement")
107 + assert t.loc[103, "ecart_mensuel"] == 319 and t.loc[103, "anciennete"] == 18 and t.loc[103, "ratio"] == pytest.approx(0.695, abs=5e-4)
108 + assert (t["mois_echeance"] == 9.9).all()
109 + # cohérence avec le module partagé (chapitres 27, 29, 30, 47)
110 + r = cas3.revenus(res["etat"], res["hyp"])
111 + assert r["rbe"] == pytest.approx(168443.94, abs=0.01)
112 +
113 +
114 +def test_valeurs_citees_va_et_sensibilite(res):
115 + va = res["va"].set_index("logement")
116 + assert va.loc[103, "va_rho_0.094"] == pytest.approx(27292, abs=1) and va.loc[103, "va_rho_0.150"] == pytest.approx(19085, abs=1)
117 + assert va.loc[103, "va_rho_0.085"] == pytest.approx(29318, abs=1) and va.loc[103, "va_perpetuel"] == pytest.approx(98099, abs=1)
118 + assert va.loc[104, "va_rho_0.150"] == pytest.approx(1316, abs=1)
119 + assert va["va_rho_0.094"].sum() == pytest.approx(146128, abs=1) and va["va_rho_0.085"].sum() == pytest.approx(156977, abs=1)
120 + s = res["sens"]
121 + assert s["par_taux"].loc["rho=0.050", "r=0.0725"] == pytest.approx(220691, abs=1)
122 + assert s["par_taux"].loc["rho=0.250", "r=0.0725"] == pytest.approx(66487, abs=1)
123 + assert s["par_taux"].loc["rho=0.150", "r=0.0625"] == pytest.approx(107779, abs=1) and s["par_taux"].loc["rho=0.150", "r=0.0825"] == pytest.approx(97148, abs=1)
124 + assert s["par_croissance"].loc["rho=0.150", "g=0.025"] == pytest.approx(97872, abs=1) and s["par_croissance"].loc["rho=0.150", "g=0.045"] == pytest.approx(106902, abs=1)
125 + assert res["esperance_duree"][0.094] == pytest.approx(10.64, abs=0.01) and res["esperance_duree"][0.085] == pytest.approx(11.76, abs=0.01)
126 +
127 +
128 +def test_valeurs_citees_economique_normalisation(res):
129 + e = res["econ_role"]
130 + assert e["rne_requis"] == pytest.approx(97130, abs=0.5) and e["rbe_requis"] == pytest.approx(175917, abs=1) and e["rbp_requis"] == pytest.approx(173316, abs=1)
131 + assert e["loyer_economique_mensuel"] == pytest.approx(1203.59, abs=0.01) and e["de_hors_gestion"] == pytest.approx(63647.43, abs=0.01)
132 + n = res["norm"]
133 + assert n["cout_annuel"] == pytest.approx(1073.34, abs=0.01) and n["cout_mensuel_moyen"] == pytest.approx(89.45, abs=0.01)
134 + assert n["kwh_tranche2"] == 1400 and n["mois_depassement"] == 3 and n["loyer_nu"] == pytest.approx(1360.55, abs=0.01)
135 + assert n["part_redevance"] == pytest.approx(0.157, abs=5e-4) and n["cout_mensuel_indexe_2027"] == pytest.approx(92.13, abs=0.01)
136 + tab = n["tableau"].set_index("mois")
137 + assert tab.loc["janv.", "total"] == pytest.approx(175.45, abs=0.01) and tab.loc["juil.", "total"] == pytest.approx(49.63, abs=0.01)
138 +
139 +
140 +def test_valeurs_citees_cas2(res):
141 + c2 = res["cas2"]
142 + assert c2["rbp_contrat"] == 41832 and c2["rbp_marche"] == 49332 and c2["ecart_marche"] == 7500
143 + assert c2["ecart_pondere"] == pytest.approx(0.152, abs=5e-4) and c2["anciennete_moyenne"] == 4.0 and c2["nb_sous_marche_10"] == 2
144 + t = c2["tableau"].set_index("logement")
145 + assert t.loc["RC", "ecart_mensuel"] == 383 and t.loc["RC", "ratio"] == pytest.approx(0.732, abs=5e-4) and t.loc["3e", "ecart_mensuel"] == 0
146 + assert cas2.revenus(c2["data"]["loyers"])["rbe"] == pytest.approx(40577.04, abs=0.01)
147 +
148 +
149 +# ----------------------------------------------------------------------------- exercices (solutions/ch26.tex)
150 +def test_exercices(ex):
151 + e2 = ex["2"]
152 + assert e2["rbp_contrat"] == 85200 and e2["rbp_marche"] == 94800 and e2["ecart_pondere"] == pytest.approx(0.1013, abs=5e-5)
153 + assert e2["nb_sous_marche_10"] == 2 and e2["nb_sous_marche_5"] == 4 and e2["anciennete_moyenne"] == 6.5 and e2["part_ecart_top3"] == pytest.approx(0.706, abs=5e-4)
154 + e3 = ex["3"]
155 + assert e3["variante"]["cout_annuel"] == pytest.approx(1258.54, abs=0.01) and e3["variante"]["loyer_nu"] == pytest.approx(1395.12, abs=0.01) and e3["variante"]["mois_depassement"] == 5
156 + e4 = ex["4"]
157 + assert e4["delta1_net"] == pytest.approx(3678.71, abs=0.01) and e4["va_094"] == pytest.approx(27292, abs=1) and e4["va_150"] == pytest.approx(19085, abs=1)
158 + assert e4["annuite_094"] == pytest.approx(26643, abs=1) and e4["annuite_150"] == pytest.approx(18920, abs=1) and e4["perpetuel"] == pytest.approx(98099, abs=1)
159 + assert e4["annuite_094"] == pytest.approx(va_annuite(e4["delta1_net"], 0.0725, 1 / 0.094), rel=1e-12)
160 + e5 = ex["5"]
161 + assert e5["R"] == pytest.approx(0.0317, abs=5e-5) and e5["role"]["rbp_requis"] == pytest.approx(35021, abs=1) and e5["role"]["loyer_mensuel_moyen"] == pytest.approx(972.80, abs=0.01)
162 + assert e5["cap"]["rbp_requis"] == pytest.approx(e5["rbp_contrat"], abs=1e-6) and e5["V_cap"] == pytest.approx(695078, abs=1)
163 + e6 = ex["6"]
164 + assert e6["g_max"] == pytest.approx(croissance_max_1955(1600, 1850), rel=1e-12) and e6["g_max"] == pytest.approx(0.0295, abs=5e-5)
165 + assert e6["va_tal"] == pytest.approx(82846, abs=1) and e6["va_saut"] == pytest.approx(87621, abs=1) and e6["va_reg"] == pytest.approx(82609, abs=1)
166 + assert e6["loyer_an5_tal"] == pytest.approx(1807.82, abs=0.01) and e6["loyer_an6_tal"] == pytest.approx(1863.86, abs=0.01) and not e6["depasse_tal"]
167 + e7 = ex["7"]
168 + assert e7["loyer_net"] == 26500 and e7["net_m2"] == pytest.approx(240.91, abs=0.01) and e7["brut_m2"] == pytest.approx(327.27, abs=0.01)
169 + assert e7["va_net_5ans"] == pytest.approx(fin.van([0] + [26500] * 5, 0.0725), rel=1e-12) and e7["va_net_5ans"] == pytest.approx(107932, abs=1)
170 + assert e7["va_residentiel_5ans"] == pytest.approx(64770, abs=1) and e7["ratio_va"] == pytest.approx(1.666, abs=5e-4)
171 + assert loyer_net_equivalent(36000, 9500, 110)["loyer_net"] == 26500
172 + c1 = ex["1"]["bail6"]
173 + assert c1["nouveau_locataire_fixation_10j"] == dt.date(2027, 1, 11)
174 +
175 +
176 +def test_figures_presentes():
177 + for slug in ("contrat-marche", "anciennete", "va-ecart", "normalisation"):
178 + assert (ROOT / "assets/figures/ch26" / f"ch26-fig-{slug}.pdf").exists()
modified analysis/python/traite/finance.py +15 −0
@@ -264,6 +264,21 @@ def ratio_depenses(depenses: float, rbe: float) -> float:
264 264 def multiplicateur_revenu_brut(prix: float, revenu_brut: float) -> float:
265 265 return prix / revenu_brut
266 266
267 +def taux_vacance_frictionnelle(rotation: float, duree_vacance_mois: float) -> float:
268 + """Inoccupation frictionnelle en régime permanent (ch. 27) : v_f = ρ · d / 12, où ρ est la part des logements
269 + qui changent de locataire par année (taux de rotation) et d la durée moyenne de vacance entre deux baux (mois).
270 + Dérivation : ρ·n départs par an, chacun laissant un logement vide d mois → ρ·n·d/12 logements-années vacants sur n."""
271 + if rotation < 0 or duree_vacance_mois < 0:
272 + raise ValueError("rotation et durée de vacance doivent être non négatives")
273 + return rotation * duree_vacance_mois / 12.0
274 +
275 +def loyer_effectif_concession(loyer_mensuel: float, mois_gratuits: float, duree_bail_mois: int = 12) -> float:
276 + """Loyer mensuel effectif d'un bail de `duree_bail_mois` mois dont `mois_gratuits` mois sont offerts :
277 + L_eff = L · (T − m) / T (concession traitée dans le revenu, donc exclue de la provision pour inoccupation)."""
278 + if not 0 <= mois_gratuits <= duree_bail_mois:
279 + raise ValueError("0 <= mois gratuits <= durée du bail")
280 + return loyer_mensuel * (duree_bail_mois - mois_gratuits) / duree_bail_mois
281 +
267 282 # ----------------------------------------------------------------------------- capitalisation
268 283
269 284 def capitalisation_directe(noi_1: float, taux: float) -> float:
modified assets/figures/ch12/ch12-fig-attenuation.pdf +0 −0

Binary file not shown.

modified assets/figures/ch12/ch12-fig-conventions-sujet.pdf +0 −0

Binary file not shown.

modified assets/figures/ch12/ch12-fig-erreur-valeur.pdf +0 −0

Binary file not shown.

modified assets/figures/ch14/ch14-fig-avant-apres.pdf +0 −0

Binary file not shown.

modified assets/figures/ch14/ch14-fig-geocodage.pdf +0 −0

Binary file not shown.

modified assets/figures/ch14/ch14-fig-levier-cook.pdf +0 −0

Binary file not shown.

modified assets/figures/ch14/ch14-fig-scores-robustes.pdf +0 −0

Binary file not shown.

modified assets/figures/ch14/ch14-fig-sensibilite-seuils.pdf +0 −0

Binary file not shown.

added assets/figures/ch15/ch15-fig-carte-segments.pdf +0 −0

Binary file not shown.

added assets/figures/ch15/ch15-fig-coefficients-secteurs.pdf +0 −0

Binary file not shown.

added assets/figures/ch15/ch15-fig-dendrogramme.pdf +0 −0

Binary file not shown.

added assets/figures/ch15/ch15-fig-loyers-rosemont.pdf +0 −0

Binary file not shown.

added assets/figures/ch15/ch15-fig-mois-inventaire.pdf +0 −0

Binary file not shown.

added assets/figures/ch15/ch15-fig-silhouette.pdf +0 −0

Binary file not shown.

modified assets/figures/ch22/ch22-fig-ecarts-metre.pdf +0 −0

Binary file not shown.

modified assets/figures/ch22/ch22-fig-repartition-corps.pdf +0 −0

Binary file not shown.

added assets/figures/ch23/ch23-fig-ipcb-divisions.pdf +0 −0

Binary file not shown.

added assets/figures/ch23/ch23-fig-ipcb-types.pdf +0 −0

Binary file not shown.

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

Binary file not shown.

added assets/figures/ch23/ch23-fig-structure-couts.pdf +0 −0

Binary file not shown.

added assets/figures/ch24/ch24-fig-composantes.pdf +0 −0

Binary file not shown.

added assets/figures/ch24/ch24-fig-courbes.pdf +0 −0

Binary file not shown.

added assets/figures/ch24/ch24-fig-decomposition.pdf +0 −0

Binary file not shown.

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

Binary file not shown.

added assets/figures/ch25/ch25-fig-carte-terrains.pdf +0 −0

Binary file not shown.

added assets/figures/ch25/ch25-fig-lotissement.pdf +0 −0

Binary file not shown.

added assets/figures/ch25/ch25-fig-residuelle-sensibilite.pdf +0 −0

Binary file not shown.

added assets/figures/ch25/ch25-fig-valeur-unitaire-superficie.pdf +0 −0

Binary file not shown.

added assets/figures/ch26/ch26-fig-anciennete.pdf +0 −0

Binary file not shown.

added assets/figures/ch26/ch26-fig-contrat-marche.pdf +0 −0

Binary file not shown.

added assets/figures/ch26/ch26-fig-normalisation.pdf +0 −0

Binary file not shown.

added assets/figures/ch26/ch26-fig-va-ecart.pdf +0 −0

Binary file not shown.

added assets/figures/ch27/ch27-fig-cascade.pdf +0 −0

Binary file not shown.

added assets/figures/ch27/ch27-fig-inoccupation-rmr.pdf +0 −0

Binary file not shown.

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

Binary file not shown.

added assets/figures/ch27/ch27-fig-trajectoires.pdf +0 −0

Binary file not shown.

added assets/figures/ch31/ch31-fig-levier.pdf +0 −0

Binary file not shown.

added assets/figures/ch31/ch31-fig-service-dette.pdf +0 −0

Binary file not shown.

added assets/figures/ch31/ch31-fig-solde.pdf +0 −0

Binary file not shown.

added assets/figures/ch31/ch31-fig-taux-bdc.pdf +0 −0

Binary file not shown.

added data/synthetic/README-brut.md +50 −0
@@ -0,0 +1,50 @@
1 +# Jeu « brut » du cas 1 — DONNÉES SYNTHÉTIQUES (chapitre 14) — généré le 2026-10-31, graine 20261031
2 +
3 +**Aucune de ces lignes ne décrit une propriété, une transaction, un matricule ou une adresse réels.** Les quatre fichiers ci-dessous sont
4 +produits par `analysis/python/cases/ch14_nettoyage.py` (`PYTHONPATH=analysis/python python3 analysis/python/cases/ch14_nettoyage.py`) à partir
5 +du jeu propre `cas1_gatineau_ventes.csv` (600 ventes, voir `README.md`). Ils servent à enseigner la détection et la correction des anomalies ;
6 +ils ne sont utilisés par aucun autre chapitre. Ce fichier est tenu à la main (le `README.md` voisin est régénéré par `synth.py`).
7 +
8 +| Fichier | Lignes | Contenu |
9 +|---|---|---|
10 +| `cas1_gatineau_ventes_brut.csv` | 618 | inscriptions de ventes « telles que reçues » : 600 ventes + 18 doublons, 109 lignes altérées |
11 +| `cas1_gatineau_anomalies_injectees.csv` | 127 | table de vérité : `id_inscription`, `id_vente_origine`, `anomalie`, `detail` |
12 +| `cas1_gatineau_role_extrait.csv` | 600 | extrait de rôle fictif (une unité par vente propre) : matricule canonique, adresse fictive, secteur, terrain, année, valeur au rôle |
13 +| `cas1_gatineau_journal_exclusions.csv` | 144 | journal produit par le pipeline du chapitre : étape, inscription, action (exclusion / correction / imputation), motif, champ, valeur avant / après |
14 +
15 +## Colonnes du jeu brut
16 +`id_inscription` (B0001…B0618, ordre = date d'acte) ; `id_vente_origine` (**colonne de traçabilité pédagogique**, absente d'un vrai jeu brut,
17 +jamais lue par le pipeline) ; `matricule` (clé d'unité fictive au format canonique `SS-FF-NNNN-C`, 9 chiffres, altérée dans 44 % des lignes :
18 +25 % avec espaces, 10 % sans séparateurs, 5 % avec un chiffre erroné, 4 % absente) ; `adresse_fictive` ; `date_promesse`, `date_acte`
19 +(l'acte suit la promesse de 21 à 90 jours, tirage uniforme) ; `secteur`, `type` ; `superficie_habitable_m2`, `superficie_terrain_m2`,
20 +`annee_construction`, `qualite_1a5`, `garage_places`, `sous_sol_fini`, `piscine`, `chambres`, `salles_de_bain` ; `lat`, `lon` ;
21 +`valeur_role_2025` ; `montant_meubles` ; `type_transaction` (ordinaire / entre personnes liées / succession) ; `prix_vente` (prix déclaré,
22 +meubles compris le cas échéant). Les colonnes dérivées du jeu propre (`age`, `dist_centre_km`, `date_vente`) sont volontairement absentes :
23 +le pipeline les recalcule.
24 +
25 +## Anomalies injectées (lignes tirées sans remise, disjointes ; graine 20261031)
26 +| Code | n | Loi d'injection | Détection attendue |
27 +|---|---|---|---|
28 +| `doublon` | 18 | copie intégrale d'une inscription, nouvel identifiant ; pour 6 d'entre elles l'acte est décalé de 1 à 3 jours | R1 (clé d'unité normalisée + promesse + prix ; puis attributs) |
29 +| `prix_symbolique` | 5 | prix remplacé par 1 $ | R4 (prix < 5 000 $) |
30 +| `meubles_inclus` | 8 | `montant_meubles` ~ U(8 000 ; 35 000) arrondi à 500 $, ajouté au prix | R3 (correction : prix − meubles) |
31 +| `superficie_pi2` | 12 | superficie × 10,7639 (saisie en pi²) | R5 (> 450 m² et plausible une fois divisée) |
32 +| `dates_inversees` | 10 | permutation promesse ↔ acte | R2 (acte antérieur à la promesse) |
33 +| `coord_nulles` | 9 | lat = lon = 0 | R6 puis G1 (géocodage de repli au centre du secteur) |
34 +| `coord_hors_zone` | 6 | signe de la longitude inversé (≈ ½) ou lat ↔ lon | R6 puis G1 |
35 +| `vente_liee_declaree` | 8 | prix × U(0,40 ; 0,60), `type_transaction` = « entre personnes liées » (p = 0,65) ou « succession » | R7 |
36 +| `vente_liee_non_declaree` | 6 | prix × U(0,40 ; 0,60), `type_transaction` laissé à « ordinaire » | S1 (score robuste) ou E1 (résidu studentisé) |
37 +| `terrain_manquant` | 20 | `superficie_terrain_m2` = NA | I1 (imputation) |
38 +| `qualite_manquante` | 15 | `qualite_1a5` = NA | I1 |
39 +| `annee_manquante` | 10 | `annee_construction` = NA | I1 |
40 +
41 +Les proportions (≈ 18 % des lignes touchées) sont volontairement élevées pour que chaque mécanisme soit visible ; elles ne mesurent pas la
42 +fréquence des anomalies dans une source réelle (aucune statistique publique sur ce point n'a été trouvée, voir `verification/chapters/ch14-claims.csv`).
43 +
44 +## Pipeline de référence (chapitre 14)
45 +R1 doublons → R2 dates → R3 meubles → R4 prix symbolique → R5 unité de superficie → R6 coordonnées → R7 transactions déclarées non représentatives →
46 +G1 géocodage de repli → I1 imputation (médiane conditionnelle secteur × type) → S1 scores robustes (|z| > 3,5 sur ln prix/m² par secteur, ln superficie,
47 +ln terrain par secteur) → E1 régression (`traite.cas1.hedonique`, exclusion si |t_i| > 3, au plus deux itérations). Résultat : 579 ventes ;
48 +le modèle hédonique réestimé coïncide avec celui du jeu propre à moins de 0,5 erreur-type sur chaque coefficient (σ = 0,0837 contre 0,0830).
49 +
50 +Version du pipeline inscrite dans le journal : `ch14_nettoyage.py 1.0 (2026-10-31)`. Tests : `analysis/python/tests/test_ch14.py`.
added data/synthetic/ch25_terrains_ventes.csv +201 −0
@@ -0,0 +1,201 @@
1 +id_vente,date_vente,secteur,superficie_m2,facade_m,desservi,topographie,forme,logements_constructibles,lat,lon,prix_vente,prix_m2
2 +T001,2023-02-05,Hull,456.7,12.85,1,plat,irrégulier,1,45.44345,-75.75611,180500.0,395.2266257937377
3 +T002,2024-08-19,Hull,450.6,14.49,1,plat,régulier,1,45.40877,-75.72161,209000.0,463.82600976475806
4 +T003,2024-11-08,Hull,702.6,12.69,1,pente,régulier,1,45.42446,-75.73211,244500.0,347.9931682322801
5 +T004,2026-06-03,Hull,743.1,19.78,1,plat,régulier,1,45.42757,-75.73362,283500.0,381.509890997174
6 +T005,2023-06-05,Hull,674.3,13.14,1,plat,régulier,1,45.43968,-75.76847,233500.0,346.285036333976
7 +T006,2024-07-12,Hull,777.3,21.5,1,roc,régulier,1,45.42449,-75.73975,267500.0,344.1399716968995
8 +T007,2024-02-22,Hull,677.8,13.78,1,plat,régulier,1,45.43861,-75.70132,218000.0,321.6287990557687
9 +T008,2023-11-04,Hull,1615.2,23.49,0,roc,régulier,1,45.42733,-75.74719,273500.0,169.3288756810302
10 +T009,2026-04-06,Hull,407.9,14.51,1,plat,régulier,1,45.43447,-75.74233,176500.0,432.70409414072077
11 +T010,2025-09-27,Hull,5177.9,48.06,0,plat,irrégulier,1,45.41687,-75.75743,565000.0,109.1175959365766
12 +T011,2024-08-03,Hull,2041.5,32.0,0,plat,irrégulier,1,45.42477,-75.72575,328000.0,160.66617683076169
13 +T012,2025-09-17,Hull,696.6,13.26,1,roc,régulier,1,45.46084,-75.76263,241500.0,346.6838931955211
14 +T013,2024-08-15,Hull,946.6,22.9,1,pente,régulier,1,45.41966,-75.76292,302500.0,319.56475808155506
15 +T014,2025-08-08,Hull,3487.4,32.05,0,pente,régulier,1,45.43282,-75.7247,392500.0,112.54803005104088
16 +T015,2024-12-30,Hull,475.2,10.0,1,plat,régulier,1,45.41531,-75.7278,190000.0,399.8316498316498
17 +T016,2023-05-29,Hull,500.2,13.42,1,plat,régulier,1,45.41816,-75.72222,180500.0,360.85565773690524
18 +T017,2023-03-15,Hull,743.3,12.92,1,plat,régulier,1,45.42047,-75.73296,231500.0,311.44894389882955
19 +T018,2024-05-08,Hull,1022.3,22.52,1,pente,régulier,1,45.42682,-75.7463,289500.0,283.1849750562457
20 +T019,2026-08-25,Hull,2050.0,25.38,0,plat,régulier,1,45.44753,-75.76225,352000.0,171.70731707317074
21 +T020,2023-12-01,Hull,413.8,12.75,1,roc,irrégulier,3,45.41909,-75.7263,232000.0,560.6573223779603
22 +T021,2024-07-31,Hull,564.1,13.18,1,plat,régulier,1,45.41775,-75.7341,244000.0,432.5474206700939
23 +T022,2026-04-29,Hull,426.3,10.91,1,plat,régulier,1,45.42063,-75.73867,205500.0,482.054890921886
24 +T023,2026-08-09,Hull,490.1,13.34,1,plat,régulier,2,45.44759,-75.74892,266000.0,542.7463782901449
25 +T024,2023-05-01,Hull,858.1,16.82,1,pente,régulier,1,45.42396,-75.75938,240000.0,279.68768208833467
26 +T025,2025-01-21,Hull,499.2,11.6,1,plat,régulier,1,45.43971,-75.72264,213500.0,427.68429487179486
27 +T026,2026-02-12,Hull,786.5,14.88,1,plat,régulier,3,45.41933,-75.76384,380500.0,483.78893833439287
28 +T027,2024-12-11,Hull,525.9,14.69,1,plat,régulier,1,45.4238,-75.76383,194000.0,368.8914242251379
29 +T028,2024-03-25,Hull,639.8,19.03,1,plat,régulier,1,45.40963,-75.73686,232500.0,363.39481087839954
30 +T029,2023-05-06,Hull,540.1,15.69,1,pente,irrégulier,3,45.46871,-75.75356,225000.0,416.5895204591742
31 +T030,2026-08-13,Hull,575.4,12.13,1,plat,régulier,1,45.42666,-75.75804,258500.0,449.2526937782412
32 +T031,2024-11-15,Hull,635.1,16.01,1,roc,régulier,1,45.41962,-75.72436,237500.0,373.95685718784443
33 +T032,2023-08-19,Hull,2856.1,33.68,0,roc,régulier,1,45.4323,-75.73055,284000.0,99.4362942474003
34 +T033,2023-12-17,Hull,415.1,15.63,1,plat,régulier,3,45.44712,-75.75034,223500.0,538.4244760298723
35 +T034,2023-09-13,Hull,680.4,14.29,1,plat,régulier,1,45.42121,-75.75402,237500.0,349.0593768371546
36 +T035,2024-08-23,Hull,460.9,14.95,1,plat,régulier,1,45.44928,-75.74102,205500.0,445.8667823822955
37 +T036,2025-11-26,Hull,546.7,10.67,1,plat,irrégulier,1,45.43742,-75.76804,233000.0,426.19352478507403
38 +T037,2025-04-25,Hull,381.9,10.73,1,pente,régulier,1,45.43565,-75.75686,169500.0,443.8334642576591
39 +T038,2026-08-16,Hull,733.8,18.0,1,plat,régulier,1,45.43408,-75.75673,320500.0,436.7675115835378
40 +T039,2024-12-25,Hull,508.4,13.49,1,plat,régulier,1,45.42989,-75.76102,198000.0,389.45712037765543
41 +T040,2024-03-15,Hull,710.2,20.67,1,plat,régulier,1,45.43173,-75.7633,242000.0,340.74908476485496
42 +T041,2025-06-20,Hull,769.2,15.27,1,pente,régulier,1,45.42114,-75.77604,243000.0,315.9126365054602
43 +T042,2023-12-15,Hull,321.7,10.92,1,plat,régulier,1,45.41753,-75.7347,159500.0,495.8035436742307
44 +T043,2023-05-09,Hull,1122.4,22.94,1,plat,régulier,2,45.42653,-75.73189,341500.0,304.2587312900926
45 +T044,2024-08-08,Hull,963.6,20.1,1,plat,régulier,1,45.43755,-75.71872,305000.0,316.5213781652138
46 +T045,2023-07-23,Hull,572.4,15.29,1,plat,régulier,1,45.43257,-75.74846,218000.0,380.85255066387145
47 +T046,2024-09-22,Hull,735.1,13.15,1,plat,régulier,1,45.41833,-75.73985,250000.0,340.08978370289753
48 +T047,2023-06-14,Hull,468.2,11.15,1,roc,régulier,1,45.43053,-75.76937,165500.0,353.48141819735156
49 +T048,2024-06-10,Hull,878.6,18.68,1,plat,régulier,3,45.40826,-75.71323,412500.0,469.49692692920553
50 +T049,2026-07-28,Hull,718.9,15.17,1,plat,irrégulier,1,45.43552,-75.75655,238000.0,331.0613437195716
51 +T050,2023-11-13,Hull,351.1,10.0,1,plat,régulier,3,45.43313,-75.74669,190500.0,542.5804614070065
52 +T051,2025-07-30,Hull,632.1,16.36,1,plat,régulier,2,45.42441,-75.7335,270000.0,427.1476032273374
53 +T052,2025-11-10,Hull,945.0,20.14,1,pente,régulier,1,45.42749,-75.76896,255500.0,270.3703703703704
54 +T053,2024-09-17,Hull,2812.7,27.96,0,plat,régulier,1,45.43769,-75.75262,413000.0,146.83400291534826
55 +T054,2023-09-21,Hull,475.3,10.91,1,plat,irrégulier,3,45.43704,-75.75511,258000.0,542.8150641699979
56 +T055,2023-11-05,Hull,619.5,17.03,1,plat,régulier,1,45.42016,-75.75283,203500.0,328.4907183212268
57 +T056,2025-06-24,Hull,605.7,14.45,1,plat,régulier,2,45.45282,-75.76208,258500.0,426.7789334654119
58 +T057,2023-07-20,Hull,481.2,16.87,1,plat,régulier,2,45.4507,-75.72012,204000.0,423.9401496259352
59 +T058,2025-05-01,Hull,385.2,13.1,1,plat,régulier,1,45.43487,-75.72915,213000.0,552.9595015576324
60 +T059,2026-04-06,Hull,603.9,17.53,1,plat,régulier,1,45.41842,-75.7456,257000.0,425.5671468786223
61 +T060,2023-03-17,Hull,521.2,13.81,1,roc,régulier,1,45.42122,-75.72486,204000.0,391.4044512663085
62 +T061,2023-09-15,Hull,381.8,12.66,1,pente,régulier,1,45.43177,-75.75683,174000.0,455.73598742797276
63 +T062,2023-12-02,Hull,609.3,14.16,1,plat,régulier,2,45.43153,-75.73802,240000.0,393.89463318562287
64 +T063,2025-02-18,Hull,700.8,18.48,1,plat,régulier,1,45.42057,-75.76936,249000.0,355.3082191780822
65 +T064,2025-10-20,Hull,462.6,15.54,1,plat,régulier,1,45.43535,-75.75618,172500.0,372.8923476005188
66 +T065,2024-08-31,Hull,675.4,17.12,1,plat,régulier,2,45.43536,-75.75826,302500.0,447.8827361563518
67 +T066,2024-03-29,Hull,839.8,17.99,1,plat,régulier,1,45.44365,-75.74527,261500.0,311.3836627768516
68 +T067,2023-03-25,Hull,528.6,13.37,1,plat,régulier,1,45.4262,-75.7449,216500.0,409.5724555429436
69 +T068,2024-07-23,Hull,485.7,11.03,1,pente,régulier,1,45.44063,-75.74873,187500.0,386.04076590487955
70 +T069,2023-02-08,Hull,332.2,13.24,1,plat,irrégulier,2,45.40775,-75.71363,177000.0,532.8115593016255
71 +T070,2023-08-23,Hull,356.4,10.0,1,plat,régulier,1,45.42973,-75.74756,197000.0,552.7497194163861
72 +T071,2026-08-30,Gatineau,1558.0,18.36,0,pente,régulier,1,45.47756,-75.68596,285500.0,183.24775353016688
73 +T072,2026-05-17,Gatineau,817.2,15.51,1,pente,irrégulier,2,45.46692,-75.67832,305500.0,373.8374938815467
74 +T073,2026-04-21,Gatineau,600.0,12.3,1,plat,régulier,1,45.48244,-75.67869,293500.0,489.1666666666667
75 +T074,2025-07-08,Gatineau,479.5,12.12,1,roc,irrégulier,1,45.4661,-75.67064,148000.0,308.6548488008342
76 +T075,2025-12-26,Gatineau,630.1,12.17,1,roc,régulier,1,45.46627,-75.65802,231500.0,367.40199968259003
77 +T076,2025-05-03,Gatineau,649.3,12.72,1,pente,régulier,1,45.46883,-75.65606,224500.0,345.7569690435854
78 +T077,2023-08-26,Gatineau,4323.4,43.04,0,plat,régulier,1,45.47591,-75.67381,511500.0,118.30966369061387
79 +T078,2024-04-18,Gatineau,922.0,18.83,1,plat,régulier,1,45.46463,-75.62882,273000.0,296.09544468546636
80 +T079,2023-07-13,Gatineau,440.5,11.98,1,plat,régulier,1,45.4928,-75.66526,177000.0,401.8161180476731
81 +T080,2025-09-07,Gatineau,320.0,14.63,1,plat,régulier,3,45.47232,-75.70496,214500.0,670.3125
82 +T081,2024-06-21,Gatineau,472.5,14.41,1,plat,régulier,2,45.47168,-75.66679,246000.0,520.6349206349206
83 +T082,2023-09-05,Gatineau,818.7,18.96,1,plat,régulier,1,45.47064,-75.68908,286500.0,349.9450348112862
84 +T083,2026-08-18,Gatineau,787.0,18.75,1,plat,régulier,1,45.47445,-75.65632,280000.0,355.7814485387548
85 +T084,2024-12-16,Gatineau,418.6,15.31,1,plat,régulier,1,45.48167,-75.676,194500.0,464.64405160057333
86 +T085,2025-05-16,Gatineau,615.5,16.43,1,plat,régulier,1,45.46007,-75.66767,216500.0,351.74654752233954
87 +T086,2026-04-22,Gatineau,360.5,12.41,1,plat,régulier,1,45.47773,-75.66256,185000.0,513.1761442441054
88 +T087,2023-01-09,Gatineau,2629.3,30.3,0,plat,irrégulier,1,45.48174,-75.65342,409000.0,155.55471037918838
89 +T088,2025-08-14,Gatineau,473.2,16.2,1,pente,régulier,1,45.48796,-75.64999,178500.0,377.2189349112426
90 +T089,2024-05-03,Gatineau,515.8,13.14,1,plat,régulier,1,45.48122,-75.65538,214000.0,414.8894920511827
91 +T090,2023-04-12,Gatineau,577.8,15.74,1,plat,irrégulier,1,45.47881,-75.66631,198000.0,342.67912772585674
92 +T091,2025-03-05,Gatineau,458.7,14.97,1,pente,régulier,1,45.47605,-75.64462,198500.0,432.7447133202529
93 +T092,2023-05-14,Gatineau,443.5,11.64,1,plat,régulier,1,45.49012,-75.69268,193000.0,435.1747463359639
94 +T093,2026-05-24,Gatineau,470.8,15.35,1,plat,régulier,3,45.48249,-75.6488,298500.0,634.0271877655055
95 +T094,2023-01-28,Gatineau,645.5,13.31,1,plat,régulier,2,45.46301,-75.67794,236500.0,366.38264910921765
96 +T095,2024-05-28,Gatineau,728.6,17.01,1,plat,régulier,1,45.50301,-75.67135,269000.0,369.2012077957727
97 +T096,2025-04-15,Gatineau,1002.0,20.14,1,plat,régulier,1,45.46888,-75.66682,281000.0,280.439121756487
98 +T097,2026-01-07,Gatineau,325.9,11.35,1,plat,irrégulier,1,45.48481,-75.6788,150000.0,460.26388462718626
99 +T098,2024-02-01,Gatineau,652.0,12.78,1,roc,régulier,1,45.46323,-75.66481,196500.0,301.3803680981595
100 +T099,2024-10-15,Gatineau,1166.4,16.32,1,roc,régulier,1,45.45529,-75.65531,350000.0,300.06858710562415
101 +T100,2024-06-09,Gatineau,514.3,13.55,1,roc,irrégulier,1,45.48287,-75.6472,188000.0,365.54540151662457
102 +T101,2025-01-14,Gatineau,786.3,20.34,1,plat,régulier,2,45.48361,-75.69076,293500.0,373.2672008139387
103 +T102,2025-05-18,Gatineau,337.6,12.56,1,roc,régulier,1,45.45988,-75.65547,134000.0,396.9194312796208
104 +T103,2023-12-03,Gatineau,524.0,13.38,1,plat,régulier,1,45.46101,-75.65545,237000.0,452.29007633587787
105 +T104,2025-05-22,Gatineau,435.9,14.4,1,pente,régulier,2,45.48944,-75.65602,225500.0,517.3204863500803
106 +T105,2024-04-30,Gatineau,4663.7,37.62,0,plat,régulier,1,45.49511,-75.67881,582000.0,124.79361880052319
107 +T106,2026-07-16,Gatineau,759.3,12.97,1,plat,régulier,2,45.47764,-75.68843,285500.0,376.0042144080074
108 +T107,2023-05-07,Gatineau,761.7,16.95,1,plat,régulier,1,45.486,-75.674,227000.0,298.0175922279112
109 +T108,2024-03-03,Gatineau,742.8,19.12,1,plat,régulier,1,45.45902,-75.65357,251500.0,338.58373721055466
110 +T109,2024-04-08,Gatineau,474.8,10.03,1,plat,régulier,1,45.47709,-75.68357,190500.0,401.22156697556863
111 +T110,2026-01-01,Gatineau,907.3,19.58,1,plat,irrégulier,1,45.48907,-75.70012,303000.0,333.95789705720273
112 +T111,2025-04-12,Gatineau,3057.6,39.24,0,pente,régulier,1,45.46777,-75.66326,446000.0,145.8660387231816
113 +T112,2026-08-18,Gatineau,379.1,11.37,1,roc,irrégulier,1,45.4506,-75.63516,144000.0,379.8470060670008
114 +T113,2023-09-02,Gatineau,824.0,13.69,1,plat,régulier,3,45.48911,-75.66628,354500.0,430.21844660194176
115 +T114,2025-03-01,Gatineau,1680.7,25.21,0,plat,irrégulier,1,45.48829,-75.67786,289500.0,172.24965788064497
116 +T115,2025-06-10,Gatineau,2712.8,24.9,0,plat,régulier,1,45.48542,-75.68801,369000.0,136.02182247124742
117 +T116,2025-07-30,Gatineau,1080.4,21.98,1,plat,irrégulier,1,45.4673,-75.67813,347500.0,321.64013328396885
118 +T117,2026-03-25,Gatineau,495.5,12.96,1,roc,irrégulier,1,45.46835,-75.68446,173000.0,349.1422805247225
119 +T118,2023-02-02,Gatineau,525.3,12.51,1,pente,régulier,1,45.48605,-75.67929,176000.0,335.0466400152294
120 +T119,2024-07-14,Gatineau,609.8,12.85,1,plat,régulier,1,45.45183,-75.64836,225500.0,369.7933748770089
121 +T120,2026-07-08,Gatineau,479.9,17.45,1,roc,régulier,1,45.50021,-75.67462,184000.0,383.41321108564284
122 +T121,2025-07-08,Gatineau,2223.6,27.66,0,plat,régulier,1,45.47377,-75.67072,392000.0,176.290699766145
123 +T122,2025-02-02,Gatineau,729.3,15.02,1,plat,régulier,1,45.48827,-75.63558,263500.0,361.3053613053613
124 +T123,2026-07-15,Gatineau,568.3,17.39,1,roc,régulier,1,45.47473,-75.64835,213000.0,374.80204117543553
125 +T124,2026-06-01,Gatineau,577.8,17.6,1,plat,régulier,1,45.49014,-75.68151,247500.0,428.3489096573209
126 +T125,2023-01-09,Gatineau,441.7,13.47,1,plat,régulier,1,45.47961,-75.64469,190000.0,430.1562146253113
127 +T126,2023-03-11,Gatineau,683.5,14.86,1,plat,régulier,1,45.48099,-75.66264,228000.0,333.57717629846377
128 +T127,2025-06-06,Gatineau,814.7,19.54,1,pente,régulier,1,45.46724,-75.69591,243500.0,298.88302442616913
129 +T128,2023-06-08,Gatineau,2756.6,33.87,0,plat,régulier,1,45.44167,-75.65306,306500.0,111.18769498657767
130 +T129,2023-01-02,Gatineau,560.1,16.0,1,pente,régulier,1,45.47874,-75.65437,180000.0,321.37118371719333
131 +T130,2023-07-15,Gatineau,568.4,13.47,1,plat,régulier,1,45.47686,-75.65534,201500.0,354.50387051372275
132 +T131,2024-04-02,Aylmer,1153.7,24.57,0,plat,régulier,1,45.38978,-75.83627,304500.0,263.9334315679986
133 +T132,2024-02-09,Aylmer,506.4,14.28,1,plat,régulier,3,45.40211,-75.84011,356500.0,703.9889415481833
134 +T133,2023-09-14,Aylmer,430.8,10.83,1,roc,régulier,1,45.38999,-75.8491,221000.0,512.9990714948932
135 +T134,2023-03-05,Aylmer,1056.1,18.09,0,pente,régulier,1,45.40228,-75.84611,229500.0,217.30896695388697
136 +T135,2024-04-01,Aylmer,1661.2,22.43,0,plat,irrégulier,1,45.38839,-75.83292,331000.0,199.25355164941007
137 +T136,2023-04-30,Aylmer,1055.7,17.9,1,pente,irrégulier,1,45.38821,-75.83056,290000.0,274.69925168134887
138 +T137,2025-08-10,Aylmer,3991.8,35.63,0,plat,irrégulier,1,45.39129,-75.80861,544000.0,136.2793727140638
139 +T138,2026-03-23,Aylmer,1705.2,27.0,0,roc,régulier,1,45.39086,-75.87544,340000.0,199.39010086793337
140 +T139,2026-02-23,Aylmer,1017.9,19.74,1,plat,régulier,1,45.39042,-75.83934,353500.0,347.2836231456921
141 +T140,2025-07-04,Aylmer,478.8,11.65,1,plat,régulier,1,45.41487,-75.82131,212500.0,443.8178780284043
142 +T141,2025-11-30,Aylmer,927.6,19.23,1,plat,régulier,1,45.38291,-75.8394,365500.0,394.0275981026304
143 +T142,2025-09-09,Aylmer,536.9,14.6,1,plat,régulier,1,45.39589,-75.85013,294500.0,548.5192773328366
144 +T143,2023-04-05,Aylmer,1119.9,15.99,1,roc,régulier,1,45.40123,-75.84269,319000.0,284.84686132690416
145 +T144,2024-08-31,Aylmer,772.2,16.39,1,pente,régulier,1,45.39059,-75.83263,276500.0,358.067858067858
146 +T145,2024-08-04,Aylmer,795.5,14.9,1,plat,irrégulier,1,45.3907,-75.88846,299500.0,376.49277184160906
147 +T146,2023-09-17,Aylmer,546.6,16.48,1,pente,régulier,1,45.40511,-75.82558,210000.0,384.1931942919868
148 +T147,2023-11-23,Aylmer,442.2,12.94,1,plat,régulier,1,45.41038,-75.83416,201500.0,455.67616463138853
149 +T148,2025-10-12,Aylmer,814.1,15.46,1,plat,régulier,1,45.39569,-75.81236,322500.0,396.1429799778897
150 +T149,2025-12-07,Aylmer,803.7,17.59,1,plat,régulier,1,45.39951,-75.82409,291500.0,362.69752395172327
151 +T150,2023-04-26,Aylmer,680.6,16.66,1,plat,irrégulier,1,45.40935,-75.82499,260000.0,382.01586835145457
152 +T151,2024-06-04,Aylmer,479.5,14.16,1,pente,régulier,1,45.38187,-75.83418,201500.0,420.2294056308655
153 +T152,2024-04-14,Aylmer,482.5,14.55,1,plat,régulier,2,45.39479,-75.81198,258500.0,535.7512953367876
154 +T153,2024-12-20,Aylmer,668.4,18.28,1,roc,régulier,1,45.38753,-75.85495,233000.0,348.5936564931179
155 +T154,2023-05-23,Aylmer,2195.2,33.28,0,plat,régulier,1,45.39087,-75.83439,390000.0,177.66034985422743
156 +T155,2025-06-04,Aylmer,914.3,25.22,1,plat,régulier,1,45.38546,-75.83015,367000.0,401.3999781253418
157 +T156,2026-01-28,Aylmer,370.6,11.74,1,plat,régulier,1,45.40186,-75.82115,197000.0,531.5704263356719
158 +T157,2023-06-20,Aylmer,570.2,17.85,1,pente,irrégulier,1,45.39782,-75.85474,257000.0,450.71904594878987
159 +T158,2025-05-14,Aylmer,616.7,14.27,1,pente,irrégulier,1,45.39962,-75.83906,255000.0,413.4911626398573
160 +T159,2025-04-04,Aylmer,1038.9,17.13,1,plat,régulier,1,45.38195,-75.85972,336000.0,323.41900086630085
161 +T160,2023-04-01,Aylmer,515.5,13.68,1,plat,régulier,1,45.38924,-75.84594,185500.0,359.84481086323956
162 +T161,2026-01-14,Aylmer,590.7,14.54,1,pente,irrégulier,1,45.38937,-75.84849,285000.0,482.47841543930923
163 +T162,2025-03-18,Aylmer,493.6,11.96,1,plat,régulier,1,45.39198,-75.8521,216500.0,438.61426256077795
164 +T163,2024-09-23,Aylmer,545.7,13.91,1,plat,régulier,1,45.3863,-75.84978,250500.0,459.04343045629463
165 +T164,2026-06-25,Aylmer,588.4,12.26,1,roc,régulier,1,45.38881,-75.81626,221500.0,376.4445955132563
166 +T165,2024-07-22,Aylmer,1353.6,18.48,1,plat,régulier,1,45.39314,-75.84659,401000.0,296.2470449172577
167 +T166,2026-03-04,Aylmer,770.9,15.56,1,pente,régulier,1,45.38123,-75.85937,324000.0,420.2879750940459
168 +T167,2023-10-15,Aylmer,905.5,19.11,1,pente,régulier,1,45.39146,-75.84241,309000.0,341.2479293208172
169 +T168,2023-12-29,Aylmer,832.7,14.11,1,pente,régulier,1,45.38523,-75.85943,284000.0,341.0592049957968
170 +T169,2023-08-23,Aylmer,574.5,13.35,1,plat,régulier,1,45.38767,-75.86244,266500.0,463.881636205396
171 +T170,2026-06-03,Aylmer,1236.5,21.27,0,plat,irrégulier,1,45.3933,-75.85088,278000.0,224.82814395471087
172 +T171,2026-02-14,Buckingham,1014.5,19.18,1,pente,régulier,1,45.59511,-75.4162,262500.0,258.7481517989157
173 +T172,2025-03-10,Buckingham,602.7,18.05,1,plat,régulier,1,45.57399,-75.41752,168000.0,278.7456445993031
174 +T173,2023-07-13,Buckingham,2120.4,25.98,0,roc,régulier,1,45.58155,-75.44074,260000.0,122.61837389171853
175 +T174,2024-08-06,Buckingham,1675.6,25.52,0,plat,irrégulier,1,45.5766,-75.4222,208000.0,124.13463833850561
176 +T175,2025-01-04,Buckingham,555.1,20.59,1,plat,régulier,1,45.58518,-75.44013,212500.0,382.8139074040713
177 +T176,2023-11-22,Buckingham,2168.3,27.48,0,plat,régulier,1,45.59394,-75.42591,272500.0,125.67449153714891
178 +T177,2024-09-27,Buckingham,2478.3,29.27,0,pente,régulier,1,45.58859,-75.41601,234500.0,94.62131299681232
179 +T178,2024-03-04,Buckingham,900.0,20.44,0,plat,irrégulier,1,45.59082,-75.40812,193000.0,214.44444444444446
180 +T179,2024-09-05,Buckingham,2616.7,27.75,0,plat,régulier,1,45.59194,-75.41695,315000.0,120.38063209385868
181 +T180,2024-11-27,Buckingham,3240.7,34.67,0,pente,régulier,1,45.59789,-75.46202,359500.0,110.9328231554911
182 +T181,2025-10-16,Buckingham,3392.6,26.32,0,roc,irrégulier,1,45.59487,-75.41613,269500.0,79.43759948122384
183 +T182,2024-12-19,Buckingham,1808.3,29.67,0,plat,irrégulier,1,45.58062,-75.42018,266500.0,147.37598849748383
184 +T183,2023-11-27,Buckingham,3992.2,40.06,0,plat,irrégulier,1,45.57827,-75.43638,345500.0,86.54376033264867
185 +T184,2023-04-08,Buckingham,388.2,13.52,1,pente,régulier,1,45.60567,-75.42256,157500.0,405.71870170015455
186 +T185,2025-10-30,Buckingham,1411.2,22.85,0,plat,régulier,1,45.60405,-75.38303,251500.0,178.2171201814059
187 +T186,2023-03-05,Masson-Angers,2979.1,33.79,0,plat,régulier,1,45.56146,-75.42078,271000.0,90.96707059178947
188 +T187,2026-03-23,Masson-Angers,510.0,13.75,1,plat,régulier,1,45.55928,-75.41855,180000.0,352.94117647058823
189 +T188,2025-08-28,Masson-Angers,1128.7,15.49,1,pente,régulier,1,45.55378,-75.41412,213000.0,188.71267830247186
190 +T189,2025-11-24,Masson-Angers,897.1,20.87,1,plat,régulier,1,45.5404,-75.40128,244000.0,271.9875153271653
191 +T190,2025-10-28,Masson-Angers,1840.0,26.95,0,plat,régulier,1,45.55732,-75.39824,289500.0,157.33695652173913
192 +T191,2026-03-03,Masson-Angers,1347.2,20.39,0,plat,régulier,1,45.54859,-75.4338,217000.0,161.0748218527316
193 +T192,2023-06-05,Masson-Angers,2607.9,25.93,0,plat,irrégulier,1,45.55561,-75.44103,296500.0,113.69300970129223
194 +T193,2023-08-31,Masson-Angers,5216.0,37.77,0,plat,régulier,1,45.54397,-75.38747,496500.0,95.18788343558282
195 +T194,2026-04-17,Masson-Angers,1824.2,25.18,0,plat,régulier,1,45.55257,-75.43779,277000.0,151.8473851551365
196 +T195,2024-11-21,Masson-Angers,637.6,12.81,1,plat,régulier,1,45.53907,-75.43427,198000.0,310.53952321204514
197 +T196,2023-06-05,Masson-Angers,2982.3,43.32,0,pente,irrégulier,1,45.54972,-75.43663,237500.0,79.63652214733594
198 +T197,2023-05-09,Masson-Angers,438.2,12.05,1,plat,régulier,1,45.54159,-75.42345,164000.0,374.25832952989504
199 +T198,2025-04-25,Masson-Angers,2765.8,30.3,0,plat,régulier,1,45.54576,-75.38573,294000.0,106.29835852194662
200 +T199,2024-03-04,Masson-Angers,2479.3,29.77,0,plat,régulier,1,45.54487,-75.41186,293000.0,118.17851813011737
201 +T200,2026-06-28,Masson-Angers,3557.9,42.02,0,plat,régulier,1,45.55807,-75.42928,360000.0,101.18328227325107
modified manuscript/chapters/ch11.tex +24 −11
@@ -78,7 +78,7 @@ L'\motcle{enveloppe} d'un bâtiment est l'ensemble des éléments qui séparent
78 78 \fill[gray!35] (0.2,-2.5) rectangle (1.3,-2.2);
79 79 \fill[gray!35] (5.7,-2.5) rectangle (6.8,-2.2);
80 80 \fill[gray!45] (1.0,-2.3) rectangle (6.0,-2.1);
81 −\fill[pattern=dots, pattern color=gray!70] (1.0,-2.1) rectangle (6.0,-1.9);
81 +\fill[gray!18] (1.0,-2.1) rectangle (6.0,-1.9); \foreach \x in {1.2,1.6,...,5.8} {\fill[gray!70] (\x,-2.0) circle (0.03);}
82 82 \node[etiq] at (3.5,-1.3) {sous-sol : humidité, efflorescence, gondolement des planchers};
83 83 \node[etiq] at (3.5,-2.45) {dalle sur remblai (pyrite) --- fissures en étoile, soulèvement};
84 84 % drain
@@ -107,7 +107,7 @@ L'\motcle{enveloppe} d'un bâtiment est l'ensemble des éléments qui séparent
107 107 \fill[gray!20] (0.5,5.2) -- (3.5,7.4) -- (6.5,5.2) -- cycle;
108 108 \draw[brandDark, thick] (0.2,5.0) -- (3.5,7.45) -- (6.8,5.0);
109 109 \draw[brandDark] (0.5,5.2) -- (6.5,5.2);
110 −\fill[pattern=north east lines, pattern color=brandAmber] (1.0,5.2) rectangle (6.0,5.55);
110 +\fill[brandAmber!35] (1.0,5.2) rectangle (6.0,5.55); \foreach \x in {1.2,1.6,...,5.8} {\draw[brandAmber, thin] (\x,5.22) -- (\x+0.25,5.53);}
111 111 \node[etiq] at (3.5,6.1) {comble : isolant, pare-vapeur,\\ventilation (soffites, évents), givre};
112 112 \node[etiq, anchor=west] at (6.9,6.4) {toiture : bardeaux, solins,\\noues, cheminée, gouttières};
113 113 \draw[fl] (6.85,6.4) -- (5.3,6.1);
@@ -207,10 +207,10 @@ Lorsqu'un test est recommandé mais n'a pas été fait à la date d'évaluation,
207 207
208 208 \subsection{Synthèse des pathologies québécoises}
209 209
210 −Le \cref{tab:11:pathologies} récapitule les pathologies examinées dans ce chapitre, les signes qui les trahissent à l'inspection, leurs conséquences pour le bâtiment et la façon dont elles entrent dans l'évaluation. La dernière colonne renvoie au traitement du \cref{ch:24} : une pathologie curable à un coût inférieur à la valeur qu'elle restitue est une dépréciation physique curable, mesurée par son coût de correction ; une pathologie qui raccourcit la vie d'une composante relève de la dépréciation de courte durée, par un âge effectif accru ; une pathologie qui affecte la structure ou le sol relève de la longue durée, ou d'une désuétude externe lorsque c'est le site qui est en cause. Le tableau ne donne aucun coût : les coûts de correction dépendent du bâtiment, du marché local et de l'année, et aucune source publique vérifiée ne permet de les tabuler ; l'évaluateur les obtient par soumission ou par les guides de coûts sous licence du \cref{ch:21}.
210 +Les \cref{tab:11:pathologies,tab:11:pathologies-b} récapitulent les pathologies examinées dans ce chapitre, les signes qui les trahissent à l'inspection, leurs conséquences pour le bâtiment et la façon dont elles entrent dans l'évaluation. Leur dernière colonne renvoie au traitement du \cref{ch:24} : une pathologie curable à un coût inférieur à la valeur qu'elle restitue est une dépréciation physique curable, mesurée par son coût de correction ; une pathologie qui raccourcit la vie d'une composante relève de la dépréciation de courte durée, par un âge effectif accru ; une pathologie qui affecte la structure ou le sol relève de la longue durée, ou d'une désuétude externe lorsque c'est le site qui est en cause. Les tableaux ne donnent aucun coût : les coûts de correction dépendent du bâtiment, du marché local et de l'année, et aucune source publique vérifiée ne permet de les tabuler ; l'évaluateur les obtient par soumission ou par les guides de coûts sous licence du \cref{ch:21}.
211 211
212 212 \begin{table}[htbp]
213 −\caption{Pathologies fréquentes du parc résidentiel québécois : signes, conséquences et traitement dans l'évaluation}\label{tab:11:pathologies}
213 +\caption{Pathologies fréquentes du parc résidentiel québécois (1) : fondations, sols, enveloppe et humidité}\label{tab:11:pathologies}
214 214 \centering
215 215 \scriptsize\setlength{\tabcolsep}{3pt}
216 216 \begin{tabularx}{\textwidth}{>{\raggedright\arraybackslash}p{23mm}YY>{\raggedright\arraybackslash}p{27mm}}
@@ -224,6 +224,20 @@ Sols argileux & Fissures d'un côté, affaissement, arbres à proximité ; vall
224 224 Fissures de fondation & Verticales fines (retrait) vs horizontales ou en escalier (mouvement) ; efflorescence & Infiltration ; perte de capacité si structurales & Curable (injection) ou longue durée \\
225 225 Infiltrations et humidité & Taches, gondolement, moisissures, humidimètre, pente négative, descentes au pied du mur & Dégradation des finitions, moisissures & Curable (drainage, pente, gouttières) \\
226 226 Moisissures & Plaques visibles ; classement petite / moyenne / grande (Santé Canada) & Santé des occupants ; ouverture des finitions si étendues & Curable si cause corrigée ; spécialiste si grande zone \\
227 +Toiture et comble & Granules, solins, givre, isolant insuffisant, ventilation & Infiltrations, condensation & Courte durée (âge effectif) ; curable (ventilation) \\
228 +\bottomrule
229 +\end{tabularx}
230 +\tabsource{synthèse de l'auteur ; signes et mécanismes d'après la RBQ (pyrite, pyrrhotite, ocre, sols argileux), Santé Canada (moisissures, amiante, radon), RNCan (vermiculite, MIUF) et Québec.ca (radon, pyrrhotite), pages consultées le 31 octobre 2026 ; périodes indicatives (\cref{fig:11:frise}). Aucun coût n'est tabulé.}
231 +\end{table}
232 +
233 +\begin{table}[htbp]
234 +\caption{Pathologies fréquentes du parc résidentiel québécois (2) : contaminants et systèmes}\label{tab:11:pathologies-b}
235 +\centering
236 +\scriptsize\setlength{\tabcolsep}{3pt}
237 +\begin{tabularx}{\textwidth}{>{\raggedright\arraybackslash}p{23mm}YY>{\raggedright\arraybackslash}p{27mm}}
238 +\toprule
239 +Pathologie & Signes à l'inspection & Conséquences & Traitement (\cref{ch:24}) \\
240 +\midrule
227 241 Amiante (matériaux anciens) & Isolant de tuyaux, carreaux, plâtre, bardeaux ; avant 1990 & Désamiantage lors de travaux ; aucun risque important si intact & Désuétude conditionnelle aux travaux projetés \\
228 242 Vermiculite (comble, murs) & Granules grises ou brunes dans le comble ; 1920-1990 & Analyse ; retrait par un professionnel si dérangée & Hypothèse extraordinaire + analyse \\
229 243 MIUF (murs) & Trous de remplissage bouchés, mousse friable ; années 1970, interdite déc. 1980 & Formaldéhyde (émissions décroissantes) ; retrait coûteux lors de travaux & Désuétude conditionnelle ; déclaration du vendeur \\
@@ -231,10 +245,9 @@ Radon & Invisible ; test de 91 jours en saison de chauffage ; ligne directrice 2
231 245 Filage d'aluminium, boutons et tubes, deux fils & Panneau, prises, conducteurs visibles ; périodes 1900-1950 et 1965-1976 & Assurabilité, mise aux normes & Curable (électricien) \\
232 246 Galvanisé, plomb, polybutylène & Matériau des conduites visibles ; pression faible ; 1900-1960 et 1978-1995 & Fuites, assurabilité, santé (plomb) & Curable (remplacement) \\
233 247 Mazout : réservoirs & Réservoir intérieur rouillé ; conduits d'un réservoir souterrain & Déversement, contamination du sol & Curable (retrait) ou désuétude externe (sol) ; expertise \\
234 −Toiture et comble & Granules, solins, givre, isolant insuffisant, ventilation & Infiltrations, condensation & Courte durée (âge effectif) ; curable (ventilation) \\
235 248 \bottomrule
236 249 \end{tabularx}
237 −\tabsource{synthèse de l'auteur ; signes et mécanismes d'après la RBQ (pyrite, pyrrhotite, ocre, sols argileux), Santé Canada (moisissures, amiante, radon), RNCan (vermiculite, MIUF) et Québec.ca (radon, pyrrhotite), pages consultées le 31 octobre 2026 ; périodes indicatives (\cref{fig:11:frise}). Aucun coût n'est tabulé.}
250 +\tabsource{voir le \cref{tab:11:pathologies}.}
238 251 \end{table}
239 252
240 253 \section{Du constat au chiffre : classement d'état et âge effectif}\label{sec:11:etat}
@@ -299,7 +312,7 @@ qui sert de variable d'état dans une grille de comparaison ou un modèle hédon
299 312
300 313 \subsection{Le lien avec la dépréciation physique}\label{sec:11:lien-depreciation}
301 314
302 −La fiche d'état alimente directement la méthode ventilée de la dépréciation physique, dont le \cref{ch:24} donne la théorie complète et que l'ouvrage implante dans \texttt{traite.cout.depreciation\_ventilee}. Les travaux immédiatement nécessaires et économiquement justifiés, c'est-à-dire ceux dont le coût est inférieur à la valeur qu'ils restituent, forment la \motcle{dépréciation physique curable}, mesurée par leur coût de correction $D_{\mathrm{cur}}$. Les composantes de courte durée, qui seront remplacées avant la fin de la vie du bâtiment, sont dépréciées chacune au prorata de leur âge effectif :
315 +La fiche d'état alimente directement la méthode ventilée de la dépréciation physique, dont le \cref{ch:24} donne la théorie complète et que l'ouvrage implante dans la fonction \texttt{depreciation\_ventilee} du module \texttt{traite.cout}. Les travaux immédiatement nécessaires et économiquement justifiés, c'est-à-dire ceux dont le coût est inférieur à la valeur qu'ils restituent, forment la \motcle{dépréciation physique curable}, mesurée par leur coût de correction $D_{\mathrm{cur}}$. Les composantes de courte durée, qui seront remplacées avant la fin de la vie du bâtiment, sont dépréciées chacune au prorata de leur âge effectif :
303 316 \begin{equation}\label{eq:11:courte}
304 317 D_{\mathrm{court}} = \sum_{i \in \mathcal{S}} C_i \min\!\left\{\frac{a_{\mathrm{eff},i}}{L_i},\ 1\right\},
305 318 \end{equation}
@@ -344,7 +357,7 @@ soit 55,61~\% du coût neuf, et la contribution du bâtiment déprécié avant d
344 357 \begin{table}[htbp]
345 358 \caption{Fiche d'état du sujet du cas 1 au 1\textsuperscript{er} septembre 2026 (données synthétiques, constats fictifs)}\label{tab:11:fiche}
346 359 \centering
347 −\scriptsize\setlength{\tabcolsep}{3pt}
360 +\scriptsize\setlength{\tabcolsep}{2.3pt}
348 361 \begin{tabular}{lS[table-format=4.0]S[table-format=2.0]S[table-format=2.0]cS[table-format=2.2]S[table-format=1.3]S[table-format=6.0]}
349 362 \toprule
350 363 Composante & {Année} & {Âge $a$} & {$L$} & $c$ & {$a_{\mathrm{eff}}$} & {$a_{\mathrm{eff}}/L$} & {Coût neuf (\$)} \\
@@ -400,7 +413,7 @@ Réfection du solin de cheminée et refixation de la gouttière & Gouttières et
400 413 \midrule
401 414 Total $D_{\mathrm{cur}}$ (2,64 \% du coût neuf) & & & 10479.98 \\
402 415 \bottomrule
403 −\end{tabular}
416 +\end{tabularx}
404 417 \tabsource{\synth{analysis/python/cases/ch11\_inspection.py ; fractions déclarées (\texttt{TRAVAUX}), coût = fraction × poste direct × 1,33043}}
405 418 \end{table}
406 419
@@ -415,7 +428,7 @@ Total $D_{\mathrm{cur}}$ (2,64 \% du coût neuf) & & & 10479.98 \\
415 428 Coût neuf $C_N$ (\cref{ch:21}) & 397567.33 & 100.00 \\
416 429 Curable $D_{\mathrm{cur}}$ (5 interventions) & 10479.98 & 2.64 \\
417 430 Courte durée $D_{\mathrm{court}}$ (18 composantes) & 130584.55 & 32.85 \\
418 −Base de longue durée $C_N - D_{\mathrm{cur}} - \sum_{\mathcal S} C_i$ & 165569.69 & 41.65 \\
431 +Base de longue durée $C_N - D_{\mathrm{cur}} - \sum\nolimits_{\mathcal S} C_i$ & 165569.69 & 41.65 \\
419 432 Longue durée $D_{\mathrm{long}}$ = base × 29/60 & 80025.35 & 20.13 \\
420 433 \midrule
421 434 Dépréciation physique totale (ventilée) & 221089.88 & 55.61 \\
@@ -487,7 +500,7 @@ Section & Objet & Contenu principal \\
487 500
488 501 Le dossier d'inspection est la preuve de l'étendue du travail. Il contient les photographies numérotées et renvoyées depuis le formulaire, les mesures (\cref{ch:12}), les documents lus et la liste de ce qui n'a pas pu être vu, avec la raison. Cette dernière liste n'est pas un aveu de faiblesse mais une exigence de la norme : la règle 2.3 des NPP veut que l'étendue du travail soit décrite et que les réserves et hypothèses soient énoncées, et la norme de l'AIBQ impose à l'inspecteur de mentionner les systèmes qui n'ont pas été inspectés et d'en donner les raisons \parencite[règle 2.3]{oeaqNormes}. La photographie est le meilleur allié de l'évaluateur en cas de litige ; elle date l'état, fixe l'étendue d'un défaut et permet à un réviseur de contrôler la note attribuée. Enfin, le formulaire d'un immeuble de revenu ajoute à ces sections un relevé par logement, parce que l'état des logements commande le loyer et les réserves (\cref{ch:28,ch:33}), et un relevé des parties communes et des équipements collectifs dont le remplacement pèse sur les flux (\cref{ch:30}).
489 502
490 −\begin{quebec}[Normes, codes et aides : repères québécois pour l'inspection]
503 +\begin{quebec}[Codes et aides publiques : repères québécois pour l'inspection]
491 504 Le chapitre I du Code de construction du Québec est fondé, depuis le 17 avril 2025, sur le Code national du bâtiment 2020 modifié pour le Québec ; la Régie du bâtiment a prolongé la période transitoire de sorte que les dispositions antérieures peuvent être appliquées aux travaux commencés avant le 17 octobre 2027 \parencite{codeConstruction,rbqtransition2026}. Pour l'évaluateur, le Code n'est pas un référentiel d'inspection : la norme de l'AIBQ exclut expressément du mandat de l'inspecteur la vérification de la conformité aux codes (art.~12.10), et une maison construite selon le code de son époque n'est pas déficiente parce que le code a changé ; elle peut en revanche être fonctionnellement désuète (\cref{ch:24}). Le chapitre « Bâtiment » de la Loi sur le bâtiment, qui fonde l'inspection par la Régie, exclut la maison unifamiliale et le bâtiment totalement résidentiel de moins de trois étages ou de moins de neuf logements \parencite[art.~29]{loibatiment} : l'inspection des petits bâtiments résidentiels existants relève donc des municipalités, des assureurs et des parties, non d'un contrôle public. Les programmes d'aide influent sur la valeur des corrections : Rénoclimat, refondu le 1\textsuperscript{er} octobre 2026, finance l'isolation du toit, des murs et des fondations ainsi que les fenêtres selon des plafonds publiés, et son volet d'adaptation finance des mesures de protection contre les inondations \parencite{renoclimat,renoclimatnouveautes} ; le \cref{ch:44} en traite. Aucune statistique publique de prévalence des pathologies décrites dans ce chapitre n'a été vérifiée à la date de rédaction : le lecteur ne trouvera donc ici ni pourcentage de maisons touchées par la pyrite, ni proportion de drains colmatés par l'ocre, et il se méfiera des chiffres de cette nature qui circulent sans source.
492 505 \end{quebec}
493 506
modified manuscript/chapters/ch12.tex +9 −9
@@ -86,19 +86,19 @@ Les titres anciens et les descriptions de lots ruraux emploient encore des unit
86 86
87 87 \begin{table}[tbp]
88 88 \caption{Facteurs de conversion des longueurs et des superficies utilisés en évaluation résidentielle au Québec.}\label{tab:12:conversions}
89 −\centering\small
89 +\centering\footnotesize
90 90 \begin{tabular}{@{}l S[table-format=5.6] l l@{}}
91 91 \toprule
92 92 Unité & {Équivalent} & Unité d'arrivée & Statut du facteur \\
93 93 \midrule
94 94 1 pied (pi) & 0.3048 & mètre & exact par définition \\
95 95 1 pouce (po) & 0.0254 & mètre & exact par définition \\
96 −1 pied carré (pi\textsuperscript{2}) & 0.092903 & mètre carré & exact : $0{,}3048^2 = 0{,}09290304$ \\
97 −1 mètre carré (m\textsuperscript{2}) & 10.7639 & pied carré & arrondi à quatre décimales (convention de l'ouvrage) \\
96 +1 pied carré (pi\textsuperscript{2}) & 0.092903 & mètre carré & exact ($0{,}3048^2$) \\
97 +1 mètre carré (m\textsuperscript{2}) & 10.7639 & pied carré & arrondi à 4 décimales (convention de l'ouvrage) \\
98 98 1 acre & 4046.856 & mètre carré & exact : 43 560 pi\textsuperscript{2} \\
99 99 1 hectare & 10000 & mètre carré & exact \\
100 100 1 arpent (longueur) & 58.47 & mètre & usuel (180 pieds français) ; source secondaire \\
101 −1 arpent carré & 3418.89 & mètre carré & usuel ; source secondaire, à vérifier au titre \\
101 +1 arpent carré & 3418.89 & mètre carré & usuel ; source secondaire \\
102 102 \bottomrule
103 103 \end{tabular}
104 104 \tabsource{définitions du pied international (\qty{0.3048}{\metre}) ; arpent et pied français : équivalences usuelles relayées par des sources secondaires, inscrites comme partiellement vérifiées au registre du chapitre ; calculs \texttt{analysis/python/cases/ch12\_mesurage.py}.}
@@ -175,7 +175,7 @@ Relevé de l'évaluateur & Toute convention choisie ; cotes de contrôle & L'év
175 175 Le certificat de localisation est un acte réservé à l'arpenteur-géomètre, officier public dont l'exercice de la profession comprend les arpentages, mesurages, levés et plans, les certificats de localisation, les calculs de superficies des propriétés et toutes les opérations faites par méthode directe, photogrammétrique, électronique ou autre se rapportant au bornage, au lotissement et au piquetage (Loi sur les arpenteurs-géomètres, RLRQ, c. A-23, art.~34, à jour au 15 octobre 2026). La norme de pratique qui le régit (RLRQ, c. A-23, r.~10, à jour au 1\ier{} juillet 2026) le définit comme un document en minute comportant un rapport et un plan, dans lequel l'arpenteur-géomètre exprime son opinion sur la situation et la condition actuelles d'un bien-fonds par rapport aux titres, au cadastre et aux lois et règlements qui peuvent l'affecter (art.~2). Le rapport doit notamment indiquer la date du levé, la description actualisée du bien-fonds avec les mesures linéaires et la contenance de chaque lot, la concordance ou la discordance entre les marques d'occupation, le plan cadastral et les titres, les servitudes, les limites bornées, les empiétements apparents, les bâtiments, dépendances et structures, la situation en zone agricole et les avis publiés au registre foncier (art.~9) ; l'arpenteur-géomètre doit avoir visité les lieux ou validé personnellement les observations (art.~7), et le système international d'unités est la règle (art.~3). L'Ordre des arpenteurs-géomètres du Québec rappelle qu'un créancier ou un notaire peut exiger un certificat à jour lors de la vente d'une propriété, que le vendeur en paie habituellement les frais et que le certificat ne doit pas servir à établir les limites sur le terrain, ce qui relève du piquetage (page consultée le 31 octobre 2026, registre du chapitre). Du côté du Code civil, le vendeur remet à l'acheteur les titres et le certificat de localisation qu'il possède et doit délivrer la contenance indiquée, sauf vente sans égard à la contenance \parencite[art.~1719-1720]{ccq} ; le plan cadastral est présumé exact et prime les titres en cas de discordance \parencite[art.~3026-3027]{ccq}, de sorte que la contenance du lot rénové est la superficie de terrain de référence pour l'évaluateur, même lorsque les titres anciens en indiquent une autre \parencite{loiCadastre}. Pour les lots issus de la rénovation cadastrale, le certificat doit en outre établir la concordance avec le plan antérieur (r.~10, art.~9, par.~6\textsuperscript{o}) : c'est là que se résolvent la plupart des écarts de contenance rencontrés dans les dossiers.
176 176 \end{quebec}
177 177
178 −Lire un certificat de localisation pour les besoins de l'évaluation suppose de distinguer trois choses que le document superpose : les limites et la contenance du lot, qui donnent la superficie de terrain ; la position du bâtiment, qui révèle les marges, les empiétements et les servitudes, donc les contraintes juridiques et urbanistiques traitées aux \cref{ch:7,ch:8} ; et les dimensions hors tout du bâtiment, qui permettent de reconstituer l'aire au sol et, connaissant le nombre d'étages, l'aire des étages. Le certificat ne dit rien de l'intérieur : ni la finition du sous-sol, ni les trémies, ni l'épaisseur des murs. Le passage à la superficie habitable exige soit un plan, soit un relevé intérieur, soit l'application de l'\cref{eq:12:murs} avec une épaisseur de mur estimée, méthode acceptable pour un contrôle mais non pour une mesure de référence.
178 +Lire un certificat de localisation pour les besoins de l'évaluation suppose de distinguer trois choses que le document superpose : les limites et la contenance du lot, qui donnent la superficie de terrain ; la position du bâtiment, qui révèle les marges, les empiétements et les servitudes, donc les contraintes juridiques et urbanistiques traitées aux \cref{ch:07,ch:08} ; et les dimensions hors tout du bâtiment, qui permettent de reconstituer l'aire au sol et, connaissant le nombre d'étages, l'aire des étages. Le certificat ne dit rien de l'intérieur : ni la finition du sous-sol, ni les trémies, ni l'épaisseur des murs. Le passage à la superficie habitable exige soit un plan, soit un relevé intérieur, soit l'application de l'\cref{eq:12:murs} avec une épaisseur de mur estimée, méthode acceptable pour un contrôle mais non pour une mesure de référence.
179 179
180 180 \subsection{Le plan coté du sujet du cas 1}
181 181
@@ -185,7 +185,7 @@ Le sujet est la maison fictive du premier dossier de cas (\texttt{data/synthetic
185 185
186 186 \begin{figure}[tbp]
187 187 \centering
188 −\begin{tikzpicture}[x=0.42cm, y=0.42cm, font=\sffamily\tiny,
188 +\begin{tikzpicture}[x=0.5cm, y=0.5cm, font=\sffamily\tiny,
189 189 mur/.style={draw=brandDark, line width=1.6pt}, cloison/.style={draw=brandGray, line width=0.5pt},
190 190 cote/.style={<->, >={Stealth[length=1.2mm]}, draw=brandGray, line width=0.4pt},
191 191 piece/.style={text=brandDark, align=center}]
@@ -222,7 +222,7 @@ Le sujet est la maison fictive du premier dossier de cas (\texttt{data/synthetic
222 222 \node[font=\sffamily\tiny, text=brandGray, align=center] at (3.96,-3.3) {aire brute 95,04 m\textsuperscript{2} ; intérieur 84,20 m\textsuperscript{2}};
223 223 \end{scope}
224 224 % ---------------- Étage
225 −\begin{scope}[xshift=13.2cm]
225 +\begin{scope}[xshift=7.0cm]
226 226 \draw[mur] (0,0) rectangle (7.92,12.00);
227 227 \draw[cloison] (0.28,0.28) rectangle (7.64,11.72);
228 228 \draw[cloison] (3.96,0.28) -- (3.96,4.40); % ch. 1 / ch. 2
@@ -245,7 +245,7 @@ Le sujet est la maison fictive du premier dossier de cas (\texttt{data/synthetic
245 245 \node[font=\sffamily\scriptsize\bfseries, text=brandDark] at (3.96,-2.4) {Étage};
246 246 \node[font=\sffamily\tiny, text=brandGray, align=center] at (3.96,-3.3) {aire brute 95,04 m\textsuperscript{2} ; intérieur 84,20 m\textsuperscript{2} ; trémie 3,40 m\textsuperscript{2}};
247 247 \end{scope}
248 −\node[font=\sffamily\tiny, text=brandGray, align=left, anchor=north west] at (-1.2,-4.3) {Cotes en mètres ; murs extérieurs de 0,28 m ; cloisons de 0,115 m ; échelle approximative 1:240. Sous-sol (non montré) : même empreinte, plancher à 1,90 m sous le niveau moyen du sol, 70 m\textsuperscript{2} finis.};
248 +\node[font=\sffamily\tiny, text=brandGray, align=left, anchor=north west, text width=118mm] at (-1.2,-4.3) {Cotes en mètres ; murs extérieurs de 0,28 m ; cloisons de 0,115 m ; échelle approximative 1:200. Sous-sol (non montré) : même empreinte, plancher à 1,90 m sous le niveau moyen du sol, 70 m\textsuperscript{2} finis.};
249 249 \end{tikzpicture}
250 250 \caption{Plan coté reconstitué du sujet du cas 1 : rez-de-chaussée et étage d'un cottage de 7,92 sur 12,00 mètres hors tout. Les cotes extérieures donnent l'aire brute de chaque niveau (\msq{95.04}), les cotes intérieures et la trémie donnent la superficie habitable (\msq{165.0} pour les deux niveaux). Les dimensions sont des hypothèses pédagogiques.}\label{fig:12:plan}
251 251 \figsource{\synth{schéma de l'auteur (TikZ, \texttt{manuscript/chapters/ch12.tex}) ; dimensions déclarées dans \texttt{analysis/python/cases/ch12\_mesurage.py}}.}
@@ -254,7 +254,7 @@ Le sujet est la maison fictive du premier dossier de cas (\texttt{data/synthetic
254 254 \begin{figure}[tbp]
255 255 \centering
256 256 \begin{tikzpicture}[x=0.22cm, y=0.22cm, font=\sffamily\tiny,
257 − lot/.style={draw=brandDark, line width=1pt}, bat/.style={draw=brandDark, fill=brandLight, line width=0.8pt},
257 + lot/.style={draw=brandDark, line width=1pt}, bat/.style={draw=brandDark, fill=brandLight, line width=0.8pt}, cloison/.style={draw=brandGray, line width=0.5pt},
258 258 cote/.style={<->, >={Stealth[length=1.2mm]}, draw=brandGray, line width=0.4pt}]
259 259 % lot 15,24 x 40,68 (façade en bas)
260 260 \draw[lot] (0,0) rectangle (15.24,40.68);
modified manuscript/chapters/ch19.tex +382 −0
@@ -257,3 +257,385 @@ Le \motcle{krigeage ordinaire} prédit $u$ en un point non observé $\mathbf{s}_
257 257 où $\mathbf{C}$ est la matrice des covariances entre points observés, $C_{ij} = c_1 e^{-d_{ij}/a}$ pour $i \neq j$ et $C_{ii} = c_0 + c_1$, et $\mathbf{c}_0$ le vecteur des covariances entre les points observés et la cible ; $\mu$ est le multiplicateur de Lagrange de la contrainte de somme unitaire et $\sigma^2_{\text{k}}$ la variance de krigeage, qui mesure l'incertitude de la prédiction du résidu. Le krigeage est l'\emph{interpolateur optimal} au sens des moindres carrés : il donne plus de poids aux voisins proches, moins aux voisins regroupés entre eux, dont l'information est redondante, et il se réduit à une moyenne locale lorsque la pépite domine. En pratique, on ne résout le système que sur les quelques dizaines de voisins les plus proches de la cible, ce qui est sans perte lorsque la portée est courte devant l'étendue de l'échantillon.
258 258
259 259 Pour l'évaluateur, le krigeage des résidus a une interprétation immédiate : c'est un \emph{ajustement de localisation} estimé à partir de ce que le modèle hédonique n'a pas su expliquer chez les ventes voisines. Si les maisons autour du sujet se sont toutes vendues 5 \% au-dessus de ce que le modèle prédit, il est raisonnable de relever de 5 \% la prédiction pour le sujet, et le krigeage dit de combien exactement, compte tenu des distances et de la redondance des voisins. Le SEM de la \cref{sec:19:modeles} fait la même chose avec une structure de covariance définie par $\mathbf{W}$ : l'espérance conditionnelle du résidu au sujet, sachant les résidus observés, s'obtient à partir de la matrice de précision $(\mathbf{I} - \lambda\mathbf{W})\T(\mathbf{I} - \lambda\mathbf{W})/\sigma^2$, et l'exemple montrera que les deux approches donnent des corrections très voisines. \textcite{dubin1988} avait précisément proposé d'estimer conjointement les coefficients hédoniques et la covariance spatiale ; la pratique courante, en deux étapes, régression puis krigeage des résidus, perd un peu d'efficacité mais reste transparente et facile à documenter.
260 +
261 +\section{Exemple intégral : les 600 ventes du cas 1}\label{sec:19:exemple}
262 +
263 +\begin{synthetique}
264 +Le dossier du cas 1 est synthétique. Les 600 ventes de \texttt{data/synthetic/cas1\_gatineau\_ventes.csv} ont été générées par \texttt{analysis/python/traite/synth.py} (graine 20261031) selon les lois déclarées dans \texttt{data/synthetic/README.md} : le logarithme du prix y est la somme d'une fonction des caractéristiques, d'une prime de secteur égale au logarithme du rapport des médianes APCIQ du troisième trimestre de 2026 à celle de Hull \parencite{apciqgatineaut32026}, d'un gradient de distance au centre au-delà de trois kilomètres, d'une tendance temporelle et d'un bruit indépendant d'écart-type 0,085. Les coordonnées sont fictives ; elles sont converties en kilomètres par une projection locale autour du centroïde des ventes et les cartes de ce chapitre ne représentent aucun lieu. Le générateur ne contient \emph{aucun} processus spatial résiduel : c'est un choix délibéré, qui permet de vérifier que les outils du chapitre retrouvent la structure connue et, surtout, de montrer ce qu'ils racontent lorsqu'on la leur cache. Tous les chiffres de cette section sont reproduits par \texttt{analysis/python/cases/ch19\_spatial.py}, les figures par \texttt{analysis/python/figures/ch19\_figures.py}, et les estimations ont été contrôlées avec les bibliothèques \texttt{spreg}, \texttt{esda} et \texttt{mgwr} de PySAL.
265 +\end{synthetique}
266 +
267 +\subsection{Deux spécifications et trois matrices}
268 +
269 +Le modèle de référence du cas 1 (\texttt{traite.cas1.hedonique}, \cref{ch:18}) régresse le logarithme du prix sur le logarithme de la superficie habitable et du terrain, l'âge et son carré, la qualité, le garage, le sous-sol fini, la piscine, le type à étages, la distance au centre au-delà de trois kilomètres, quatre indicatrices de secteur (Hull étant la référence) et une tendance linéaire en mois ; il explique 85,3 \% de la variance avec un écart-type résiduel de 0,083. La question du chapitre est de savoir ce qu'il reste de dépendance spatiale dans ses résidus, et ce qui se passe lorsqu'un analyste ne dispose pas des frontières de secteurs. On estime donc aussi une spécification \emph{sans indicatrices de secteur}, identique par ailleurs, qui explique 76,6 \% de la variance avec un écart-type résiduel de 0,104, et, pour mémoire, une spécification sans indicatrices ni gradient de distance ($R^2 = 0{,}637$).
270 +
271 +Les coordonnées projetées s'étendent sur 42 kilomètres d'est en ouest et 27 du sud au nord ; le sujet se trouve à 5,0 kilomètres à l'ouest et 4,1 au sud du centroïde. Trois matrices de pondération sont construites : celle des huit plus proches voisins ($\mathbf{W}_{\text{knn}}$, huit voisins par vente, aucune ligne vide) ; celle de la distance inverse à moins de deux kilomètres ($\mathbf{W}_{\text{dist}}$, 61,8 voisins en moyenne) ; et la matrice spatio-temporelle de l'\cref{eq:19:spatio-temporelle}, distance inverse à moins de deux kilomètres et ventes antérieures de moins de douze mois ($\mathbf{W}_{\text{st}}$, 15,1 voisins en moyenne, 18 ventes sans aucune voisine antérieure). La \cref{prop:jacobien-st} se vérifie numériquement : la plus grande valeur propre de $\mathbf{W}_{\text{st}}$ en module est nulle à la précision machine.
272 +
273 +\subsection{La dépendance des résidus, avec et sans indicatrices de secteur}
274 +
275 +Le \cref{tab:19:moran} donne la statistique de Moran du logarithme du prix et des résidus des trois spécifications, pour les trois matrices. Le logarithme du prix brut est, sans surprise, autocorrélé ($I = 0{,}20$ avec $\mathbf{W}_{\text{knn}}$, $z = 10{,}7$) : des maisons voisines se ressemblent et se vendent à des prix voisins. Retirer l'effet des caractéristiques sans modéliser la localisation \emph{augmente} la dépendance ($I = 0{,}36$) : une fois la superficie, l'âge et la qualité pris en compte, ce qui reste est presque entièrement de la localisation, et la localisation est lisse dans l'espace. Ajouter le gradient de distance réduit à peine la statistique ($I = 0{,}32$, $z = 17{,}1$), parce que le gradient ne distingue pas Aylmer de Gatineau, à distances comparables du centre mais à primes opposées. Ajouter les quatre indicatrices de secteur ramène la statistique à $-0{,}014$ ($z = -0{,}63$, valeur $p$ de 0,53) : plus aucune dépendance détectable. Le résultat est identique avec la matrice de distance inverse ; avec la matrice spatio-temporelle, la statistique des résidus du modèle de référence est légèrement négative ($-0{,}039$, $z = -2{,}06$), un effet marginal que l'on peut attribuer aux 18 lignes vides et à la faible taille des voisinages antérieurs, et dont la valeur $p$ de 0,04 ne survit à aucune correction pour tests multiples.
276 +
277 +\begin{table}[htbp]
278 +\caption{Statistique $I$ de Moran du logarithme des prix et des résidus de trois spécifications, cas 1, selon la matrice de pondération (données synthétiques)}\label{tab:19:moran}
279 +\centering
280 +\small
281 +\begin{tabular}{lrrrrrr}
282 +\toprule
283 + & \multicolumn{2}{c}{$\mathbf{W}_{\text{knn}}$ ($k = 8$)} & \multicolumn{2}{c}{$\mathbf{W}_{\text{dist}}$ (2 km)} & \multicolumn{2}{c}{$\mathbf{W}_{\text{st}}$ (2 km, 12 mois)} \\
284 +\cmidrule(lr){2-3}\cmidrule(lr){4-5}\cmidrule(lr){6-7}
285 +Série & {$I$} & {$z$} & {$I$} & {$z$} & {$I$} & {$z$} \\
286 +\midrule
287 +$\ln P$ (sans modèle) & 0,201 & 10,7 & 0,221 & 17,3 & 0,277 & 15,6 \\
288 +Résidus, sans secteur ni gradient & 0,360 & 19,0 & 0,352 & 27,5 & 0,348 & 19,5 \\
289 +Résidus, sans indicatrices de secteur & 0,324 & 17,1 & 0,322 & 25,2 & 0,305 & 17,1 \\
290 +Résidus, modèle de référence & $-0{,}014$ & $-0{,}6$ & $-0{,}012$ & $-0{,}8$ & $-0{,}039$ & $-2{,}1$ \\
291 +\bottomrule
292 +\end{tabular}
293 +\tabsource{\synth{analysis/python/cases/ch19\_spatial.py ; $\E[I] = -0{,}0017$ ; $z$ sous l'hypothèse de normalité, \cref{eq:19:moran-moments}}}
294 +\end{table}
295 +
296 +La \cref{fig:19:carte-residus} rend le phénomène visible. Sans indicatrices, les résidus sont positifs sur tout Aylmer et négatifs sur Buckingham et Masson-Angers : le modèle sous-évalue systématiquement un secteur et surévalue les autres, et la carte des résidus dessine la carte des primes de secteur. Avec les indicatrices, les résidus positifs et négatifs se mêlent sans structure apparente. Le corrélogramme de la \cref{fig:19:correlogramme} précise l'échelle de la dépendance : pour les résidus sans indicatrices, $I$ vaut 0,22 pour les paires distantes de moins de 500 mètres, reste autour de 0,20 à 0,26 jusqu'à quatre kilomètres, s'annule entre six et huit kilomètres, puis devient franchement négatif au-delà ($-0{,}29$ entre 12 et 15 kilomètres). Ce profil en plateau suivi d'une inversion n'est pas celui d'une décroissance continue avec la distance ; c'est la signature de sous-marchés d'étendue finie, à l'intérieur desquels les résidus partagent un même niveau et entre lesquels ils s'opposent. Pour les résidus du modèle de référence, aucune bande ne s'écarte significativement de zéro.
297 +
298 +\begin{figure}[htbp]
299 +\centering
300 +\includegraphics[width=\textwidth]{ch19/ch19-fig-carte-residus.pdf}
301 +\caption{Résidus du modèle semi-logarithmique sur les coordonnées synthétiques des 600 ventes du cas 1 (étoile : sujet). Sans indicatrices de secteur (gauche), les résidus positifs se concentrent à Aylmer et les résidus négatifs à Buckingham et Masson-Angers ; avec les indicatrices du modèle de référence (droite), les signes se mêlent sans structure.}\label{fig:19:carte-residus}
302 +\figsource{\synth{analysis/python/figures/ch19\_figures.py, graine 20261031}}
303 +\end{figure}
304 +
305 +\begin{figure}[htbp]
306 +\centering
307 +\includegraphics[width=\textwidth]{ch19/ch19-fig-correlogramme.pdf}
308 +\caption{Corrélogramme spatial : statistique $I$ de Moran des résidus calculée sur les seules paires de ventes dont la distance tombe dans chaque bande, avec un intervalle à 95 \% sous l'hypothèse nulle. Le plateau positif jusqu'à quatre kilomètres puis l'inversion au-delà de huit kilomètres signalent des sous-marchés d'étendue finie ; les résidus du modèle de référence ne s'écartent de zéro dans aucune bande.}\label{fig:19:correlogramme}
309 +\figsource{\synth{analysis/python/figures/ch19\_figures.py}}
310 +\end{figure}
311 +
312 +Les tests LM du \cref{tab:19:lm} confirment et précisent. Pour la spécification sans indicatrices, tout est significatif sauf le test robuste du décalage : $\mathrm{LM}_{\text{err}} = 285$, $\mathrm{LM}_{\text{lag}} = 116$, $\mathrm{RLM}_{\text{err}} = 170$, mais $\mathrm{RLM}_{\text{lag}} = 1{,}09$ avec une valeur $p$ de 0,30. La règle séquentielle de la \cref{sec:19:tests} désigne sans ambiguïté le modèle à erreur spatiale : la dépendance est dans les résidus, non dans une interaction entre les prix, ce qui est exactement la vérité du générateur, où la prime de secteur omise joue le rôle d'un terme d'erreur commun aux maisons d'un même secteur. Pour le modèle de référence, aucune des cinq statistiques n'approche le seuil de signification.
313 +
314 +\begin{table}[htbp]
315 +\caption{Tests du multiplicateur de Lagrange sur les résidus MCO, matrice des huit plus proches voisins (données synthétiques)}\label{tab:19:lm}
316 +\centering
317 +\small
318 +\begin{tabular}{lrrrr}
319 +\toprule
320 + & \multicolumn{2}{c}{Sans indicatrices de secteur} & \multicolumn{2}{c}{Modèle de référence} \\
321 +\cmidrule(lr){2-3}\cmidrule(lr){4-5}
322 +Test & {Statistique} & {Valeur $p$} & {Statistique} & {Valeur $p$} \\
323 +\midrule
324 +$\mathrm{LM}_{\text{err}}$ ($\chi^2_1$) & 285,18 & $< 0{,}001$ & 0,50 & 0,478 \\
325 +$\mathrm{LM}_{\text{lag}}$ ($\chi^2_1$) & 116,21 & $< 0{,}001$ & 1,80 & 0,179 \\
326 +$\mathrm{RLM}_{\text{err}}$ ($\chi^2_1$) & 170,06 & $< 0{,}001$ & 0,01 & 0,909 \\
327 +$\mathrm{RLM}_{\text{lag}}$ ($\chi^2_1$) & 1,09 & 0,297 & 1,31 & 0,252 \\
328 +SARMA ($\chi^2_2$) & 286,26 & $< 0{,}001$ & 1,82 & 0,403 \\
329 +\bottomrule
330 +\end{tabular}
331 +\tabsource{\synth{analysis/python/cases/ch19\_spatial.py, \cref{eq:19:lm,eq:19:rlm} ; valeurs identiques à celles de spreg}}
332 +\end{table}
333 +
334 +\subsection{SAR, SEM et SDM sur la spécification sans secteur}
335 +
336 +Le \cref{tab:19:coefficients} compare, pour la spécification sans indicatrices, les estimations MCO, SAR, SEM et SDM obtenues par maximum de vraisemblance concentré avec $\mathbf{W}_{\text{knn}}$, et les met en regard des coefficients du modèle de référence. Trois lectures s'imposent.
337 +
338 +\begin{table}[htbp]
339 +\caption{Coefficients du modèle semi-logarithmique sans indicatrices de secteur selon la méthode d'estimation, matrice des huit plus proches voisins, et modèle de référence avec indicatrices (données synthétiques ; erreurs-types entre parenthèses)}\label{tab:19:coefficients}
340 +\centering
341 +\footnotesize\setlength{\tabcolsep}{4pt}
342 +\begin{tabular}{lrrrrrr}
343 +\toprule
344 + & \multicolumn{5}{c}{Sans indicatrices de secteur} & {Référence} \\
345 +\cmidrule(lr){2-6}\cmidrule(lr){7-7}
346 +Variable & {MCO} & {SAR} & {SEM} & {SDM ($\beta$)} & {SDM ($\theta$, $\mathbf{W}x$)} & {MCO} \\
347 +\midrule
348 +Constante & 13,2285 & 7,4800 & 13,2429 & 6,4950 & --- & 13,2074 \\
349 + & (0,0164) & (0,5413) & (0,0195) & (0,7211) & & (0,0138) \\
350 +$\ln(S/140)$ & 0,5501 & 0,5571 & 0,5416 & 0,5459 & $-0{,}2439$ & 0,5391 \\
351 + & (0,0167) & (0,0157) & (0,0140) & (0,0146) & & (0,0127) \\
352 +$\ln(T/560)$ & 0,0419 & 0,0647 & 0,0886 & 0,0793 & $-0{,}1719$ & 0,0937 \\
353 + & (0,0134) & (0,0115) & (0,0107) & (0,0108) & & (0,0110) \\
354 +Âge & $-0{,}0046$ & $-0{,}0052$ & $-0{,}0049$ & $-0{,}0049$ & 0,0026 & $-0{,}0051$ \\
355 + & (0,0007) & (0,0007) & (0,0006) & (0,0006) & & (0,0006) \\
356 +Âge$^2/100$ & 0,0021 & 0,0026 & 0,0023 & 0,0024 & $-0{,}0005$ & 0,0026 \\
357 + & (0,0008) & (0,0008) & (0,0007) & (0,0007) & & (0,0006) \\
358 +Qualité ($-3$) & 0,0581 & 0,0604 & 0,0600 & 0,0605 & $-0{,}0336$ & 0,0611 \\
359 + & (0,0054) & (0,0047) & (0,0042) & (0,0043) & & (0,0042) \\
360 +Garage (places) & 0,0248 & 0,0273 & 0,0322 & 0,0305 & $-0{,}0432$ & 0,0324 \\
361 + & (0,0062) & (0,0059) & (0,0054) & (0,0055) & & (0,0050) \\
362 +Sous-sol fini & 0,0275 & 0,0247 & 0,0219 & 0,0209 & $-0{,}0126$ & 0,0220 \\
363 + & (0,0086) & (0,0079) & (0,0072) & (0,0073) & & (0,0069) \\
364 +Piscine & 0,0192 & 0,0283 & 0,0307 & 0,0263 & $-0{,}0506$ & 0,0263 \\
365 + & (0,0109) & (0,0114) & (0,0103) & (0,0105) & & (0,0084) \\
366 +À étages & 0,0358 & 0,0376 & 0,0318 & 0,0298 & $-0{,}0228$ & 0,0301 \\
367 + & (0,0085) & (0,0079) & (0,0070) & (0,0073) & & (0,0069) \\
368 +Distance au centre ($> 3$ km) & $-0{,}0117$ & $-0{,}0078$ & $-0{,}0131$ & $-0{,}0166$ & 0,0141 & $-0{,}0036$ \\
369 + & (0,0007) & (0,0007) & (0,0013) & (0,0125) & & (0,0027) \\
370 +Mois (tendance) & 0,0039 & 0,0039 & 0,0038 & 0,0038 & $-0{,}0017$ & 0,0036 \\
371 + & (0,0003) & (0,0003) & (0,0003) & (0,0003) & & (0,0003) \\
372 +\midrule
373 +$\rho$ ou $\lambda$ & --- & 0,4385 & 0,6486 & 0,5089 & & --- \\
374 + & & (0,0412) & (0,0447) & (0,0545) & & \\
375 +$\hat\sigma$ & 0,1043 & 0,0937 & 0,0829 & 0,0812 & & 0,0830 \\
376 +Log-vraisemblance & 510,7 & 554,2 & 590,6 & 606,7 & & 650,0 \\
377 +AIC & $-995{,}4$ & $-1\,080{,}5$ & $-1\,153{,}2$ & $-1\,163{,}4$ & & $-1\,266{,}0$ \\
378 +$I$ de Moran des innovations & 0,324 & 0,121 & $-0{,}041$ & $-0{,}035$ & & $-0{,}014$ \\
379 +\bottomrule
380 +\end{tabular}
381 +\tabsource{\synth{analysis/python/cases/ch19\_spatial.py ; erreurs-types HC1 pour les MCO, asymptotiques (matrice d'information) pour SAR, SEM et SDM ; les indicatrices de secteur du modèle de référence ne sont pas reproduites (voir \cref{ch:18})}}
382 +\end{table}
383 +
384 +D'abord, les coefficients des caractéristiques physiques sont remarquablement stables d'une méthode à l'autre : l'élasticité à la superficie vaut 0,54 à 0,56 partout, la prime de qualité 0,058 à 0,061, l'effet de l'âge $-0{,}0046$ à $-0{,}0052$ par année. Les caractéristiques physiques sont peu corrélées avec la localisation dans ce jeu de données, et leur prix implicite ne souffre pas de l'omission des secteurs. Deux coefficients font exception et leur comportement est instructif. Le terrain, dont l'élasticité vraie est de 0,10, tombe à 0,042 dans les MCO sans secteur et remonte à 0,089 dans le SEM et 0,094 dans le modèle de référence : les grands terrains sont surreprésentés dans les secteurs périphériques à prime négative, et le coefficient MCO absorbe cette corrélation ; le SEM, en modélisant le résidu commun, la neutralise en grande partie. La distance au centre, dont la pente vraie est de $-0{,}006$ par kilomètre au-delà de trois kilomètres, vaut $-0{,}0117$ sans secteur, parce qu'elle est seule à distinguer Buckingham et Masson-Angers, et $-0{,}0036$ dans le modèle de référence, qui la sépare des primes de secteur.
385 +
386 +Ensuite, le classement des modèles par la vraisemblance et l'AIC est net : MCO ($-995$), SAR ($-1\,081$), SEM ($-1\,153$), SDM ($-1\,163$), et, loin devant, le modèle de référence avec ses quatre indicatrices ($-1\,266$). Le SAR laisse des innovations encore corrélées ($I = 0{,}12$), signe qu'il n'est pas la bonne forme ; le SEM et le SDM les blanchissent ($I = -0{,}04$). Le SDM l'emporte sur le SEM d'une dizaine de points d'AIC parce que ses décalages $\mathbf{W}x$ lui permettent de reproduire un SEM ($\boldsymbol\theta \approx -\lambda\boldsymbol\beta$) tout en ajustant quelques coefficients, mais ses paramètres $\theta$ sont individuellement peu interprétables : le décalage du terrain ($-0{,}17$) et celui de la piscine ($-0{,}05$) n'ont aucune contrepartie dans le générateur. Le modèle qui connaît les frontières des secteurs bat tous les modèles qui tentent de les deviner par le voisinage, avec moins de paramètres : c'est la leçon de la \cref{sec:19:effets-fixes}, et elle vaut pour la pratique, où la connaissance du marché local, traduite en zones, reste la première ressource de l'évaluateur.
387 +
388 +Enfin, le paramètre $\rho = 0{,}44$ du SAR est très significatif, et il est entièrement trompeur. Le générateur ne contient aucune interaction entre les prix ; $\rho$ capte la ressemblance des primes de secteur entre voisines. Appliqué sur le modèle de référence, le même SAR donne $\rho = -0{,}08$ (erreur-type 0,054) et le SEM $\lambda = -0{,}07$ (0,090), ni l'un ni l'autre significatif : l'« interaction » disparaît dès que la localisation est correctement spécifiée. Le \cref{tab:19:effets} en tire la conséquence pour les effets. Dans le SAR, l'effet total de la superficie vaut $0{,}5571/(1 - 0{,}4385) = 0{,}992$, conformément à la \cref{prop:effet-total}, soit une élasticité apparente de près de un, alors que l'élasticité du générateur est 0,55 : l'effet indirect de 0,42 est un artefact. Dans le SDM, les décalages $\theta$ négatifs compensent presque exactement la propagation par $\rho$, et l'effet total de la superficie retombe à 0,615, proche de la vérité ; c'est la raison pour laquelle \textcite{lesagePace2009} préfèrent le SDM, mais le prix de cette robustesse est que ses effets indirects, tantôt positifs tantôt négatifs d'une variable à l'autre, ne décrivent rien de réel ici.
389 +
390 +\begin{table}[htbp]
391 +\caption{Effets directs, indirects et totaux moyens du SAR et du SDM sans indicatrices de secteur (données synthétiques ; variables sélectionnées)}\label{tab:19:effets}
392 +\centering
393 +\small
394 +\begin{tabular}{lrrrrrrr}
395 +\toprule
396 + & & \multicolumn{3}{c}{SAR ($\rho = 0{,}4385$)} & \multicolumn{3}{c}{SDM ($\rho = 0{,}5089$)} \\
397 +\cmidrule(lr){3-5}\cmidrule(lr){6-8}
398 +Variable & {$\beta$ MCO} & {Direct} & {Indirect} & {Total} & {Direct} & {Indirect} & {Total} \\
399 +\midrule
400 +$\ln(S/140)$ & 0,5501 & 0,5715 & 0,4206 & 0,9921 & 0,5484 & 0,0666 & 0,6150 \\
401 +$\ln(T/560)$ & 0,0419 & 0,0664 & 0,0489 & 0,1153 & 0,0696 & $-0{,}2581$ & $-0{,}1885$ \\
402 +Âge & $-0{,}0046$ & $-0{,}0053$ & $-0{,}0039$ & $-0{,}0092$ & $-0{,}0049$ & 0,0003 & $-0{,}0046$ \\
403 +Qualité & 0,0581 & 0,0620 & 0,0456 & 0,1076 & 0,0603 & $-0{,}0056$ & 0,0547 \\
404 +Garage & 0,0248 & 0,0280 & 0,0206 & 0,0485 & 0,0284 & $-0{,}0544$ & $-0{,}0260$ \\
405 +Distance au centre & $-0{,}0117$ & $-0{,}0080$ & $-0{,}0059$ & $-0{,}0139$ & $-0{,}0161$ & 0,0111 & $-0{,}0050$ \\
406 +\bottomrule
407 +\end{tabular}
408 +\tabsource{\synth{analysis/python/cases/ch19\_spatial.py, \cref{eq:19:effets} ; les effets du générateur sont 0,55 (superficie), 0,10 (terrain), $-0{,}0055$ (âge, pente à l'origine), 0,06 (qualité), 0,035 (garage), $-0{,}006$ (distance)}}
409 +\end{table}
410 +
411 +\subsection{La régression géographiquement pondérée}
412 +
413 +La GWR est estimée sur la spécification sans indicatrices, avec le noyau gaussien de l'\cref{eq:19:noyau}. La \cref{fig:19:gwr-bande} montre le score de validation croisée selon la bande passante : il explose en deçà d'un kilomètre, où les régressions locales reposent sur trop peu de ventes, décroît jusqu'à un minimum à $h^* = 4{,}99$ kilomètres, puis remonte lentement vers la régression globale. La bibliothèque \texttt{mgwr} retient 4,87 kilomètres avec le même critère, le score étant plat à $10^{-4}$ près entre les deux ; avec $h^*$, la trace de la matrice chapeau vaut 44,8, soit l'équivalent de 45 paramètres pour 12 variables, le $R^2$ vaut 0,861 et l'écart-type résiduel 0,083, celui du modèle de référence. Le critère $\mathrm{AIC}_c$ passe de $-994{,}8$ pour les MCO sans secteur à $-1\,233{,}2$ pour la GWR, mais reste au-dessus des $-1\,264{,}9$ du modèle de référence : quarante-cinq paramètres locaux font moins bien que quatre indicatrices bien placées.
414 +
415 +\begin{figure}[htbp]
416 +\centering
417 +\includegraphics[width=0.52\textwidth]{ch19/ch19-fig-gwr-bande.pdf}
418 +\caption{Score de validation croisée de la GWR (noyau gaussien) selon la bande passante. Le minimum à 4,99 km marque l'arbitrage entre une bande courte, qui suit les variations locales sur trop peu de ventes, et une bande longue, qui lisse tout vers la régression globale.}\label{fig:19:gwr-bande}
419 +\figsource{\synth{analysis/python/figures/ch19\_figures.py}}
420 +\end{figure}
421 +
422 +Le \cref{tab:19:gwr} résume la distribution des coefficients locaux et la \cref{fig:19:gwr-coefficients} les cartographie. L'élasticité à la superficie varie de 0,51 à 0,55 d'un point à l'autre (médiane 0,54), l'effet de l'âge de $-0{,}62$ à $-0{,}25$ \% par année, la prime de qualité de 0,049 à 0,072 : des variations modestes, sans structure géographique nette, compatibles avec des coefficients constants que le générateur impose et qu'un bruit d'échantillonnage local fait osciller. La constante locale, en revanche, varie de 12,77 à 13,21 et dessine la carte des secteurs. Pour la rendre comparable au modèle de référence, on calcule un effet de localisation local, constante plus coefficient local de la distance multiplié par la distance, en écart à la médiane de Hull ; sa médiane par secteur vaut $-0{,}31$ à Buckingham et $-0{,}32$ à Masson-Angers, contre $-0{,}34$ et $-0{,}35$ pour l'effet de localisation du modèle de référence (indicatrice plus gradient) et $-0{,}34$ et $-0{,}35$ pour celui du générateur ; $-0{,}03$ dans le secteur Gatineau contre $-0{,}04$ et $-0{,}05$. La GWR retrouve donc les deux grandes discontinuités, mais elle manque presque entièrement la prime d'Aylmer : $+0{,}003$ contre $+0{,}057$ pour le modèle de référence et $+0{,}062$ pour le générateur. Aylmer est contigu à Hull, et une bande passante de cinq kilomètres mélange les ventes des deux secteurs de part et d'autre de la frontière ; la discontinuité est lissée, comme la \cref{sec:19:gwr} l'annonçait. Les secteurs périphériques, isolés par plusieurs kilomètres sans vente, ne souffrent pas de ce lissage.
423 +
424 +\begin{table}[htbp]
425 +\caption{Distribution des coefficients locaux de la GWR (noyau gaussien, bande passante 4,99 km) sur les 600 ventes, spécification sans indicatrices de secteur (données synthétiques)}\label{tab:19:gwr}
426 +\centering
427 +\small
428 +\begin{tabular}{lrrrrrr}
429 +\toprule
430 +Coefficient local & {Minimum} & {Q1} & {Médiane} & {Q3} & {Maximum} & {Global (MCO)} \\
431 +\midrule
432 +Constante & 12,769 & 13,171 & 13,202 & 13,206 & 13,208 & 13,229 \\
433 +$\ln(S/140)$ & 0,508 & 0,533 & 0,542 & 0,549 & 0,552 & 0,550 \\
434 +$\ln(T/560)$ & 0,037 & 0,083 & 0,089 & 0,103 & 0,134 & 0,042 \\
435 +Âge & $-0{,}0062$ & $-0{,}0053$ & $-0{,}0048$ & $-0{,}0045$ & $-0{,}0025$ & $-0{,}0046$ \\
436 +Qualité & 0,049 & 0,058 & 0,059 & 0,060 & 0,072 & 0,058 \\
437 +Garage & 0,018 & 0,030 & 0,033 & 0,035 & 0,039 & 0,025 \\
438 +Distance au centre & $-0{,}0127$ & $-0{,}0069$ & $-0{,}0030$ & 0,0025 & 0,0073 & $-0{,}0117$ \\
439 +Mois & 0,0032 & 0,0033 & 0,0034 & 0,0036 & 0,0046 & 0,0039 \\
440 +\bottomrule
441 +\end{tabular}
442 +\tabsource{\synth{analysis/python/cases/ch19\_spatial.py ; trace de la matrice chapeau 44,8 ; $\mathrm{AIC}_c$ $-1\,233{,}2$ contre $-994{,}8$ (MCO sans secteur) et $-1\,264{,}9$ (modèle de référence)}}
443 +\end{table}
444 +
445 +\begin{figure}[htbp]
446 +\centering
447 +\includegraphics[width=\textwidth]{ch19/ch19-fig-gwr-coefficients.pdf}
448 +\caption{Coefficients locaux de la GWR sur les coordonnées synthétiques : effet de localisation local en écart à Hull (gauche), élasticité locale à la superficie (centre) et effet local de l'âge (droite). La localisation reproduit les secteurs périphériques mais lisse la frontière Hull--Aylmer ; les coefficients des caractéristiques varient peu et sans structure.}\label{fig:19:gwr-coefficients}
449 +\figsource{\synth{analysis/python/figures/ch19\_figures.py}}
450 +\end{figure}
451 +
452 +\subsection{Effets fixes : secteurs, grilles et trimestres}
453 +
454 +Le \cref{tab:19:effets-fixes} compare des spécifications à effets fixes de granularité croissante. Les quatre indicatrices de secteur font passer l'AIC de $-995$ à $-1\,266$ et annulent la dépendance résiduelle. Des grilles géométriques de cinq, trois et deux kilomètres, avec respectivement 19, 39 et 67 cellules, augmentent le $R^2$ brut (jusqu'à 0,872) mais dégradent l'AIC ($-1\,252$, $-1\,240$, $-1\,225$) : chaque cellule supplémentaire coûte plus en paramètres qu'elle ne rapporte en ajustement, et la grille la plus fine produit même une légère autocorrélation négative des résidus ($I = -0{,}065$), symptôme d'un sur-ajustement local. Les grilles coupent les secteurs au hasard, de sorte qu'il faut beaucoup de cellules pour approcher ce que quatre frontières bien placées font d'emblée. Du côté du temps, remplacer la tendance linéaire par quatorze indicatrices trimestrielles laisse l'écart-type résiduel inchangé (0,083) et dégrade l'AIC de douze points ; l'indice implicite croît de zéro au premier trimestre de 2023 à 0,199 en logarithme au troisième trimestre de 2026, soit 22 \%, mais par à-coups de $\pm 0{,}02$ d'un trimestre à l'autre qui ne sont que du bruit d'échantillonnage sur une quarantaine de ventes par trimestre. Sur un marché réel, où la trajectoire des prix n'est pas une exponentielle régulière, les indicatrices temporelles seraient préférables ; le \cref{ch:35} discute comment les lisser.
455 +
456 +\begin{table}[htbp]
457 +\caption{Spécifications à effets fixes spatiaux et temporels sur les 600 ventes du cas 1 (données synthétiques)}\label{tab:19:effets-fixes}
458 +\centering
459 +\small
460 +\begin{tabular}{lrrrrrr}
461 +\toprule
462 +Spécification & {Param.} & {$R^2$} & {$\bar R^2$} & {$\hat\sigma$} & {AIC} & {$I$ résidus} \\
463 +\midrule
464 +Gradient de distance seul ; tendance linéaire & 12 & 0,766 & 0,762 & 0,104 & $-995{,}4$ & 0,324 \\
465 +Secteurs et gradient (référence) ; tendance linéaire & 16 & 0,853 & 0,849 & 0,083 & $-1\,266{,}0$ & $-0{,}014$ \\
466 +Grille de 5 km (19 cellules) ; tendance linéaire & 30 & 0,856 & 0,849 & 0,083 & $-1\,252{,}1$ & $-0{,}021$ \\
467 +Grille de 3 km (39 cellules) ; tendance linéaire & 50 & 0,863 & 0,851 & 0,083 & $-1\,240{,}4$ & $-0{,}032$ \\
468 +Grille de 2 km (67 cellules) ; tendance linéaire & 78 & 0,872 & 0,853 & 0,082 & $-1\,225{,}4$ & $-0{,}065$ \\
469 +Secteurs et gradient ; 14 indicatrices trimestrielles & 29 & 0,856 & 0,849 & 0,083 & $-1\,253{,}8$ & $-0{,}018$ \\
470 +\bottomrule
471 +\end{tabular}
472 +\tabsource{\synth{analysis/python/cases/ch19\_spatial.py ; $I$ de Moran avec $\mathbf{W}_{\text{knn}}$ ; AIC $= 2(k+1) - 2\hat\ell$}}
473 +\end{table}
474 +
475 +\subsection{Variogramme et krigeage des résidus}
476 +
477 +Le variogramme empirique des résidus sans indicatrices (\cref{fig:19:variogramme}) a une forme que la \cref{sec:19:krigeage} n'avait pas annoncée et qui mérite qu'on s'y arrête. Jusqu'à six kilomètres, la semi-variance est presque constante, de 0,0070 à 0,0076 ; elle croît ensuite régulièrement jusqu'à 0,0170 vers douze kilomètres, puis se stabilise et redescend légèrement. La valeur du plateau initial est, à peu de chose près, la variance $\sigma^2 = 0{,}0069$ du bruit de transaction du modèle de référence : deux ventes distantes de moins de six kilomètres appartiennent presque toujours au même secteur, et leur différence de résidus ne contient que du bruit ; au-delà, les paires chevauchent des secteurs différents, et la différence des primes s'ajoute. La variance totale des résidus, 0,0107, se décompose donc en une pépite de bruit et une composante de secteur de 0,0038 qui n'apparaît qu'à grande distance. Le variogramme des résidus du modèle de référence, lui, est plat à 0,0065--0,0072 sur toute la plage : il ne reste rien à kriger. Le modèle exponentiel ajusté par moindres carrés pondérés donne une pépite de 0,0046, un palier partiel de 0,0090 et une portée $a$ qui bute sur la borne de cinq kilomètres imposée à l'ajustement (portée pratique de quinze kilomètres) : un processus à paliers par secteur n'est pas stationnaire au sens géostatistique, et aucun modèle à décroissance continue ne le décrit parfaitement. L'ajustement reste utilisable pour le krigeage, parce que la prédiction locale dépend surtout du rapport entre la pépite et le palier à courte distance.
478 +
479 +\begin{figure}[htbp]
480 +\centering
481 +\includegraphics[width=\textwidth]{ch19/ch19-fig-variogramme.pdf}
482 +\caption{Variogramme empirique des résidus (bandes de 1 km) et modèle exponentiel ajusté. Pour les résidus sans indicatrices de secteur, le plateau initial égale la variance du bruit de transaction et la composante de secteur n'apparaît qu'au-delà de six kilomètres ; les résidus du modèle de référence ont un variogramme plat.}\label{fig:19:variogramme}
483 +\figsource{\synth{analysis/python/figures/ch19\_figures.py}}
484 +\end{figure}
485 +
486 +La \cref{fig:19:krigeage} montre la surface des résidus krigés avec les quarante voisins les plus proches de chaque nœud d'une grille, à moins de 2,5 kilomètres d'une vente. Elle reconstitue la carte des primes : positive sur Aylmer, négative sur Buckingham et Masson-Angers, proche de zéro à Hull et dans le secteur Gatineau. Au sujet, le résidu krigé vaut $-0{,}060$, avec une variance de krigeage de 0,0057 (écart-type 0,075) : les ventes autour du sujet se sont vendues en moyenne 6 \% sous ce que le modèle sans secteur prédit, parce que ce modèle, ne distinguant pas Hull d'Aylmer, surévalue Hull. En validation croisée, l'erreur quadratique moyenne des résidus passe de 0,105 avant krigeage à 0,088 après, et la statistique de Moran des erreurs devient négative ($-0{,}11$), signe que le krigeage a plutôt sur-corrigé localement.
487 +
488 +\begin{figure}[htbp]
489 +\centering
490 +\includegraphics[width=\textwidth]{ch19/ch19-fig-krigeage.pdf}
491 +\caption{Surface des résidus du modèle sans indicatrices de secteur krigée par krigeage ordinaire (modèle exponentiel, quarante voisins), sur les coordonnées synthétiques ; les points sont les ventes et l'étoile le sujet. La surface reconstitue la carte des primes de secteur que le modèle ignorait.}\label{fig:19:krigeage}
492 +\figsource{\synth{analysis/python/figures/ch19\_figures.py}}
493 +\end{figure}
494 +
495 +\subsection{Le décalage spatio-temporel}
496 +
497 +Avec la matrice $\mathbf{W}_{\text{st}}$, la statistique de Moran des résidus sans indicatrices vaut 0,305 ($z = 17{,}1$), à peine moins qu'avec les matrices purement spatiales : la dépendance est tout aussi visible lorsqu'on ne fait parler que les ventes antérieures, ce qui est la situation réelle de l'évaluateur. Le modèle à décalage spatio-temporel est estimé, conformément à la \cref{prop:jacobien-st}, par moindres carrés sur les 582 ventes qui ont au moins une voisine antérieure (15,5 en moyenne) : $\hat\rho_{\text{st}} = 0{,}370$ (erreur-type robuste 0,053), avec un $R^2$ de 0,785. Le prix d'une maison « suit » donc, à hauteur de 37 \%, la moyenne des prix récents de son voisinage, mais les résidus de ce modèle restent autocorrélés ($I = 0{,}13$) : comme le SAR, le décalage spatio-temporel ne fait qu'une partie du travail, parce que la vraie structure est un résidu de secteur, non une interaction. Le modèle à erreur spatio-temporelle, qui place $\mathbf{W}_{\text{st}}$ dans le terme d'erreur, donne $\hat\lambda_{\text{st}} = 0{,}748$ (0,060) et un AIC de $-1\,154{,}8$, équivalent à celui du SEM spatial. L'intérêt de la pondération spatio-temporelle n'est donc pas ici un meilleur ajustement ; c'est la cohérence avec le mécanisme supposé et, dans un marché en mouvement rapide, la garantie que les voisines mobilisées sont contemporaines du sujet.
498 +
499 +\subsection{Validation croisée et indications pour le sujet}
500 +
501 +\begin{exemple}{Six indications de valeur pour la maison du cas 1}{cas1-spatial}
502 +Le sujet est le cottage de \msq{165} du secteur de Hull décrit au \cref{ch:16}, évalué au 1\textsuperscript{er} septembre 2026. Ses huit plus proches ventes sont toutes dans Hull et comptent deux des cinq comparables du \cref{ch:16}, G0404 et G0574. Le \cref{tab:19:validation} rassemble, pour six modèles, la précision en validation croisée sur les 600 ventes et l'indication pour le sujet, toutes retransformées en niveau avec le facteur de Duan du modèle concerné.
503 +
504 +\emph{Le modèle de référence.} Avec les indicatrices de secteur, l'erreur quadratique moyenne de prédiction hors échantillon est de 0,084 en logarithme, soit une erreur absolue moyenne de 6,8 \% du prix, et l'indication pour le sujet est de \dollars{637953}, celle du \cref{ch:18}. C'est la borne que les autres modèles tentent d'approcher sans connaître les secteurs.
505 +
506 +\emph{Les MCO sans secteur.} L'erreur passe à 0,105 (8,4 \%) et l'indication à \dollars{664342}, 4,1 \% au-dessus de la référence : la constante sans secteur est une moyenne des niveaux de prix de tous les secteurs, tirée vers le haut par Aylmer, et le sujet, à Hull, en hérite.
507 +
508 +\emph{Le krigeage des résidus.} La correction de $-0{,}060$ krigée au sujet ramène l'indication à \dollars{624799}, 2,1 \% sous la référence, et l'erreur de validation à 0,088 (7,1 \%). Le krigeage a corrigé l'essentiel du biais de localisation ; il l'a un peu sur-corrigé, parce que les voisines du sujet ont, par hasard d'échantillonnage, des résidus légèrement plus négatifs que la moyenne de Hull.
509 +
510 +\emph{Le SEM.} L'espérance conditionnelle du résidu au sujet, calculée à partir de la matrice de précision du SEM étendue au sujet, vaut $-0{,}052$ ; l'indication est de \dollars{633352}, à 0,7 \% de la référence, et l'erreur de validation de 0,088 (7,1 \%). SEM et krigeage sont deux expressions de la même idée, et leurs résultats sont presque interchangeables.
511 +
512 +\emph{Le SAR.} La prédiction conditionnelle $\hat\rho\,(\mathbf{W}\mathbf{y})_s + \mathbf{x}_s\T\hat{\boldsymbol\beta}$ utilise la moyenne des logarithmes des prix des huit ventes voisines ; elle donne \dollars{654429}, 2,6 \% au-dessus de la référence, et l'erreur de validation la plus élevée des modèles spatiaux (0,095) : le SAR corrige moins bien parce qu'il n'est pas la bonne forme.
513 +
514 +\emph{La GWR.} Les coefficients locaux au sujet (constante 13,198, élasticité 0,535) donnent \dollars{627519}, 1,6 \% sous la référence, et l'erreur de validation croisée la plus faible des modèles sans secteur (0,086).
515 +
516 +Aucun des modèles qui ignorent les secteurs ne retrouve exactement la référence, mais tous ceux qui exploitent les résidus voisins (krigeage, SEM, GWR) ramènent l'écart de $+4{,}1$ \% à moins de 2,5 \% en valeur absolue, et réduisent l'erreur de prédiction d'un cinquième. C'est l'ordre de grandeur de ce que l'information spatiale apporte lorsqu'on ne dispose pas d'une bonne segmentation ; lorsqu'on en dispose, elle n'apporte plus rien ici, parce que le générateur n'a rien laissé d'autre à expliquer. Sur un marché réel, la segmentation administrative n'épuise jamais la localisation, et les deux approches se complètent : indicatrices de secteur pour les paliers connus, krigeage ou SEM pour ce qui varie à l'intérieur.
517 +\end{exemple}
518 +
519 +\begin{table}[htbp]
520 +\caption{Précision en validation croisée sur les 600 ventes et indication de valeur pour le sujet selon le modèle (données synthétiques)}\label{tab:19:validation}
521 +\centering
522 +\small\setlength{\tabcolsep}{4pt}
523 +\begin{tabular}{lrrrrr}
524 +\toprule
525 +Modèle & {EQM (log)} & {Err. abs. moy.} & {$I$ des erreurs} & {Indication (\$)} & {Écart à la réf.} \\
526 +\midrule
527 +MCO, modèle de référence (secteurs) & 0,084 & 6,8 \% & $-0{,}014$ & 637 953 & --- \\
528 +MCO sans indicatrices de secteur & 0,105 & 8,4 \% & 0,323 & 664 342 & $+4{,}1$ \% \\
529 +MCO sans secteur + krigeage du résidu & 0,088 & 7,1 \% & $-0{,}115$ & 624 799 & $-2{,}1$ \% \\
530 +SEM sans secteur (espérance conditionnelle) & 0,088 & 7,1 \% & $-0{,}152$ & 633 352 & $-0{,}7$ \% \\
531 +SAR sans secteur (prédiction conditionnelle) & 0,095 & 7,6 \% & 0,121 & 654 429 & $+2{,}6$ \% \\
532 +GWR sans secteur (coefficients locaux) & 0,086 & --- & $-0{,}002$ & 627 519 & $-1{,}6$ \% \\
533 +\bottomrule
534 +\end{tabular}
535 +\tabsource{\synth{analysis/python/cases/ch19\_spatial.py ; EQM = racine de l'erreur quadratique moyenne des prédictions leave-one-out en logarithme ; erreur absolue moyenne en niveau ; indications retransformées avec le facteur de Duan ; écart en pourcentage de l'indication de référence}}
536 +\end{table}
537 +
538 +\section{Sensibilité au choix de la matrice de pondération}\label{sec:19:sensibilite}
539 +
540 +Le \cref{tab:19:sensibilite} et la \cref{fig:19:sensibilite} reprennent les estimations de la spécification sans secteur pour cinq matrices de plus proches voisins et trois matrices de distance inverse. La statistique de Moran des résidus est remarquablement stable, entre 0,32 et 0,33 quelle que soit la matrice : la dépendance est un fait des données, pas de la matrice. Les paramètres autorégressifs, eux, ne le sont pas du tout. Le $\rho$ du SAR passe de 0,24 avec quatre voisins à 0,65 avec seize, et jusqu'à 0,84 avec la distance inverse à quatre kilomètres (141 voisins en moyenne) ; le $\lambda$ du SEM, de 0,51 à 0,79, puis 0,89. Plus le voisinage est large, plus le décalage $\mathbf{W}\mathbf{y}$ est lissé, et plus il faut un coefficient élevé pour reproduire la même covariance résiduelle ; le paramètre et la matrice ne sont identifiés qu'ensemble. L'AIC, de son côté, s'améliore avec l'étendue du voisinage, parce qu'un voisinage de plusieurs kilomètres approche mieux la structure par secteurs du générateur qu'un voisinage de quatre maisons. Deux conséquences pratiques en découlent. D'abord, un paramètre autorégressif ne se compare pas d'une étude à l'autre sans la matrice qui l'accompagne, et il ne mesure pas une « intensité de l'effet de voisinage » qui existerait indépendamment d'elle. Ensuite, les grandeurs utiles à l'évaluateur, la prédiction pour un sujet et la correction de localisation, sont bien moins sensibles au choix de la matrice que les paramètres eux-mêmes, parce que les différentes matrices mobilisent à peu près la même information locale ; l'\cref{ex:19:7} le vérifie.
541 +
542 +\begin{table}[htbp]
543 +\caption{Sensibilité au choix de la matrice de pondération, spécification sans indicatrices de secteur (données synthétiques)}\label{tab:19:sensibilite}
544 +\centering
545 +\small
546 +\begin{tabular}{lrrrrrrr}
547 +\toprule
548 +Matrice & {Voisins moy.} & {$I$ résidus MCO} & {$z$} & {$\hat\rho$ SAR} & {AIC SAR} & {$\hat\lambda$ SEM} & {AIC SEM} \\
549 +\midrule
550 +4 plus proches voisins & 4,0 & 0,320 & 12,0 & 0,236 (0,036) & $-1\,033{,}0$ & 0,507 (0,041) & $-1\,108{,}7$ \\
551 +6 plus proches voisins & 6,0 & 0,331 & 15,1 & 0,343 (0,038) & $-1\,059{,}6$ & 0,596 (0,043) & $-1\,140{,}4$ \\
552 +8 plus proches voisins (référence) & 8,0 & 0,324 & 17,1 & 0,439 (0,041) & $-1\,080{,}5$ & 0,649 (0,045) & $-1\,153{,}2$ \\
553 +12 plus proches voisins & 12,0 & 0,324 & 21,2 & 0,531 (0,046) & $-1\,099{,}4$ & 0,718 (0,047) & $-1\,175{,}3$ \\
554 +16 plus proches voisins & 16,0 & 0,332 & 25,2 & 0,654 (0,046) & $-1\,135{,}9$ & 0,786 (0,044) & $-1\,204{,}1$ \\
555 +Distance inverse, 2 km & 61,8 & 0,322 & 25,2 & 0,636 (0,057) & $-1\,124{,}2$ & 0,803 (0,049) & $-1\,201{,}7$ \\
556 +Distance inverse, 3 km & 106,7 & 0,328 & 35,4 & 0,790 (0,053) & $-1\,175{,}8$ & 0,863 (0,043) & $-1\,226{,}0$ \\
557 +Distance inverse, 4 km & 140,7 & 0,327 & 40,4 & 0,837 (0,047) & $-1\,196{,}3$ & 0,888 (0,039) & $-1\,233{,}2$ \\
558 +\bottomrule
559 +\end{tabular}
560 +\tabsource{\synth{analysis/python/cases/ch19\_spatial.py ; erreurs-types asymptotiques entre parenthèses ; toutes les matrices sont standardisées en ligne et sans ligne vide}}
561 +\end{table}
562 +
563 +\begin{figure}[htbp]
564 +\centering
565 +\includegraphics[width=\textwidth]{ch19/ch19-fig-sensibilite-W.pdf}
566 +\caption{Statistique de Moran des résidus MCO, paramètre $\rho$ du SAR et paramètre $\lambda$ du SEM selon le nombre de plus proches voisins de la matrice de pondération. La dépendance mesurée par $I$ est stable ; les paramètres autorégressifs croissent avec l'étendue du voisinage et ne sont identifiés qu'avec la matrice qui les accompagne.}\label{fig:19:sensibilite}
567 +\figsource{\synth{analysis/python/figures/ch19\_figures.py}}
568 +\end{figure}
569 +
570 +\begin{quebec}[Localisation, données ouvertes et fiscalité au Québec]
571 +Les travaux de l'école de Québec sur la dimension spatiale des prix résidentiels sont parmi les plus cités de la littérature : \textcite{desrosiersTheriaultVilleneuve2000} séparent les effets d'accessibilité et de voisinage dans un modèle hédonique de la région de Québec, \textcite{theriaultDesrosiersVilleneuveKestens2003} montrent que les prix implicites des attributs y varient avec la localisation, et \textcite{dubeLegros2013} y développent leur mesure spatio-temporelle de la dépendance ; \textcite{theriaultDesrosiers2011} et \textcite{dubeLegros2014} en font la synthèse. Les outils de ce chapitre sont donc nés, pour partie, de données québécoises. Leur application à grande échelle est facilitée depuis que le ministère des Affaires municipales et de l'Habitation diffuse les rôles d'évaluation foncière des 1\,140 municipalités en format ouvert, sous licence Creative Commons Attribution 4.0, y compris dans des versions géoréférencées (FGDB, GPKG, GeoJSON) qui permettent de localiser chaque unité d'évaluation, les noms et adresses des propriétaires étant caviardés \parencite{dqroles}. L'hétérogénéité spatiale est aussi reconnue par la fiscalité locale : le règlement de taxation 2026 de la Ville de Gatineau fixe, à côté d'un taux de base de \qty{5.224}{\dollar} par \dollars{1000} d'évaluation, un taux de la taxe foncière générale résidentielle propre à chaque unité de voisinage, qui s'échelonne de 1,613 à \qty{7.544}{\dollar} par \dollars{1000} selon l'annexe du règlement \parencite{gatineaureglement994}. Les « unités de voisinage » de l'évaluateur municipal sont, en pratique, des effets fixes de quartier ; le choix de leurs frontières relève du jugement professionnel et des méthodes du \cref{ch:15}, et les tests de ce chapitre permettent de vérifier qu'elles épuisent la dépendance spatiale des ratios du \cref{ch:9}. Sources consultées le 31 octobre 2026.
572 +\end{quebec}
573 +
574 +\begin{quebec}[Modèles spatiaux et pratique professionnelle]
575 +Aucune norme québécoise n'impose ni n'interdit l'usage de modèles spatiaux ; les Normes de pratique professionnelle de l'OEAQ exigent que l'évaluateur décrive les données, l'analyse et le raisonnement qui mènent à sa conclusion \parencite{oeaqNormes}, et les normes de l'IAAO sur l'évaluation de masse et sur les modèles automatisés traitent la localisation comme une dimension à modéliser explicitement et à valider par des études de ratios \parencite{iaaomass2025,iaaoavm2018}. Un modèle spatial utilisé pour dériver un ajustement de localisation ou une indication de valeur doit donc être documenté comme n'importe quel autre élément de preuve : matrice de pondération et sa justification, tests de dépendance, forme retenue et pourquoi, précision en validation croisée, et surtout sensibilité des conclusions aux choix faits. Le \cref{ch:10} précise le contenu attendu du rapport ; le \cref{ch:20} intègre l'incertitude du modèle à la fourchette de valeur.
576 +\end{quebec}
577 +
578 +\begin{erreurs}
579 +\begin{itemize}
580 +\item Lire un $\rho$ significatif comme la preuve d'un effet de voisinage. Dans l'exemple, $\rho = 0{,}44$ ne mesure que la prime de secteur omise ; il tombe à zéro dès que la localisation est spécifiée. Les tests LM robustes et la statistique de Moran des innovations auraient dû alerter.
581 +\item Dériver des ajustements des effets totaux d'un SAR. L'effet total $\beta/(1-\rho)$ double l'élasticité à la superficie sans aucun fondement économique ; seuls les effets directs, ou les coefficients d'un SEM, s'interprètent comme des prix implicites.
582 +\item Comparer des paramètres autorégressifs estimés avec des matrices différentes, ou rapporter un $\rho$ sans décrire $\mathbf{W}$. Le \cref{tab:19:sensibilite} montre que $\rho$ varie du simple au triple selon la matrice, à dépendance égale.
583 +\item Calculer des distances en degrés de latitude et de longitude, ou mélanger des coordonnées de précisions différentes (adresses géocodées au centroïde du code postal et au bâtiment, \cref{ch:14}).
584 +\item Oublier les lignes vides d'une matrice à portée fixe ou spatio-temporelle : un décalage nul pour une observation sans voisine fausse l'estimation de $\rho$ bien plus que l'exclusion de l'observation.
585 +\item Interpréter chaque variation d'un coefficient local de GWR comme un fait de marché. Sans test de stabilité, les oscillations de l'élasticité entre 0,51 et 0,55 sont du bruit ; seules les variations de la constante locale, massives et cohérentes avec les secteurs, sont informatives.
586 +\item Kriger les résidus d'un modèle qui contient déjà des indicatrices fines de quartier : la correction est nulle en espérance et n'ajoute que de la variance.
587 +\item Choisir la bande passante d'une GWR, ou la portée d'un variogramme, sans validation croisée, ou avec une validation qui n'exclut pas l'observation prédite.
588 +\end{itemize}
589 +\end{erreurs}
590 +
591 +\begin{limites}
592 +Les résultats numériques de ce chapitre proviennent d'un jeu synthétique dont la structure spatiale est connue et simple, des primes de secteur par paliers et un gradient, sans processus spatial résiduel ; c'est ce qui permet de vérifier les outils, mais c'est aussi ce qui explique qu'un modèle à quatre indicatrices les domine tous. Sur un marché réel, la localisation agit à plusieurs échelles, et aucune segmentation administrative n'épuise la dépendance ; les écarts entre modèles seraient différents et les gains du krigeage ou du SEM, probablement plus importants. Les erreurs-types des modèles autorégressifs supposent la normalité et l'homoscédasticité des innovations, hypothèses que le \cref{ch:18} a discutées et que les estimateurs de \textcite{kelejianPrucha2010} relâchent. Le modèle exponentiel ajusté au variogramme bute sur la borne de portée imposée, parce que le processus sous-jacent n'est pas stationnaire ; la variance de krigeage qui en découle n'est qu'indicative. Les coordonnées synthétiques sont exactes, alors que les coordonnées réelles proviennent d'un géocodage dont la précision varie ; le \cref{ch:14} et le \cref{ch:38} traitent de cette source d'erreur. Enfin, les modèles de ce chapitre sont des outils d'analyse de marché et de dérivation d'ajustements ; ils ne remplacent ni l'inspection, ni la vérification des ventes, ni le jugement qui pondère les indications au \cref{ch:20}.
593 +\end{limites}
594 +
595 +\begin{synthese}
596 +\begin{itemize}
597 +\item La dépendance spatiale porte sur les résidus ou les prix et se diagnostique par la statistique de Moran, le corrélogramme et les tests LM ; l'hétérogénéité spatiale porte sur les coefficients et se traite par la segmentation ou la régression géographiquement pondérée.
598 +\item Une matrice de pondération standardisée en ligne définit le voisinage ; une matrice spatio-temporelle unidirectionnelle ne fait parler que les ventes antérieures et son jacobien vaut un. Le paramètre autorégressif n'est identifié qu'avec la matrice qui l'accompagne.
599 +\item Les résidus du modèle de référence du cas 1 ne présentent aucune dépendance spatiale ($I = -0{,}014$) ; sans les indicatrices de secteur, la dépendance est massive ($I = 0{,}32$), les tests LM désignent le modèle à erreur spatiale, et le SAR attribue à une interaction fictive ce qui n'est qu'une prime de secteur omise.
600 +\item Les effets directs d'un SAR ou les coefficients d'un SEM peuvent s'interpréter comme des prix implicites ; les effets totaux, multipliés par $1/(1-\rho)$, ne le peuvent pas sans une justification du mécanisme d'interaction.
601 +\item La GWR cartographie les coefficients locaux et retrouve les grandes discontinuités de prix, mais elle lisse les frontières contiguës et ses variations fines ne sont pas toutes significatives ; sa bande passante se choisit par validation croisée.
602 +\item Le krigeage des résidus et l'espérance conditionnelle du SEM fournissent un ajustement de localisation pour le sujet à partir des résidus des ventes voisines ; dans l'exemple, ils ramènent un biais de $+4{,}1$ \% à moins de 2,5 \% et réduisent l'erreur de prédiction d'un cinquième.
603 +\item Des effets fixes bien placés valent mieux que des effets fixes nombreux ; la connaissance des sous-marchés reste la première ressource, et les modèles spatiaux servent à vérifier qu'elle suffit et à corriger ce qu'elle laisse échapper.
604 +\end{itemize}
605 +\end{synthese}
606 +
607 +\section*{Exercices}
608 +\addcontentsline{toc}{section}{Exercices}
609 +
610 +\begin{exercice}[1]{Statistique de Moran à la main}{ex:19:1}
611 +Cinq maisons sont alignées le long d'une rue, chacune n'ayant pour voisines que ses deux adjacentes (une seule aux extrémités). Les résidus d'un modèle hédonique sont, dans l'ordre, $0{,}10$ ; $0{,}06$ ; $-0{,}02$ ; $-0{,}08$ ; $-0{,}06$. Construisez la matrice de contiguïté standardisée en ligne, calculez le décalage spatial de chaque résidu, puis la statistique $I$ de Moran de l'\cref{eq:19:moran}. Comparez-la à son espérance sous l'hypothèse nulle et interprétez le signe. Que vaudrait $I$ si les résidus alternaient de signe d'une maison à l'autre ?
612 +\end{exercice}
613 +
614 +\begin{exercice}[1]{Dépendance ou hétérogénéité ?}{ex:19:2}
615 +Pour chacune des situations suivantes, dites si elle relève de la dépendance spatiale ou de l'hétérogénéité spatiale, et proposez l'outil de ce chapitre le mieux adapté : (a) les résidus d'un modèle hédonique sont tous positifs autour d'un nouveau parc ; (b) la prime du garage est forte dans un quartier central sans stationnement sur rue et nulle en banlieue ; (c) les prix des maisons d'un même développement suivent de près les dernières ventes du promoteur ; (d) un modèle estimé sur deux arrondissements donne des élasticités à la superficie de 0,45 et 0,65 ; (e) les ratios valeur au rôle sur prix d'une municipalité sont systématiquement inférieurs à 0,90 dans l'un de ses quartiers.
616 +\end{exercice}
617 +
618 +\begin{exercice}[2]{Lire les tests LM}{ex:19:3}
619 +Un modèle hédonique estimé sur 450 ventes donne, avec une matrice de six plus proches voisins, $\mathrm{LM}_{\text{err}} = 48{,}2$, $\mathrm{LM}_{\text{lag}} = 31{,}5$, $\mathrm{RLM}_{\text{err}} = 17{,}9$ et $\mathrm{RLM}_{\text{lag}} = 1{,}2$. Appliquez la règle de décision séquentielle de la \cref{sec:19:tests} en indiquant le seuil critique à 5 \% d'une loi du $\chi^2$ à un degré de liberté et la valeur $p$ approximative de la statistique $\mathrm{RLM}_{\text{lag}}$. Quelle forme de modèle retenez-vous, et quelle interprétation économique en donnez-vous ? Qu'auriez-vous conclu si les deux statistiques robustes avaient dépassé 20 ?
620 +\end{exercice}
621 +
622 +\begin{exercice}[2]{Effets directs, indirects et totaux}{ex:19:4}
623 +Un SAR estimé avec une matrice standardisée en ligne donne $\hat\rho = 0{,}4385$ et un coefficient de $0{,}5571$ pour le logarithme de la superficie et de $0{,}0604$ pour la qualité. Calculez l'effet total moyen de chaque variable par la \cref{prop:effet-total}. Sachant que l'effet direct moyen de la superficie vaut 0,5715, déduisez l'effet indirect. Le générateur du jeu de données impose une élasticité de 0,55 : quelle est l'ampleur de l'artefact dans l'effet total, et quel est le seul des trois effets qu'un évaluateur pourrait utiliser pour dériver un ajustement de superficie ?
624 +\end{exercice}
625 +
626 +\begin{exercice}[2]{Poids d'un noyau gaussien}{ex:19:5}
627 +Quatre ventes se trouvent à 0,5 ; 1 ; 2 et 5 kilomètres d'un sujet. Calculez leurs poids dans une régression géographiquement pondérée à noyau gaussien pour une bande passante de 2 km puis de 5 km, et les poids normalisés à somme unitaire. Commentez le rôle de la vente à 5 km dans chaque cas et reliez le choix de la bande passante à l'arbitrage biais--variance du \cref{ch:16} sur le nombre de comparables.
628 +\end{exercice}
629 +
630 +\begin{exercice}[3]{Krigeage ordinaire à trois voisins}{ex:19:6}
631 +Le résidu d'un modèle hédonique est connu pour trois ventes situées, dans un repère en kilomètres centré sur le sujet, en $(1, 0)$, $(0, 2)$ et $(-3, 0)$, avec les valeurs $0{,}08$ ; $0{,}05$ et $-0{,}01$. Le variogramme exponentiel ajusté a une pépite $c_0 = 0{,}0045$, un palier partiel $c_1 = 0{,}009$ et un paramètre de portée $a = 5$ km. Écrivez le système de krigeage ordinaire de l'\cref{eq:19:krigeage}, résolvez-le, puis donnez le résidu krigé au sujet et la variance de krigeage. Comparez le résultat à la moyenne simple et à la moyenne pondérée par l'inverse de la distance, et expliquez pourquoi le krigeage donne au point le plus éloigné un poids supérieur à celui que lui accorde l'inverse de la distance, et au point le plus proche un poids inférieur.
632 +\end{exercice}
633 +
634 +\begin{exercice}[3]{Sensibilité à la matrice et prédiction pour le sujet}{ex:19:7}
635 +À l'aide de \texttt{analysis/python/cases/ch19\_spatial.py} et des fonctions de \texttt{traite.hedonique}, estimez le SEM sans indicatrices de secteur avec les matrices de 4, 8 et 16 plus proches voisins, puis calculez pour chacune l'espérance conditionnelle du résidu au sujet et l'indication de valeur qui en résulte. Comparez la dispersion de ces trois indications à celle des paramètres $\hat\lambda$ du \cref{tab:19:sensibilite}. Remplacez ensuite la fenêtre de douze mois de la matrice spatio-temporelle par vingt-quatre mois et commentez l'effet sur le nombre de ventes sans voisine antérieure et sur $\hat\rho_{\text{st}}$. Rédigez une note de 200 mots sur ce que ces résultats impliquent pour la documentation d'un ajustement de localisation dérivé d'un modèle spatial.
636 +\end{exercice}
637 +
638 +\section*{Pour aller plus loin}
639 +\addcontentsline{toc}{section}{Pour aller plus loin}
640 +
641 +L'ouvrage fondateur de \textcite{anselin1988} reste la référence pour les tests et les modèles autorégressifs ; \textcite{lesagePace2009} en donnent une présentation moderne centrée sur l'interprétation des effets et les méthodes de calcul, et \textcite{elhorst2014} étendent le cadre aux données de panel, que le \cref{ch:36} mobilise. Pour les microdonnées immobilières, \textcite{dubeLegros2014} est le manuel le plus directement utile, avec sa discussion des matrices spatio-temporelles introduites dans \textcite{dubeLegros2013} et prolongées en estimateurs de différences de différences spatio-temporelles par \textcite{dubeLegrosTheriaultDesrosiers2014,dubeLegrosTheriaultDesrosiers2017}. Le lecteur qui aborde la géostatistique lira d'abord le primer de \textcite{dubin1998}, puis \textcite{dubin1988}, \textcite{basuThibodeau1998} et \textcite{gillenThibodeauWachter2001} sur la structure de l'autocorrélation des prix de maisons, et \textcite{paceBarrySirmans1998} pour les questions de calcul à grande échelle ; \textcite{paceBarryClappRodriguez1998} proposent le modèle autorégressif spatio-temporel qui a inspiré la pondération des ventes antérieures. La régression géographiquement pondérée est exposée dans \textcite{fotheringhamBrunsdonCharlton2002}, et ses applications québécoises dans \textcite{theriaultDesrosiersVilleneuveKestens2003} et \textcite{kestensTheriaultDesrosiers2006}. \textcite{bourassaCantoniHoesli2010} comparent, en termes de précision de prédiction, les principales approches de ce chapitre, et \textcite{goodmanThibodeau2003} ainsi que \textcite{bourassaHoesliPeng2003} montrent ce que les sous-marchés apportent à cette précision. Les modèles spatio-temporels bayésiens de \textcite{gelfandGhoshKnightSirmans1998} et \textcite{banerjeeGelfandKnightSirmans2004} ouvrent sur le \cref{ch:39}, et les estimateurs par moments de \textcite{kelejianPrucha1998,kelejianPrucha2010} sur les situations où la vraisemblance normale n'est pas tenable.
added references/lib-ch11.bib +123 −0
@@ -0,0 +1,123 @@
1 +% Nouvelles références du chapitre 11 — à fusionner dans library.bib par l'orchestrateur.
2 +% Non citées dans ch11.tex (règle de collision : le chapitre ne cite que des clés déjà dans library.bib) ;
3 +% les sources sont mentionnées en toutes lettres et en note de bas de page avec URL et date de consultation.
4 +% Toutes les entrées ont été ouvertes et lues le 31 octobre 2026 (registre : verification/chapters/ch11-registry.csv).
5 +
6 +@manual{aibqNorme2011,
7 + author = {{Association des inspecteurs en bâtiments du Québec}},
8 + title = {Norme de pratique professionnelle pour l'inspection visuelle de bâtiments principalement résidentiels},
9 + organization = {Association des inspecteurs en bâtiments du Québec, en collaboration avec l'Organisme d'autoréglementation du courtage immobilier du Québec},
10 + year = {2011},
11 + note = {Version V03, avril 2011 (entrée en vigueur janvier 2010), fichier mis à jour le 23 mars 2023 (art.~12.10) ; 24~p. ; art.~7--8 (inspection visuelle, non techniquement exhaustive), 11--12 (limites et exclusions, dont la valeur marchande), 14 (rapport), 16 (déclarations du vendeur)},
12 + url = {https://www.aibq.qc.ca/media/2220/aibq_normepreachat_maj-23-mars-2023-art1210.pdf},
13 + urldate = {2026-10-31}
14 +}
15 +
16 +@online{internachiSOP2022,
17 + author = {{International Association of Certified Home Inspectors}},
18 + title = {Home Inspection Standards of Practice},
19 + year = {2022},
20 + note = {Last revised October 2022 ; art.~2.1.5 (ne détermine pas la valeur marchande), 2.1.8 (ni l'espérance de vie), 3.1--3.10 (systèmes)},
21 + url = {https://www.nachi.org/sop.htm},
22 + urldate = {2026-10-31}
23 +}
24 +
25 +@online{scRadonLignes2025,
26 + author = {{Santé Canada}},
27 + title = {Lignes directrices sur le radon},
28 + year = {2025},
29 + note = {Page datée du 15 octobre 2025 ; ligne directrice de 200~Bq/m\textsuperscript{3} ; mesures correctives d'ici un an},
30 + url = {https://www.canada.ca/fr/sante-canada/services/securite-et-risque-pour-sante/radiation/radon/lignes-directrices-gouvernement-canada.html},
31 + urldate = {2026-10-31}
32 +}
33 +
34 +@manual{scRadonMesure2026,
35 + author = {{Santé Canada}},
36 + title = {Guide pour la mesure du radon dans les maisons},
37 + organization = {Santé Canada},
38 + year = {2026},
39 + note = {Page datée du 22 mai 2026 ; mesure à long terme d'au moins 91 jours en saison de chauffage, au niveau le plus bas normalement occupé},
40 + url = {https://www.canada.ca/fr/sante-canada/services/publications/securite-et-risque-pour-sante/guide-mesures-radon-maisons.html},
41 + urldate = {2026-10-31}
42 +}
43 +
44 +@online{qcRadonDomiciliaire2026,
45 + author = {{Gouvernement du Québec}},
46 + title = {Radon domiciliaire},
47 + year = {2026},
48 + note = {Dernière mise à jour : 20 août 2026 ; concentration moyenne dans les sous-sols d'environ 37~Bq/m\textsuperscript{3} ; dosimètre passif (3 mois, octobre à avril, environ 50~\$)},
49 + url = {https://www.quebec.ca/habitation-territoire/milieu-de-vie-sain/radon-domiciliaire},
50 + urldate = {2026-10-31}
51 +}
52 +
53 +@online{scMoisissure2023,
54 + author = {{Santé Canada}},
55 + title = {Moisissure},
56 + year = {2023},
57 + note = {Page datée du 13 février 2023 ; humidité relative de 30 à 50~\% ; catégories de surface petite / moyenne / grande (seuils de 1 et 3~m\textsuperscript{2})},
58 + url = {https://www.canada.ca/fr/sante-canada/services/qualite-air/contaminants-air-interieur/reduisez-humidite-et-moisissures.html},
59 + urldate = {2026-10-31}
60 +}
61 +
62 +@online{scAmiante2023,
63 + author = {{Santé Canada}},
64 + title = {L'amiante et votre santé},
65 + year = {2023},
66 + note = {Page datée du 10 février 2023 ; usage avant 1990 ; interdiction (règlement de 2018) ; matériaux de construction anciens pouvant contenir de l'amiante},
67 + url = {https://www.canada.ca/fr/sante-canada/services/qualite-air/contaminants-air-interieur/risques-pour-sante-associes-amiante.html},
68 + urldate = {2026-10-31}
69 +}
70 +
71 +@online{rncanRenovations2025,
72 + author = {{Ressources naturelles Canada}},
73 + title = {Rénovations écoénergétiques --- Mesures de santé et de sécurité relatives aux améliorations écoénergétiques},
74 + year = {2025},
75 + note = {Page modifiée le 16 janvier 2025 ; MIUF interdite au Canada depuis décembre 1980 (Loi sur les produits dangereux) ; isolant de vermiculite pouvant contenir de l'amiante ; voir aussi \emph{Emprisonnons la chaleur}, section~1.4 (mine de Libby, 1920--1990, Zonolite)},
76 + url = {https://ressources-naturelles.canada.ca/efficacite-energetique/efficacite-energetique-maison/renovations-ecoenergetiques},
77 + urldate = {2026-10-31}
78 +}
79 +
80 +@online{rbqOcre,
81 + author = {{Régie du bâtiment du Québec}},
82 + title = {Dépôt d'ocre},
83 + year = {2026},
84 + note = {Page consultée le 31 octobre 2026 (aucune date de mise à jour affichée) ; mécanismes chimique et biologique de l'ocre ferreuse, sols à risque, cheminées de nettoyage},
85 + url = {https://www.rbq.gouv.qc.ca/vous-etes/citoyen/problemes-avec-votre-residence/depot-docre},
86 + urldate = {2026-10-31}
87 +}
88 +
89 +@online{rbqPyritePyrrhotite,
90 + author = {{Régie du bâtiment du Québec}},
91 + title = {Pyrite et pyrrhotite},
92 + year = {2026},
93 + note = {Page consultée le 31 octobre 2026 (aucune date de mise à jour affichée) ; mécanismes, garantie de cinq ans, recours},
94 + url = {https://www.rbq.gouv.qc.ca/vous-etes/citoyen/problemes-particuliers-en-batiment/pyrite-et-pyrrhotite/},
95 + urldate = {2026-10-31}
96 +}
97 +
98 +@online{rbqSolsArgileux,
99 + author = {{Régie du bâtiment du Québec}},
100 + title = {Sols argileux},
101 + year = {2026},
102 + note = {Texte publié en avril 2006 (Info-RBQ) et mis à jour ; consulté le 31 octobre 2026 ; vallée du Saint-Laurent, vallée de l'Outaouais, Saguenay--Lac-Saint-Jean},
103 + url = {https://www.rbq.gouv.qc.ca/vous-etes/citoyen/problemes-particuliers-en-batiment/sols-argileux/},
104 + urldate = {2026-10-31}
105 +}
106 +
107 +@online{rbqEquipementsPetroliers,
108 + author = {{Régie du bâtiment du Québec}},
109 + title = {Équipements pétroliers --- Équipements pétroliers visés ; Lois et règlements},
110 + year = {2026},
111 + note = {Pages consultées le 31 octobre 2026 ; chapitre VIII du Code de construction ; équipements de moins de 10~000~L d'une maison unifamiliale non considérés à risque élevé},
112 + url = {https://www.rbq.gouv.qc.ca/domaines-dintervention/equipements-petroliers/},
113 + urldate = {2026-10-31}
114 +}
115 +
116 +@online{qcPyrrhotiteVoletI2026,
117 + author = {{Gouvernement du Québec}},
118 + title = {Programme pour les résidences endommagées par la pyrrhotite --- Volet I : aide financière pour la réalisation de travaux},
119 + year = {2026},
120 + note = {Dernière mise à jour : 8 octobre 2026 ; programme terminé le 30 septembre 2026 ; aide de 75~\% des coûts admissibles jusqu'à 75~000~\$ ; taux minimal de 0,23~\% de pyrrhotite},
121 + url = {https://www.quebec.ca/habitation-territoire/construction-renovation/renovation-residentielle/programme-residences-endommagees-pyrrhotite/programme-pyrrhotite-aide-financiere-realisation-travaux},
122 + urldate = {2026-10-31}
123 +}
added references/lib-ch12.bib +50 −0
@@ -0,0 +1,50 @@
1 +% Nouvelles références du chapitre 12 — à fusionner dans library.bib par l'orchestrateur.
2 +% Non citées dans ch12.tex (pour éviter une citation non définie avant fusion) ; le chapitre les mentionne en toutes lettres
3 +% (encadré « Le certificat de localisation et la contenance du lot », § Normes de mesurage).
4 +
5 +@legislation{loiArpenteurs,
6 + author = {{Québec}},
7 + title = {Loi sur les arpenteurs-géomètres},
8 + year = {1973},
9 + note = {RLRQ, c. A-23 ; à jour au 15 octobre 2026 ; art.~34 : exercice de la profession (certificats de localisation, calculs de superficies, méthode photogrammétrique)},
10 + url = {https://www.legisquebec.gouv.qc.ca/fr/document/lc/A-23},
11 + urldate = {2026-10-31}
12 +}
13 +
14 +@legislation{reglCertificatLocalisation,
15 + author = {{Québec}},
16 + title = {Règlement sur la norme de pratique relative au certificat de localisation},
17 + year = {2002},
18 + note = {RLRQ, c. A-23, r. 10 (D. 1058-2002) ; à jour au 1\textsuperscript{er} juillet 2026 ; art.~2 (définition), 3 (système international d'unités), 7 (visite des lieux), 9 (contenu du rapport)},
19 + url = {https://www.legisquebec.gouv.qc.ca/fr/document/rc/A-23,%20r.%2010},
20 + urldate = {2026-10-31}
21 +}
22 +
23 +@legislation{reglPiquetage,
24 + author = {{Québec}},
25 + title = {Règlement sur la norme de pratique relative au piquetage et à l'implantation},
26 + year = {2002},
27 + note = {RLRQ, c. A-23, r. 11 (D. 1057-2002) ; à jour au 1\textsuperscript{er} juillet 2026},
28 + url = {https://www.legisquebec.gouv.qc.ca/fr/document/rc/A-23,%20r.%2011},
29 + urldate = {2026-10-31}
30 +}
31 +
32 +@online{oagqCertificat,
33 + author = {{Ordre des arpenteurs-géomètres du Québec}},
34 + title = {Certificat de localisation},
35 + year = {2026},
36 + note = {Page consultée le 31 octobre 2026 ; rapport et plan ; un créancier ou un notaire peut exiger un certificat à jour lors de la vente ; le vendeur paie habituellement},
37 + url = {https://oagq.qc.ca/services/certificat-de-localisation},
38 + urldate = {2026-10-31}
39 +}
40 +
41 +@manual{ansiZ765,
42 + author = {{Home Innovation Research Labs}},
43 + title = {Square Footage --- Method for Calculating: ANSI Z765-2021 (American National Standard for Single-Family Residential Buildings)},
44 + organization = {American National Standards Institute / Home Innovation Research Labs},
45 + address = {Upper Marlboro (MD)},
46 + year = {2021},
47 + note = {Approuvée le 29 mars 2021 ; norme volontaire ; texte consulté dans une copie diffusée par un service d'inscriptions immobilières (statut : partiellement vérifié ; original non acquis auprès de l'éditeur)},
48 + url = {https://www.mlsnow.com/pdf/ANSIsqft.pdf},
49 + urldate = {2026-10-31}
50 +}
added references/lib-ch14.bib +114 −0
@@ -0,0 +1,114 @@
1 +% Nouvelles références du chapitre 14 — à fusionner dans library.bib par l'orchestrateur.
2 +% Aucune n'est citée dans ch14.tex (pour éviter une citation non définie avant fusion) ; le texte nomme les auteurs en toutes lettres.
3 +% Vérification : DOI résolus dans l'API Crossref (api.crossref.org/works/<doi>) le 2026-10-31 ; fiches Données Québec lues par l'API CKAN le 2026-10-31.
4 +
5 +@article{cook1977,
6 + author = {Cook, R. Dennis},
7 + title = {Detection of Influential Observation in Linear Regression},
8 + journaltitle = {Technometrics},
9 + year = {1977},
10 + volume = {19},
11 + number = {1},
12 + pages = {15--18},
13 + doi = {10.1080/00401706.1977.10489493},
14 + urldate = {2026-10-31},
15 + note = {Distance de Cook ; vérifié Crossref 2026-10-31}
16 +}
17 +
18 +@article{cook1979,
19 + author = {Cook, R. Dennis},
20 + title = {Influential Observations in Linear Regression},
21 + journaltitle = {Journal of the American Statistical Association},
22 + year = {1979},
23 + volume = {74},
24 + number = {365},
25 + pages = {169--174},
26 + doi = {10.1080/01621459.1979.10481634},
27 + urldate = {2026-10-31},
28 + note = {Vérifié Crossref 2026-10-31}
29 +}
30 +
31 +@article{hampel1974,
32 + author = {Hampel, Frank R.},
33 + title = {The Influence Curve and its Role in Robust Estimation},
34 + journaltitle = {Journal of the American Statistical Association},
35 + year = {1974},
36 + volume = {69},
37 + number = {346},
38 + pages = {383--393},
39 + doi = {10.1080/01621459.1974.10482962},
40 + urldate = {2026-10-31},
41 + note = {Fonction d'influence, point de rupture ; vérifié Crossref 2026-10-31}
42 +}
43 +
44 +@article{rousseeuwCroux1993,
45 + author = {Rousseeuw, Peter J. and Croux, Christophe},
46 + title = {Alternatives to the Median Absolute Deviation},
47 + journaltitle = {Journal of the American Statistical Association},
48 + year = {1993},
49 + volume = {88},
50 + number = {424},
51 + pages = {1273--1283},
52 + doi = {10.1080/01621459.1993.10476408},
53 + urldate = {2026-10-31},
54 + note = {Constante de cohérence 1,4826 du MAD, estimateurs Sn et Qn ; vérifié Crossref 2026-10-31}
55 +}
56 +
57 +@article{leysLeyKleinBernardLicata2013,
58 + author = {Leys, Christophe and Ley, Christophe and Klein, Olivier and Bernard, Philippe and Licata, Laurent},
59 + title = {Detecting Outliers: Do Not Use Standard Deviation around the Mean, Use Absolute Deviation around the Median},
60 + journaltitle = {Journal of Experimental Social Psychology},
61 + year = {2013},
62 + volume = {49},
63 + number = {4},
64 + pages = {764--766},
65 + doi = {10.1016/j.jesp.2013.03.013},
66 + urldate = {2026-10-31},
67 + note = {Seuils usuels 2,5 / 3 / 3,5 du score robuste ; vérifié Crossref 2026-10-31}
68 +}
69 +
70 +@article{fellegiSunter1969,
71 + author = {Fellegi, Ivan P. and Sunter, Alan B.},
72 + title = {A Theory for Record Linkage},
73 + journaltitle = {Journal of the American Statistical Association},
74 + year = {1969},
75 + volume = {64},
76 + number = {328},
77 + pages = {1183--1210},
78 + doi = {10.1080/01621459.1969.10501049},
79 + urldate = {2026-10-31},
80 + note = {Appariement probabiliste d'enregistrements (poids de concordance m/u) ; vérifié Crossref 2026-10-31}
81 +}
82 +
83 +@article{rubin1976,
84 + author = {Rubin, Donald B.},
85 + title = {Inference and Missing Data},
86 + journaltitle = {Biometrika},
87 + year = {1976},
88 + volume = {63},
89 + number = {3},
90 + pages = {581--592},
91 + doi = {10.1093/biomet/63.3.581},
92 + urldate = {2026-10-31},
93 + note = {Mécanismes MCAR / MAR / MNAR ; vérifié Crossref 2026-10-31}
94 +}
95 +
96 +@online{mrnfAdressesQuebec2026,
97 + author = {{Ministère des Ressources naturelles et des Forêts}},
98 + title = {Adresses Québec},
99 + organization = {Données Québec},
100 + year = {2026},
101 + url = {https://www.donneesquebec.ca/recherche/dataset/adresses-quebec},
102 + urldate = {2026-10-31},
103 + note = {Géobase routière couvrant tout le Québec (odonymes officiels, adresses par tranches et par points, codes postaux, service Web d'adresses AQgéobâti, service REST de géocodage) ; licence CC BY 4.0 ; métadonnées modifiées le 2026-09-29 ; la gamme est en transition vers le Référentiel québécois des adresses et le Référentiel québécois du transport terrestre jusqu'à la fin de 2026 (fiche lue par l'API CKAN le 2026-10-31)}
104 +}
105 +
106 +@online{mrnfRQA2026,
107 + author = {{Ministère des Ressources naturelles et des Forêts}},
108 + title = {Référentiel québécois des adresses},
109 + organization = {Données Québec},
110 + year = {2026},
111 + url = {https://www.donneesquebec.ca/recherche/dataset/referentiel-quebecois-des-adresses},
112 + urldate = {2026-10-31},
113 + note = {Ensemble des adresses municipales du Québec avec numéro, suffixe, unité, odonyme, municipalité, code postal, usage principal, dates et coordonnées ; mise à jour mensuelle ; licence CC BY 4.0 ; métadonnées modifiées le 2026-10-15 ; produit en collaboration avec Élections Québec, le MAMH et le MTMD (fiche lue par l'API CKAN le 2026-10-31)}
114 +}
modified references/library.bib +268 −13
@@ -4676,6 +4676,274 @@
4676 4676 urldate = {2026-10-31}
4677 4677 }
4678 4678
4679 +@manual{aibqNorme2011,
4680 + author = {{Association des inspecteurs en bâtiments du Québec}},
4681 + title = {Norme de pratique professionnelle pour l'inspection visuelle de bâtiments principalement résidentiels},
4682 + organization = {Association des inspecteurs en bâtiments du Québec, en collaboration avec l'Organisme d'autoréglementation du courtage immobilier du Québec},
4683 + year = {2011},
4684 + note = {Version V03, avril 2011 (entrée en vigueur janvier 2010), fichier mis à jour le 23 mars 2023 (art.~12.10) ; 24~p. ; art.~7--8 (inspection visuelle, non techniquement exhaustive), 11--12 (limites et exclusions, dont la valeur marchande), 14 (rapport), 16 (déclarations du vendeur)},
4685 + url = {https://www.aibq.qc.ca/media/2220/aibq_normepreachat_maj-23-mars-2023-art1210.pdf},
4686 + urldate = {2026-10-31}
4687 +}
4688 +
4689 +@online{internachiSOP2022,
4690 + author = {{International Association of Certified Home Inspectors}},
4691 + title = {Home Inspection Standards of Practice},
4692 + year = {2022},
4693 + note = {Last revised October 2022 ; art.~2.1.5 (ne détermine pas la valeur marchande), 2.1.8 (ni l'espérance de vie), 3.1--3.10 (systèmes)},
4694 + url = {https://www.nachi.org/sop.htm},
4695 + urldate = {2026-10-31}
4696 +}
4697 +
4698 +@online{scRadonLignes2025,
4699 + author = {{Santé Canada}},
4700 + title = {Lignes directrices sur le radon},
4701 + year = {2025},
4702 + note = {Page datée du 15 octobre 2025 ; ligne directrice de 200~Bq/m\textsuperscript{3} ; mesures correctives d'ici un an},
4703 + url = {https://www.canada.ca/fr/sante-canada/services/securite-et-risque-pour-sante/radiation/radon/lignes-directrices-gouvernement-canada.html},
4704 + urldate = {2026-10-31}
4705 +}
4706 +
4707 +@manual{scRadonMesure2026,
4708 + author = {{Santé Canada}},
4709 + title = {Guide pour la mesure du radon dans les maisons},
4710 + organization = {Santé Canada},
4711 + year = {2026},
4712 + note = {Page datée du 22 mai 2026 ; mesure à long terme d'au moins 91 jours en saison de chauffage, au niveau le plus bas normalement occupé},
4713 + url = {https://www.canada.ca/fr/sante-canada/services/publications/securite-et-risque-pour-sante/guide-mesures-radon-maisons.html},
4714 + urldate = {2026-10-31}
4715 +}
4716 +
4717 +@online{qcRadonDomiciliaire2026,
4718 + author = {{Gouvernement du Québec}},
4719 + title = {Radon domiciliaire},
4720 + year = {2026},
4721 + note = {Dernière mise à jour : 20 août 2026 ; concentration moyenne dans les sous-sols d'environ 37~Bq/m\textsuperscript{3} ; dosimètre passif (3 mois, octobre à avril, environ 50~\$)},
4722 + url = {https://www.quebec.ca/habitation-territoire/milieu-de-vie-sain/radon-domiciliaire},
4723 + urldate = {2026-10-31}
4724 +}
4725 +
4726 +@online{scMoisissure2023,
4727 + author = {{Santé Canada}},
4728 + title = {Moisissure},
4729 + year = {2023},
4730 + note = {Page datée du 13 février 2023 ; humidité relative de 30 à 50~\% ; catégories de surface petite / moyenne / grande (seuils de 1 et 3~m\textsuperscript{2})},
4731 + url = {https://www.canada.ca/fr/sante-canada/services/qualite-air/contaminants-air-interieur/reduisez-humidite-et-moisissures.html},
4732 + urldate = {2026-10-31}
4733 +}
4734 +
4735 +@online{scAmiante2023,
4736 + author = {{Santé Canada}},
4737 + title = {L'amiante et votre santé},
4738 + year = {2023},
4739 + note = {Page datée du 10 février 2023 ; usage avant 1990 ; interdiction (règlement de 2018) ; matériaux de construction anciens pouvant contenir de l'amiante},
4740 + url = {https://www.canada.ca/fr/sante-canada/services/qualite-air/contaminants-air-interieur/risques-pour-sante-associes-amiante.html},
4741 + urldate = {2026-10-31}
4742 +}
4743 +
4744 +@online{rncanRenovations2025,
4745 + author = {{Ressources naturelles Canada}},
4746 + title = {Rénovations écoénergétiques --- Mesures de santé et de sécurité relatives aux améliorations écoénergétiques},
4747 + year = {2025},
4748 + note = {Page modifiée le 16 janvier 2025 ; MIUF interdite au Canada depuis décembre 1980 (Loi sur les produits dangereux) ; isolant de vermiculite pouvant contenir de l'amiante ; voir aussi \emph{Emprisonnons la chaleur}, section~1.4 (mine de Libby, 1920--1990, Zonolite)},
4749 + url = {https://ressources-naturelles.canada.ca/efficacite-energetique/efficacite-energetique-maison/renovations-ecoenergetiques},
4750 + urldate = {2026-10-31}
4751 +}
4752 +
4753 +@online{rbqOcre,
4754 + author = {{Régie du bâtiment du Québec}},
4755 + title = {Dépôt d'ocre},
4756 + year = {2026},
4757 + note = {Page consultée le 31 octobre 2026 (aucune date de mise à jour affichée) ; mécanismes chimique et biologique de l'ocre ferreuse, sols à risque, cheminées de nettoyage},
4758 + url = {https://www.rbq.gouv.qc.ca/vous-etes/citoyen/problemes-avec-votre-residence/depot-docre},
4759 + urldate = {2026-10-31}
4760 +}
4761 +
4762 +@online{rbqPyritePyrrhotite,
4763 + author = {{Régie du bâtiment du Québec}},
4764 + title = {Pyrite et pyrrhotite},
4765 + year = {2026},
4766 + note = {Page consultée le 31 octobre 2026 (aucune date de mise à jour affichée) ; mécanismes, garantie de cinq ans, recours},
4767 + url = {https://www.rbq.gouv.qc.ca/vous-etes/citoyen/problemes-particuliers-en-batiment/pyrite-et-pyrrhotite/},
4768 + urldate = {2026-10-31}
4769 +}
4770 +
4771 +@online{rbqSolsArgileux,
4772 + author = {{Régie du bâtiment du Québec}},
4773 + title = {Sols argileux},
4774 + year = {2026},
4775 + note = {Texte publié en avril 2006 (Info-RBQ) et mis à jour ; consulté le 31 octobre 2026 ; vallée du Saint-Laurent, vallée de l'Outaouais, Saguenay--Lac-Saint-Jean},
4776 + url = {https://www.rbq.gouv.qc.ca/vous-etes/citoyen/problemes-particuliers-en-batiment/sols-argileux/},
4777 + urldate = {2026-10-31}
4778 +}
4779 +
4780 +@online{rbqEquipementsPetroliers,
4781 + author = {{Régie du bâtiment du Québec}},
4782 + title = {Équipements pétroliers --- Équipements pétroliers visés ; Lois et règlements},
4783 + year = {2026},
4784 + note = {Pages consultées le 31 octobre 2026 ; chapitre VIII du Code de construction ; équipements de moins de 10~000~L d'une maison unifamiliale non considérés à risque élevé},
4785 + url = {https://www.rbq.gouv.qc.ca/domaines-dintervention/equipements-petroliers/},
4786 + urldate = {2026-10-31}
4787 +}
4788 +
4789 +@online{qcPyrrhotiteVoletI2026,
4790 + author = {{Gouvernement du Québec}},
4791 + title = {Programme pour les résidences endommagées par la pyrrhotite --- Volet I : aide financière pour la réalisation de travaux},
4792 + year = {2026},
4793 + note = {Dernière mise à jour : 8 octobre 2026 ; programme terminé le 30 septembre 2026 ; aide de 75~\% des coûts admissibles jusqu'à 75~000~\$ ; taux minimal de 0,23~\% de pyrrhotite},
4794 + url = {https://www.quebec.ca/habitation-territoire/construction-renovation/renovation-residentielle/programme-residences-endommagees-pyrrhotite/programme-pyrrhotite-aide-financiere-realisation-travaux},
4795 + urldate = {2026-10-31}
4796 +}
4797 +
4798 +@legislation{loiArpenteurs,
4799 + author = {{Québec}},
4800 + title = {Loi sur les arpenteurs-géomètres},
4801 + year = {1973},
4802 + note = {RLRQ, c. A-23 ; à jour au 15 octobre 2026 ; art.~34 : exercice de la profession (certificats de localisation, calculs de superficies, méthode photogrammétrique)},
4803 + url = {https://www.legisquebec.gouv.qc.ca/fr/document/lc/A-23},
4804 + urldate = {2026-10-31}
4805 +}
4806 +
4807 +@legislation{reglPiquetage,
4808 + author = {{Québec}},
4809 + title = {Règlement sur la norme de pratique relative au piquetage et à l'implantation},
4810 + year = {2002},
4811 + note = {RLRQ, c. A-23, r. 11 (D. 1057-2002) ; à jour au 1\textsuperscript{er} juillet 2026},
4812 + url = {https://www.legisquebec.gouv.qc.ca/fr/document/rc/A-23,%20r.%2011},
4813 + urldate = {2026-10-31}
4814 +}
4815 +
4816 +@online{oagqCertificat,
4817 + author = {{Ordre des arpenteurs-géomètres du Québec}},
4818 + title = {Certificat de localisation},
4819 + year = {2026},
4820 + note = {Page consultée le 31 octobre 2026 ; rapport et plan ; un créancier ou un notaire peut exiger un certificat à jour lors de la vente ; le vendeur paie habituellement},
4821 + url = {https://oagq.qc.ca/services/certificat-de-localisation},
4822 + urldate = {2026-10-31}
4823 +}
4824 +
4825 +@manual{ansiZ765,
4826 + author = {{Home Innovation Research Labs}},
4827 + title = {Square Footage --- Method for Calculating: ANSI Z765-2021 (American National Standard for Single-Family Residential Buildings)},
4828 + organization = {American National Standards Institute / Home Innovation Research Labs},
4829 + address = {Upper Marlboro (MD)},
4830 + year = {2021},
4831 + note = {Approuvée le 29 mars 2021 ; norme volontaire ; texte consulté dans une copie diffusée par un service d'inscriptions immobilières (statut : partiellement vérifié ; original non acquis auprès de l'éditeur)},
4832 + url = {https://www.mlsnow.com/pdf/ANSIsqft.pdf},
4833 + urldate = {2026-10-31}
4834 +}
4835 +
4836 +@article{cook1977,
4837 + author = {Cook, R. Dennis},
4838 + title = {Detection of Influential Observation in Linear Regression},
4839 + journaltitle = {Technometrics},
4840 + year = {1977},
4841 + volume = {19},
4842 + number = {1},
4843 + pages = {15--18},
4844 + doi = {10.1080/00401706.1977.10489493},
4845 + urldate = {2026-10-31},
4846 + note = {Distance de Cook ; vérifié Crossref 2026-10-31}
4847 +}
4848 +
4849 +@article{cook1979,
4850 + author = {Cook, R. Dennis},
4851 + title = {Influential Observations in Linear Regression},
4852 + journaltitle = {Journal of the American Statistical Association},
4853 + year = {1979},
4854 + volume = {74},
4855 + number = {365},
4856 + pages = {169--174},
4857 + doi = {10.1080/01621459.1979.10481634},
4858 + urldate = {2026-10-31},
4859 + note = {Vérifié Crossref 2026-10-31}
4860 +}
4861 +
4862 +@article{hampel1974,
4863 + author = {Hampel, Frank R.},
4864 + title = {The Influence Curve and its Role in Robust Estimation},
4865 + journaltitle = {Journal of the American Statistical Association},
4866 + year = {1974},
4867 + volume = {69},
4868 + number = {346},
4869 + pages = {383--393},
4870 + doi = {10.1080/01621459.1974.10482962},
4871 + urldate = {2026-10-31},
4872 + note = {Fonction d'influence, point de rupture ; vérifié Crossref 2026-10-31}
4873 +}
4874 +
4875 +@article{rousseeuwCroux1993,
4876 + author = {Rousseeuw, Peter J. and Croux, Christophe},
4877 + title = {Alternatives to the Median Absolute Deviation},
4878 + journaltitle = {Journal of the American Statistical Association},
4879 + year = {1993},
4880 + volume = {88},
4881 + number = {424},
4882 + pages = {1273--1283},
4883 + doi = {10.1080/01621459.1993.10476408},
4884 + urldate = {2026-10-31},
4885 + note = {Constante de cohérence 1,4826 du MAD, estimateurs Sn et Qn ; vérifié Crossref 2026-10-31}
4886 +}
4887 +
4888 +@article{leysLeyKleinBernardLicata2013,
4889 + author = {Leys, Christophe and Ley, Christophe and Klein, Olivier and Bernard, Philippe and Licata, Laurent},
4890 + title = {Detecting Outliers: Do Not Use Standard Deviation around the Mean, Use Absolute Deviation around the Median},
4891 + journaltitle = {Journal of Experimental Social Psychology},
4892 + year = {2013},
4893 + volume = {49},
4894 + number = {4},
4895 + pages = {764--766},
4896 + doi = {10.1016/j.jesp.2013.03.013},
4897 + urldate = {2026-10-31},
4898 + note = {Seuils usuels 2,5 / 3 / 3,5 du score robuste ; vérifié Crossref 2026-10-31}
4899 +}
4900 +
4901 +@article{fellegiSunter1969,
4902 + author = {Fellegi, Ivan P. and Sunter, Alan B.},
4903 + title = {A Theory for Record Linkage},
4904 + journaltitle = {Journal of the American Statistical Association},
4905 + year = {1969},
4906 + volume = {64},
4907 + number = {328},
4908 + pages = {1183--1210},
4909 + doi = {10.1080/01621459.1969.10501049},
4910 + urldate = {2026-10-31},
4911 + note = {Appariement probabiliste d'enregistrements (poids de concordance m/u) ; vérifié Crossref 2026-10-31}
4912 +}
4913 +
4914 +@article{rubin1976,
4915 + author = {Rubin, Donald B.},
4916 + title = {Inference and Missing Data},
4917 + journaltitle = {Biometrika},
4918 + year = {1976},
4919 + volume = {63},
4920 + number = {3},
4921 + pages = {581--592},
4922 + doi = {10.1093/biomet/63.3.581},
4923 + urldate = {2026-10-31},
4924 + note = {Mécanismes MCAR / MAR / MNAR ; vérifié Crossref 2026-10-31}
4925 +}
4926 +
4927 +@online{mrnfAdressesQuebec2026,
4928 + author = {{Ministère des Ressources naturelles et des Forêts}},
4929 + title = {Adresses Québec},
4930 + organization = {Données Québec},
4931 + year = {2026},
4932 + url = {https://www.donneesquebec.ca/recherche/dataset/adresses-quebec},
4933 + urldate = {2026-10-31},
4934 + note = {Géobase routière couvrant tout le Québec (odonymes officiels, adresses par tranches et par points, codes postaux, service Web d'adresses AQgéobâti, service REST de géocodage) ; licence CC BY 4.0 ; métadonnées modifiées le 2026-09-29 ; la gamme est en transition vers le Référentiel québécois des adresses et le Référentiel québécois du transport terrestre jusqu'à la fin de 2026 (fiche lue par l'API CKAN le 2026-10-31)}
4935 +}
4936 +
4937 +@online{mrnfRQA2026,
4938 + author = {{Ministère des Ressources naturelles et des Forêts}},
4939 + title = {Référentiel québécois des adresses},
4940 + organization = {Données Québec},
4941 + year = {2026},
4942 + url = {https://www.donneesquebec.ca/recherche/dataset/referentiel-quebecois-des-adresses},
4943 + urldate = {2026-10-31},
4944 + note = {Ensemble des adresses municipales du Québec avec numéro, suffixe, unité, odonyme, municipalité, code postal, usage principal, dates et coordonnées ; mise à jour mensuelle ; licence CC BY 4.0 ; métadonnées modifiées le 2026-10-15 ; produit en collaboration avec Élections Québec, le MAMH et le MTMD (fiche lue par l'API CKAN le 2026-10-31)}
4945 +}
4946 +
4679 4947 @article{duan1983,
4680 4948 author = {Duan, Naihua},
4681 4949 title = {Smearing Estimate: A Nonparametric Retransformation Method},
@@ -4741,19 +5009,6 @@
4741 5009 note = {Test du multiplicateur de Lagrange d'hétéroscédasticité ; version studentisée de Koenker (1981) utilisée dans le script ; vérifié Crossref}
4742 5010 }
4743 5011
4744 −@article{cook1977,
4745 − author = {Cook, R. Dennis},
4746 − title = {Detection of Influential Observation in Linear Regression},
4747 − journaltitle = {Technometrics},
4748 − volume = {19},
4749 − number = {1},
4750 − pages = {15--18},
4751 − year = {1977},
4752 − doi = {10.1080/00401706.1977.10489493},
4753 − urldate = {2026-10-31},
4754 − note = {Distance de Cook ; vérifié Crossref}
4755 −}
4756 −
4757 5012 @book{belsleyKuhWelsch1980,
4758 5013 author = {Belsley, David A. and Kuh, Edwin and Welsch, Roy E.},
4759 5014 title = {Regression Diagnostics: Identifying Influential Data and Sources of Collinearity},
modified solutions/ch11.tex +61 −0
@@ -1 +1,62 @@
1 1 % Solutions du chapitre 11 — Inspection et pathologies du bâtiment
2 +% Chiffres recalculés par analysis/python/tests/test_ch11.py (classe TestExercices) et analysis/python/cases/ch11_inspection.py.
3 +
4 +\begin{solution}{ex:11:1}{Deux inspections, deux mandats}
5 +(a) \emph{Évaluation seulement.} La norme de l'AIBQ exclut expressément « l'évaluation de la valeur marchande de la propriété » (art.~12.3) ; c'est l'objet même du mandat de l'évaluateur. (b) \emph{Préachat.} L'art.~18 de la norme oblige l'inspecteur à sonder (« piquer ») les composantes structurales qui semblent détériorées, sauf risque de dommage ; l'évaluateur, qui n'intervient pas sur le bâtiment, se limite à l'observation et renvoie au spécialiste. (c) \emph{Évaluation.} L'art.~12.1 exclut du mandat de l'inspecteur l'évaluation de la durée de vie résiduelle ; pour l'évaluateur, la vie restante d'une composante est au contraire un intrant de la dépréciation ventilée (\cref{eq:11:courte}). (d) \emph{Les deux.} L'art.~16 de la norme impose à l'inspecteur de prendre connaissance des déclarations du vendeur ; l'évaluateur les lit également, parce qu'elles datent les composantes et révèlent les sinistres (règle 2.3 des NPP : étendue du travail et traitement de l'information). (e) \emph{Aucune.} La conformité aux codes est exclue par l'art.~12.10 de la norme et n'est pas un objet de l'évaluation ; une maison conforme au code de son époque n'est pas déficiente parce que le code a changé. (f) \emph{Les deux.} L'inspecteur doit recommander un examen techniquement exhaustif par un spécialiste lorsque des indices suffisants le justifient (art.~10) ; l'évaluateur recommande le test lorsque son coût est inférieur à la perte espérée (\cref{eq:11:test}) et formule l'hypothèse extraordinaire correspondante. (g) \emph{Les deux.} L'art.~14.6.2 de la norme exige la mention des systèmes non inspectés et des raisons ; la règle 2.3 des NPP exige la description de l'étendue du travail et des réserves.
6 +\end{solution}
7 +
8 +\begin{solution}{ex:11:2}{Lire les signes}
9 +(a) \emph{Pyrite dans le remblai sous la dalle} : les trois indices convergent (dalle de garage bombée où le remblai est le plus épais, fissures en étoile, période et secteur touchés, aucun certificat). Recommandation : analyse du remblai par un laboratoire selon un protocole reconnu, hypothèse extraordinaire en attendant. (b) \emph{Ocre ferreuse} : dépôt gélatineux rougeâtre, odeur de soufre, sol sablonneux sont les signes décrits par la RBQ. Recommandation : inspection du drain par caméra, vérification de la présence de cheminées de nettoyage et de l'historique d'entretien. (c) \emph{Isolant de vermiculite} pouvant contenir de l'amiante (maison de 1965, dans la période 1920-1990 de la vermiculite de Libby). Recommandation : ne pas déranger, analyse en laboratoire d'un échantillon prélevé par un professionnel, hypothèse extraordinaire. (d) \emph{Mouvement de la fondation} (poussée latérale du sol ou du gel sur un mur de blocs) et non une simple fissure de retrait : fissure horizontale continue avec déversement. Recommandation : expertise structurale ; la note de la structure ne peut être tranchée sans elle. (e) \emph{Mousse isolante d'urée-formaldéhyde} : maison de 1976, trous de remplissage bouchés dans les murs extérieurs, période d'installation antérieure à l'interdiction de décembre 1980. Recommandation : vérifier la déclaration du vendeur, prélever un échantillon pour analyse si des travaux sont projetés, mentionner la présence probable. (f) \emph{Réservoir de mazout souterrain}, en service ou abandonné : les conduits de remplissage et d'évent sans réservoir visible en sont le signe. Recommandation : vérification de l'existence et de l'état du réservoir, expertise environnementale du sol au besoin (\cref{ch:43}), hypothèse extraordinaire quant à l'absence de contamination.
10 +\end{solution}
11 +
12 +\begin{solution}{ex:11:3}{Âges effectifs}
13 +Avec $\lambda = 0{,}5$ et $\varphi(2) = 0{,}25$, $\varphi(4) = 0{,}75$ (\cref{tab:11:echelle}), l'\cref{eq:11:aeff} donne :
14 +
15 +\emph{Toiture} : $a_{\mathrm{eff}} = 0{,}5 \times 12 + 0{,}5 \times 0{,}75 \times 20 = 6 + 7{,}5 = 13{,}5$ ans ; fraction consommée $13{,}5/20 = 0{,}675$ ; vie restante $6{,}5$ ans. La note 4 vieillit la toiture de 1,5 an par rapport à son âge.
16 +
17 +\emph{Chauffe-eau} : $a_{\mathrm{eff}} = 0{,}5 \times 9 + 0{,}5 \times 0{,}25 \times 12 = 4{,}5 + 1{,}5 = 6$ ans ; fraction $0{,}5$ ; vie restante 6 ans. Le bon état rajeunit l'appareil de 3 ans.
18 +
19 +\emph{Fenêtres} : $a_{\mathrm{eff}} = 0{,}5 \times 35 + 0{,}5 \times 0{,}25 \times 30 = 17{,}5 + 3{,}75 = 21{,}25$ ans, valeur inférieure à $L = 30$, donc non bornée ; fraction $0{,}708$ ; vie restante $8{,}75$ ans. C'est le cas qui justifie la convention : par l'âge chronologique seul, les fenêtres, à 35 ans pour une vie de 30, seraient déclarées entièrement dépréciées (fraction bornée à 1), alors qu'elles sont en bon état ; la note 2 ramène leur âge effectif à 21,25 ans et leur laisse une vie restante. Inversement, si la note avait été 4, l'âge effectif aurait été $17{,}5 + 11{,}25 = 28{,}75$ ans, proche de la fin de vie.
20 +
21 +\emph{Structure} : $a_{\mathrm{eff}} = 0{,}5 \times 45 + 0{,}5 \times 0{,}25 \times 60 = 22{,}5 + 7{,}5 = 30$ ans ; fraction $0{,}5$ ; vie économique restante $60 - 30 = 30$ ans. Une structure de 45 ans en bon état est traitée comme une structure typique de 30 ans.
22 +\end{solution}
23 +
24 +\begin{solution}{ex:11:4}{Dépréciation ventilée}
25 +\emph{Méthode ventilée.} Curable : $D_{\mathrm{cur}} = \dollars{4500}$. Courte durée (\cref{eq:11:courte}), avec les âges effectifs de l'\cref{ex:11:3} : toiture $\dollars{12000} \times 13{,}5/20 = \dollars{8100}$ ; chauffe-eau $\dollars{1500} \times 6/12 = \dollars{750}$ ; fenêtres $\dollars{24000} \times 21{,}25/30 = \dollars{17000}$ ; total $D_{\mathrm{court}} = \dollars{25850}$. Base de longue durée (\cref{eq:11:longue}) : $\dollars{300000} - \dollars{4500} - (\dollars{12000} + \dollars{1500} + \dollars{24000}) = \dollars{258000}$ ; $D_{\mathrm{long}} = \dollars{258000} \times 30/60 = \dollars{129000}$. Dépréciation physique totale : $\dollars{4500} + \dollars{25850} + \dollars{129000} = \dollars{159350}$, soit $53{,}12$ \% du coût neuf ; contribution du bâtiment avant désuétudes : \dollars{140650}.
26 +
27 +\emph{Âge-vie simple.} $\dollars{300000} \times 30/60 = \dollars{150000}$, soit 50 \%.
28 +
29 +\emph{Écart.} La méthode ventilée donne \dollars{9350} de plus. Les composantes de courte durée (\dollars{37500} de coût neuf) sont dépréciées à $25\,850/37\,500 = 68{,}9$ \% en moyenne, bien plus que les 50 \% du ratio de la structure, et les travaux curables sont déduits intégralement ; l'âge-vie simple applique un ratio unique à l'ensemble et ignore donc l'usure plus rapide des finitions et des équipements. L'écart serait de signe opposé si les composantes de courte durée venaient d'être remplacées : la méthode ventilée est plus fidèle dans les deux cas.
30 +\end{solution}
31 +
32 +\begin{solution}{ex:11:5}{Recommander ou non un test}
33 +(a) Perte espérée sans test : $pC = 0{,}08 \times \dollars{20000} = \dollars{1600}$. Valeur de l'information parfaite (\cref{eq:11:vei}, avec $p < 1/2$) nette du coût du test : $pC - t = \dollars{1600} - \dollars{900} = \dollars{700} > 0$. Seuil : $p^{*} = t/C = 900/20\,000 = 0{,}045$ ; comme $p = 0{,}08 > 0{,}045$, le test est recommandé.
34 +
35 +(b) Radon : $p^{*} = 50/2\,500 = 0{,}02$. Il suffit donc que la probabilité de dépasser la ligne directrice excède 2 \% pour que le dosimètre vaille son coût ; en l'absence de test antérieur, l'évaluateur ne dispose d'aucune information qui lui permettrait de juger cette probabilité inférieure à 2 \%, de sorte que la recommandation est quasi systématique. Le seuil baisse encore si le coût de correction est plus élevé que \dollars{2500}. La recommandation se double de l'hypothèse extraordinaire selon laquelle la concentration respecte la ligne directrice, rappelée avec la conclusion.
36 +
37 +(c) Le test de radon mesure directement la variable d'intérêt, la concentration moyenne dans l'aire occupée, sur une période longue ; son imperfection tient à la variabilité annuelle, que Santé Canada traite en exigeant au moins 91 jours en saison de chauffage. Le test de pyrite est un échantillonnage ponctuel d'un remblai hétérogène, dont le résultat est un indice de potentiel de gonflement et non une mesure du dommage ; un résultat rassurant à un endroit n'exclut pas un remblai différent ailleurs sous la dalle, et un indice élevé ne signifie pas qu'un gonflement se produira. L'hypothèse du test parfaitement informatif est donc plus forte pour la pyrite ; avec un test imparfait, la valeur de l'information est plus faible que $pC - t$ et le seuil $p^{*}$ plus élevé, ce qui va dans le sens d'une recommandation plus sélective.
38 +\end{solution}
39 +
40 +\begin{solution}{ex:11:6}{Le poids de l'observation}
41 +Les valeurs sont celles de la fonction \texttt{sensibilite} du script, structure notée 3.
42 +
43 +\emph{$\lambda = 0{,}25$.} Âge effectif de la structure : $0{,}75 \times 28 + 0{,}25 \times 30 = 28{,}5$ ans ; dépréciation physique totale \dollars{237631} (59,77 \% du coût neuf), dont \dollars{148506} de courte durée et \dollars{78646} de longue durée.
44 +
45 +\emph{$\lambda = 0{,}75$.} Âge effectif de la structure : $0{,}25 \times 28 + 0{,}75 \times 30 = 29{,}5$ ans ; dépréciation totale \dollars{202500} (50,93 \%), dont \dollars{110615} de courte durée et \dollars{81405} de longue durée.
46 +
47 +L'écart de \dollars{35131} entre les deux conventions provient presque entièrement des composantes de courte durée : la longue durée ne varie que de \dollars{2759}, en sens inverse, parce que l'âge effectif de la structure reste voisin de 29 ans. Les trois composantes dont la dépréciation varie le plus sont le revêtement extérieur (\dollars{22374} à \dollars{13805}, écart \dollars{8569}), les armoires et comptoirs (\dollars{19042} à \dollars{14041}, écart \dollars{5001}) et les fenêtres (\dollars{18407} à \dollars{13573}, écart \dollars{4834}) ; viennent ensuite l'électricité et la plomberie. Ce sont les composantes d'origine, coûteuses, dont la note (2 ou 3) est meilleure que ce que leur âge de 28 ans laisserait attendre : plus $\lambda$ est élevé, plus l'inspection les rajeunit.
48 +
49 +\emph{Paragraphe de rapport (modèle).} « L'âge effectif de chaque composante a été établi selon la convention déclarée à l'annexe des conventions de calcul : moyenne, à poids égaux ($\lambda = 0{,}5$), de l'âge chronologique documenté par les factures et la déclaration du vendeur, et de l'âge indiqué par la note d'état constatée à l'inspection du 1\textsuperscript{er} septembre 2026, chaque note étant associée à une fraction de vie utile consommée (1 : 5 \% ; 2 : 25 \% ; 3 : 50 \% ; 4 : 75 \% ; 5 : 100 \%). Le poids égal traduit le fait que l'âge est un fait vérifié et la note un jugement, et qu'aucun des deux ne doit dominer. La sensibilité a été mesurée : un poids de 0,25 porterait la dépréciation physique à 59,8 \% du coût neuf et un poids de 0,75 la ramènerait à 50,9 \%, contre 55,6 \% retenu ; cet intervalle est intégré à la fourchette de valeur conclue. Les notes sont appuyées par 64 photographies numérotées jointes au dossier. »
50 +\end{solution}
51 +
52 +\begin{solution}{ex:11:7}{Déclarations du vendeur et hypothèses extraordinaires}
53 +(a) \emph{Vérifications et documents.} Toiture : facture de 2019, état des bardeaux et des solins, comble (isolant, pare-vapeur, ventilation, traces de l'infiltration passée). Infiltration de 2021 : localisation exacte, correction visible de la pente, traces d'efflorescence ou d'humidité au mur concerné, humidimètre au bas des murs et au plancher, existence d'un rapport ou d'une facture, réclamation d'assurance. Pyrite : maison de 1979 dans un secteur documenté, donc examen de la dalle du garage et du sous-sol (bombement, fissures en étoile), recherche d'un certificat de remblai ou d'un test antérieur, et, en leur absence, recommandation d'analyse ; la mention « aucune connaissance » du vendeur n'est pas une preuve d'absence. MIUF : trous de remplissage dans les murs extérieurs, date de construction compatible (1979, avant l'interdiction de décembre 1980). Amiante : matériaux de 1979 (carreaux, plâtre, isolant de tuyaux), vermiculite au comble. Radon : demander le résultat écrit du test, sa date et sa durée ; « il y a longtemps, résultat correct » n'est pas utilisable. Mazout : réservoir intérieur de 2008, rouille, suintements, plaque signalétique, conduits laissant supposer un ancien réservoir extérieur ou souterrain ; facture d'entretien du brûleur. Documents : DV signée, factures, permis municipaux (toiture, correction de drainage), rapports d'inspection antérieurs, police d'assurance et réclamations, certificat de localisation, rôle.
54 +
55 +(b) \emph{Hypothèses extraordinaires} (au moins : remblai, radon, infiltration/drain, réservoir et sol). Deux formulations complètes :
56 +
57 +\emph{Remblai.} Indice : maison construite en 1979 dans un secteur où la pyrite est documentée ; aucun certificat de remblai ni test antérieur ; dalle du garage examinée sans bombement apparent le jour de la visite. Hypothèse : le remblai sous les dalles n'a pas de potentiel de gonflement significatif. Recommandation : prélèvement et analyse du remblai par un laboratoire selon un protocole reconnu avant toute décision fondée sur le présent rapport. Incidence : si l'hypothèse se révélait fausse, la valeur conclue devrait être réduite du coût de correction établi par soumission, net de la plus-value éventuelle, et le rapport révisé ; l'hypothèse est rappelée à chaque énoncé de la conclusion (NPP, règles 2.1~c) et 2.3).
58 +
59 +\emph{Radon.} Indice : aucun résultat écrit de test ; déclaration verbale non datée. Hypothèse : la concentration moyenne annuelle de radon dans les aires occupées du niveau le plus bas respecte la ligne directrice canadienne de 200~Bq/m\textsuperscript{3}. Recommandation : mesure à long terme d'au moins 91 jours en saison de chauffage par dosimètre. Incidence : en cas de dépassement, la valeur devrait être réduite du coût d'un système d'atténuation, à établir par soumission ; l'incidence est modeste au regard de la valeur mais l'hypothèse doit être rappelée avec la conclusion.
60 +
61 +(c) \emph{Exclusion de garantie et comparabilité.} La vente « sans garantie légale, aux risques et périls de l'acheteur » (art.~1733 C.c.Q.) transfère à l'acheteur le risque des vices cachés que la garantie aurait couverts, sauf ceux que le vendeur connaissait ou ne pouvait ignorer. Un acheteur informé exige en contrepartie une décote, variable selon l'âge du bâtiment, les indices visibles et l'information disponible ; le prix observé est donc, toutes choses égales par ailleurs, inférieur à celui d'une vente avec garantie. Si cette vente devait servir de comparable pour un sujet vendu avec garantie, elle exigerait un ajustement de conditions de vente (\cref{ch:17}), difficile à mesurer et à documenter ; le \cref{ch:16} recommande, lorsque l'ajustement de transaction dépasserait quelques points de pourcentage, de remplacer le comparable plutôt que de l'ajuster, et d'écarter d'emblée les ventes dont les conditions particulières ne peuvent être vérifiées. Si elle est tout de même retenue, le rapport en fait état, l'ajuste à partir de paires de ventes si le marché en fournit, et lui attribue un poids réduit dans la réconciliation (\cref{ch:20}).
62 +\end{solution}
modified solutions/ch12.tex +49 −0
@@ -1 +1,50 @@
1 1 % Solutions du chapitre 12 — Mesurage, superficies et plans
2 +% Chiffres recalculés par analysis/python/tests/test_ch12.py (classe TestExercices) et analysis/python/cases/ch12_mesurage.py.
3 +
4 +\begin{solution}{ex:12:1}{Conversions et aires brutes}
5 +\emph{Aire et périmètre au sol.} $28 \times 42 = \pisq{1176}$ ; en mètres carrés, $1\,176 \times 0{,}09290304 = \msq{109.25}$ (ou $1\,176 / 10{,}7639 = 109{,}25$). Le périmètre vaut $2\,(28 + 42) = 140$ pieds, soit $140 \times 0{,}3048 = \qty{42.67}{\metre}$.
6 +
7 +\emph{Aire des étages (MEFQ).} Le bungalow n'a qu'un niveau dont le plancher n'est pas plus bas que 0,4 mètre sous le niveau moyen du sol : l'aire des étages est égale à l'aire au sol, \msq{109.25}. Le sous-sol, dont le plancher est à 1,5 mètre sous le sol, n'est pas un étage au sens du bloc *07 de la partie 2C du Manuel ; il entre dans l'aire au sol (projection de tous les étages et sous-sols, ici la même empreinte) mais non dans l'aire des étages. Un cottage de mêmes dimensions à deux étages aurait une aire des étages de $2 \times 109{,}25 = \msq{218.5}$ (\pisq{2352}).
8 +
9 +\emph{Variante.} Si le plancher du sous-sol était à 0,3 mètre sous le sol, le niveau inférieur satisferait la règle des 0,4 mètre et compterait comme un étage : l'aire des étages du bungalow passerait à \msq{218.5}, ce qui illustre la sensibilité de la mesure du rôle à une règle de seuil et explique que des maisons « à paliers » (split-level) affichent au rôle un nombre d'étages différent de celui que perçoivent leurs occupants.
10 +\end{solution}
11 +
12 +\begin{solution}{ex:12:2}{Classer les espaces}
13 +(a) \emph{Garage intégré} : compris dans l'aire des étages du MEFQ (le garage intégré fait partie de l'étage) ; exclu de la superficie habitable de l'ouvrage et de l'aire finie hors sol de l'ANSI Z765 (les garages ne peuvent être inclus). (b) \emph{Sous-sol fini, plancher à 1,2 m sous le sol} : exclu de l'aire des étages du MEFQ (plancher à plus de 0,4 m sous le sol) ; exclu de la superficie habitable, mais sa partie finie est indiquée séparément ; pour l'ANSI, c'est une aire finie \emph{sous le sol}, déclarée à part et jamais confondue avec l'aire hors sol. (c) \emph{Véranda trois saisons non chauffée} : le Manuel range la verrière intégrée dans l'étage, de sorte qu'une véranda intégrée au corps du bâtiment est comprise dans la mesure brute ; elle est exclue de la superficie habitable (espace non utilisable toute l'année) et de l'aire finie de l'ANSI, qui exige un espace clos utilisable en toute saison. (d) \emph{Chambre mansardée} : pour l'ANSI, la pièce est admissible puisque la moitié de sa surface atteint 2,13 m, mais la bande de 1,2 m de hauteur est inférieure au minimum de 1,52 m et doit être retranchée ; la convention de l'ouvrage retranche de même les espaces de hauteur insuffisante ; au MEFQ, la mesure est brute si le niveau est un étage, mais un comble aménagé peut relever de l'« attique » exclu de l'aire des étages, et c'est le Manuel qu'il faut consulter pour trancher la classification. (e) \emph{Trémie d'escalier} : comprise dans la mesure brute du MEFQ ; exclue de la superficie habitable ; exclue de l'aire finie de l'ANSI, sauf la surface des marches et paliers qui descendent, dans la limite de l'ouverture. (f) \emph{Mezzanine ouverte} : exclue de l'aire des étages du MEFQ par définition ; comprise dans la superficie habitable de l'ouvrage si elle est finie et de hauteur suffisante, puisqu'elle est un plancher hors sol ; comprise dans l'aire finie de l'ANSI si elle est reliée au reste de la maison et satisfait la hauteur minimale. Le classement montre que les trois conventions ne divergent que sur quatre points, le sous-sol, le garage, la hauteur et les ouvertures, et que ce sont ces quatre points que la note de dossier doit documenter.
14 +\end{solution}
15 +
16 +\begin{solution}{ex:12:3}{De l'aire brute à la superficie habitable}
17 +\emph{Un niveau.} Aire brute $A = 11{,}00 \times 9{,}00 = \msq{99.0}$ ; périmètre $P = 2\,(11 + 9) = \qty{40.0}{\metre}$. Par l'\cref{eq:12:murs}, $A_{\text{int}}/A = 1 - 0{,}30 \times 40/99 + 4 \times 0{,}09/99 = 1 - 0{,}12121 + 0{,}00364 = 0{,}88242$, d'où $A_{\text{int}} = \msq{87.36}$. Calcul direct : $(11{,}00 - 0{,}60)(9{,}00 - 0{,}60) = 10{,}40 \times 8{,}40 = \msq{87.36}$ ; les deux concordent.
18 +
19 +\emph{Deux niveaux.} Superficie habitable : $2 \times 87{,}36 - 3{,}2 = \msq{171.52}$ ; aire des étages : $2 \times 99{,}0 = \msq{198.0}$. Rapport aire des étages / habitable : $198{,}0/171{,}52 = 1{,}154$.
20 +
21 +\emph{Comparaison.} Le sujet du cas 1 a un rapport de 1,152 avec des murs de 0,28 m et une empreinte de \msq{95.04}. Le cottage de l'exercice a des murs plus épais (0,30 m), ce qui augmente l'écart, mais une empreinte un peu plus grande et plus compacte (11 × 9, proportion 1,22, contre 12 × 7,92, proportion 1,52), ce qui le réduit : le facteur de forme $\kappa = P/\sqrt{A}$ vaut $40/\sqrt{99} = 4{,}02$ contre $39{,}84/\sqrt{95{,}04} = 4{,}09$ pour le sujet. Les deux effets se compensent presque, et les rapports sont voisins. La trémie, un peu plus petite ici (\msq{3.2} contre \msq{3.4}), joue à la marge.
22 +\end{solution}
23 +
24 +\begin{solution}{ex:12:4}{Propagation d'une erreur de mesure}
25 +Prix marginal (\cref{eq:12:derivee}) : $\partial V/\partial S = 0{,}55 \times \dollars{520000}/\msq{140} = \parmsq{2042.86}$, contre un prix moyen de $\dollars{520000}/140 = \parmsq{3714.29}$.
26 +
27 +Écart-type induit (\cref{eq:12:delta}) : $\sigma_S = 0{,}03 \times 140 = \msq{4.2}$ et $\sigma_V \approx 2\,042{,}86 \times 4{,}2 = \dollars{8580}$, soit $0{,}55 \times 3\,\% = 1{,}65\,\%$ de la valeur.
28 +
29 +Effet exact (\cref{eq:12:exact}) : $+3\,\%$ donne $\dollars{520000} \times (1{,}03^{0{,}55} - 1) = +\dollars{8523}$ ($+1{,}639\,\%$) ; $-3\,\%$ donne $\dollars{520000} \times (0{,}97^{0{,}55} - 1) = -\dollars{8639}$ ($-1{,}661\,\%$). L'asymétrie, une soixantaine de dollars de part et d'autre de l'approximation linéaire, vient du terme du second ordre $\tfrac12 \beta(\beta - 1) e^2 = -0{,}011\,\%$, négatif puisque $0 < \beta < 1$.
30 +
31 +Calcul au prix moyen : $3\,714{,}29 \times 4{,}2 = \dollars{15600}$, soit $1/0{,}55 = 1{,}82$ fois l'effet réel. Ce calcul suppose que la valeur est proportionnelle à la superficie, c'est-à-dire que chaque mètre carré manquant emporte sa part de terrain, de cuisine, de raccordements et d'équipements ; or ces composantes ne varient pas avec une erreur de relevé. C'est l'élasticité, non le prix moyen, qui traduit une erreur de mesure en erreur de valeur.
32 +\end{solution}
33 +
34 +\begin{solution}{ex:12:5}{Mélange de conventions dans une grille}
35 +\emph{Biais de l'indication.} Par l'\cref{eq:12:melange}, $\hat V/V = (205/178)^{0{,}539} = 1{,}1517^{0{,}539} = 1{,}0791$ : le sujet est surévalué de \pct{7.91} parce que sa superficie, exprimée en aire brute du rôle, est comparée à des superficies habitables.
36 +
37 +\emph{Ajustement du comparable.} Avec la superficie habitable du sujet (\cref{eq:12:ajustement}) : $A = \dollars{560000} \times [(178/172)^{0{,}539} - 1] = \dollars{560000} \times 0{,}01866 = +\dollars{10447}$. Avec la superficie erronée : $A' = \dollars{560000} \times [(205/172)^{0{,}539} - 1] = \dollars{560000} \times 0{,}09924 = +\dollars{55572}$. L'erreur sur ce comparable est de \dollars{45125}, soit 8,1 \% de son prix, et elle se répète sur chaque comparable de la grille : l'indication finale est biaisée d'environ 7,9 \%, conformément au premier calcul.
38 +
39 +\emph{Note de dossier (exemple).} « Les superficies de la grille sont des superficies habitables au sens de la convention de l'ouvrage : niveaux hors sol mesurés à la face intérieure des murs extérieurs, trémies, garage et sous-sol exclus. La superficie du sujet a été relevée sur place le [date] au télémètre laser (croquis au dossier) : \msq{178}, sous-sol fini de [x] m² indiqué séparément. L'aire des étages du rôle (\msq{205}, mesure brute à la face externe des murs selon le MEFQ) n'a pas été retenue, le rapport brute/habitable de 1,15 étant cohérent avec l'épaisseur des murs. Les superficies des comparables proviennent des fiches de courtage, vérifiées auprès du rôle (rapport brute/habitable entre 1,12 et 1,18 pour les cinq ventes) ; aucune ne comprend le sous-sol. Les ajustements de superficie utilisent le prix marginal dérivé du modèle hédonique ($\beta = 0{,}539$) et non le prix moyen. »
40 +\end{solution}
41 +
42 +\begin{solution}{ex:12:6}{Atténuation et correction}
43 +\emph{Facteur d'atténuation.} Par l'\cref{eq:12:attenuation}, $\lambda = 0{,}0609/(0{,}0609 + 0{,}08^2) = 0{,}0609/0{,}0673 = 0{,}905$. Le coefficient attendu est $0{,}905 \times 0{,}539 = 0{,}488$, soit une perte d'un dixième. La simulation du script (fonction \texttt{attenuation}, en ajoutant $\sigma_u = 0{,}08$ à la liste des écarts-types) donne un coefficient moyen voisin de 0,49 avec un écart-type d'échantillonnage d'environ 0,007, en accord avec la théorie ; le \cref{tab:12:attenuation} encadre ce résultat entre $\sigma_u = 0{,}05$ (0,518) et $\sigma_u = 0{,}10$ (0,465).
44 +
45 +\emph{Première stratégie : une seconde mesure.} Si un sous-échantillon de ventes est remesuré selon la convention de l'ouvrage, la variance de l'erreur $\sigma_u^2$ s'estime directement par la variance des écarts entre la superficie déclarée et la superficie remesurée (en logarithme), et le coefficient estimé se corrige en le divisant par $\hat\lambda$. L'hypothèse requise est que l'erreur soit classique : indépendante de la vraie superficie, des autres régresseurs et du prix. Elle est violée si les fiches arrondissent vers le haut les petites maisons ou si les grandes maisons comptent plus souvent le sous-sol ; l'erreur est alors corrélée à la taille et la correction par $\hat\lambda$ est insuffisante.
46 +
47 +\emph{Deuxième stratégie : un instrument tiré du rôle.} L'aire des étages du rôle est une seconde mesure de la taille, corrélée à la vraie superficie habitable et dont l'erreur est produite par un processus différent (mesure brute par l'évaluateur municipal). Utilisée comme instrument de la superficie déclarée dans une estimation par variables instrumentales, elle fournit un estimateur convergent de $\beta$ pourvu que son erreur soit indépendante de celle des fiches et du terme d'erreur du modèle. L'hypothèse est plausible lorsque les deux mesures ont des sources distinctes ; elle ne l'est plus si les fiches recopient le rôle, ce que l'on vérifie en examinant la distribution du rapport des deux superficies.
48 +
49 +\emph{Conséquences pour les ajustements.} Un coefficient atténué de 0,488 au lieu de 0,539 sous-estime de 9,5 \% tous les ajustements de superficie dérivés du modèle au \cref{ch:17} : pour le comparable G0404 (\dollars{506500}, \msq{160}, sujet \msq{165}), l'ajustement passerait de \dollars{8472} à environ \dollars{7670}. L'erreur est modeste pour des comparables proches du sujet en taille, mais elle croît avec l'écart de superficie et devient systématique dans une grille où tous les comparables sont plus petits ou plus grands que le sujet.
50 +\end{solution}
modified sources/registry.csv +6 −0
@@ -332,6 +332,12 @@ SRC-11-011,norme,Norme de pratique professionnelle pour l'inspection visuelle de
332 332 SRC-11-012,page_web,Inspection préachat (page AIBQ),AIBQ,© AIBQ 2026 ; consultée le 2026-10-31,https://www.aibq.qc.ca/inspection-preachat/,2026-10-31,verifie,11,"« C'est l'inspection des systèmes et composantes visibles et accessibles d'un bâtiment » ; 11 composantes (structure, extérieur, toiture, plomberie, électricité, chauffage, climatisation et pompe à chaleur, intérieur, isolation, ventilation, sécurité des personnes) ; rapport écrit ; lien vers le PDF de la norme."
333 333 SRC-11-013,norme,Home Inspection Standards of Practice,International Association of Certified Home Inspectors (InterNACHI),Last revised October 2022,https://www.nachi.org/sop.htm,2026-10-31,verifie,11,"« A home inspection is a non-invasive, visual examination of the accessible areas of a residential property » ; 2.1.5 ne détermine pas la valeur marchande ; 2.1.8 ne détermine pas l'espérance de vie ; systèmes 3.1-3.10 ; quatre logements ou moins (2.1.10)."
334 334 SRC-11-014,page_web,Équipements pétroliers – Équipements pétroliers visés ; Lois et règlements,Régie du bâtiment du Québec,Consultées le 2026-10-30/31 (aucune date de mise à jour affichée),https://www.rbq.gouv.qc.ca/domaines-dintervention/equipements-petroliers/,2026-10-31,verifie,11,"Chapitre VIII « Installation d'équipements pétroliers » du Code de construction (B-1.1, r. 2) ; « Les équipements de moins de 10 000 litres d'une maison unifamiliale ne sont pas considérés comme des équipements pétroliers à risque élevé » ; appareils de chauffage non assujettis aux chapitres d'installation ; art. 8.05.01 adopte par renvoi la norme CSA B139 (nouvelle du 30 mai 2024)."
335 +SRC-12-001,loi,"Loi sur les arpenteurs-géomètres (RLRQ, c. A-23)",Québec (LégisQuébec),À jour au 15 octobre 2026,https://www.legisquebec.gouv.qc.ca/fr/document/lc/A-23,2026-10-31,verifie,12,"Art. 34 lu (officier public ; exercice de la profession : arpentages, mesurages, bornages, levés, plans, certificats de localisation, calculs de superficies ; méthodes directe, photogrammétrique, électronique). Page obtenue avec un en-tête User-Agent de navigateur (403 sinon)."
336 +SRC-12-002,reglement,"Règlement sur la norme de pratique relative au certificat de localisation (RLRQ, c. A-23, r. 10)",Québec (LégisQuébec),À jour au 1er juillet 2026 (D. 1058-2002),"https://www.legisquebec.gouv.qc.ca/fr/document/rc/A-23,%20r.%2010",2026-10-31,verifie,12,"Art. 1-9 lus : définition (art. 2), SI (art. 3), opérations (4-6), visite (7), réglementation municipale (8), contenu du rapport (9, par. 1 à 16)."
337 +SRC-12-003,reglement,"Règlement sur la norme de pratique relative au piquetage et à l'implantation (RLRQ, c. A-23, r. 11)",Québec (LégisQuébec),À jour au 1er juillet 2026 (D. 1057-2002),"https://www.legisquebec.gouv.qc.ca/fr/document/rc/A-23,%20r.%2011",2026-10-31,verifie,12,"Art. 1 (définitions implantation/piquetage), 4, 9-10 lus ; cité en toutes lettres pour distinguer piquetage et certificat."
338 +SRC-12-004,page_web,Certificat de localisation,Ordre des arpenteurs-géomètres du Québec,© OAGQ 2026 ; Guide des tarifs suggérés 2026 ; exemple de certificat 2025,https://oagq.qc.ca/services/certificat-de-localisation,2026-10-31,verifie,12,Rapport + plan ; éléments vérifiés ; « un créancier ou un notaire peut exiger un certificat à jour lors de la vente » ; vendeur paie habituellement ; ne sert pas à établir les limites. Pas de durée de validité chiffrée.
339 +SRC-12-005,norme,"ANSI Z765-2021, Square Footage — Method for Calculating (American National Standard for Single-Family Residential Buildings)",Home Innovation Research Labs (ANSI),Approuvée le 29 mars 2021,https://www.mlsnow.com/pdf/ANSIsqft.pdf,2026-10-31,partiellement_verifie,12,"Copie PDF diffusée par un service d'inscriptions (texte intégral lu : sections 1-4 et annexe) ; page de l'éditeur homeinnovation.com/…/ansi_z765_standard en HTTP 410 ; original non acquis. Norme volontaire, protégée par le droit d'auteur : paraphrasée, non reproduite."
340 +SRC-12-006,page_web,Arpent (unités du Québec : 180 pieds français ; 3 418,89 m²),Wikipédia (extrait de recherche),Consulté le 2026-10-31,https://fr.wikipedia.org/wiki/Arpent,2026-10-31,partiellement_verifie,12
335 341 SRC-CH17-001,article,Smearing Estimate: A Nonparametric Retransformation Method (Duan),Journal of the American Statistical Association,1983 vol. 78 no 383 p. 605-610,https://doi.org/10.1080/01621459.1983.10478017,2026-10-31,verifie,ch17,Crossref ; references/lib-ch17.bib (duan1983) ; non cité par clé
336 342 SRC-CH17-002,article,The Interpretation of Dummy Variables in Semilogarithmic Equations (Giles),Economics Letters,1982 vol. 10 no 1-2 p. 77-79,https://doi.org/10.1016/0165-1765(82)90119-7,2026-10-31,verifie,ch17,Crossref ; references/lib-ch17.bib (giles1982) ; non cité par clé
337 343 SRC-CH17-003,article,Exact Interpretation of Dummy Variables in Semilogarithmic Equations (van Garderen & Shah),The Econometrics Journal,2002 vol. 5 no 1 p. 149-159,https://doi.org/10.1111/1368-423X.00078,2026-10-31,verifie,ch17,Crossref ; references/lib-ch17.bib (vanGarderenShah2002) ; non cité par clé
modified tools/BRIEF-CHAPITRE.md +2 −0
@@ -14,6 +14,8 @@ Tu rédiges le **chapitre NN — TITRE** du traité « Évaluation immobilière
14 14 5. Lignes ajoutées (append, sans toucher aux autres lignes) à `verification/figures.csv`, `verification/calculations.csv`, `verification/claims.csv` ; nouvelles sources dans `sources/registry.csv` et `references/library.bib` (uniquement vérifiées : ouvre l'URL/DOI ; sinon claims.csv statut non_verifie).
15 15 6. `verification/chapters/chNN.md` — bilan : mots, figures, tableaux, équations, exercices, liste des affirmations non vérifiées, difficultés.
16 16
17 +**Renvois fins** : en compilation isolée, `tools/compile_chapter.sh` résout aussi les étiquettes (`eq:`, `fig:`, `tab:`, `sec:`, `def:`, `prop:`, `ex:`) des chapitres déjà compilés dans `build/` ; tu peux donc écrire `\cref{eq:16:local}` si le ch. 16 est livré.
18 +
17 19 **Contrôle final obligatoire** : `tools/compile_chapter.sh NN` → doit afficher `OK chNN : … 0 references/citations non definies` ; corrige jusqu'à ce que ce soit le cas (lis `build/chNN/_wrap_chNN.log` en cas d'erreur). Puis `python3 tools/check_manuscript.py --no-log`. Ne déclare jamais le chapitre terminé sans ces deux sorties.
18 20
19 21 **Interdits** : inventer une référence, une loi, un chiffre ; placeholders ; modifier `styles/`, `main.tex`, les fichiers d'autres chapitres ou la bibliothèque sans ajouter de test ; dépasser le périmètre de ton chapitre (renvoie aux autres chapitres par `\cref{ch:MM}`) ; murs de puces.
modified verification/calculations.csv +23 −0
@@ -252,3 +252,26 @@ CALC-18-15,18,"Indice trimestriel (15 trimestres, 2026T3 = 122,0 vs tendance 116
252 252 CALC-18-16,18,"Sensibilité à l'échantillon : Hull 186 → 625 967 ; 18 mois 236 → 640 098 ; étages 330 → 629 272 ; sans Cook 571 → 636 872 ; 110-230 m² 495 → 635 048 ; étendue 2,2 %",analysis/python/cases/ch18_hedonique.py,analysis/python/tests/test_ch18.py::test_indice_et_sensibilite,voir tab:18:sensibilite,idem,verifie,2026-10-31
253 253 CALC-18-17,18,"Résidus des cinq comparables du ch. 16 (G0574 +0,007 ; G0404 −0,161 ; G0468 −0,104 ; G0477 −0,111 ; G0575 −0,084 ; moyenne −8,7 %)",analysis/python/cases/ch18_hedonique.py,analysis/python/tests/test_ch18.py::test_residus_des_comparables,voir description,idem,verifie,2026-10-31
254 254 CALC-18-18,18,Solutions des exercices 18.1 à 18.8 (chiffres des solutions/ch18.tex),analysis/python/cases/ch18_hedonique.py,analysis/python/tests/test_ch18.py::test_solutions_exercices,voir solutions/ch18.tex,idem,verifie,2026-10-31
255 +CALC-11-001,11,Facteur de majoration C_N/D des coûts de composantes (coût neuf ch. 21 / coûts directs),analysis/python/cases/ch11_inspection.py,analysis/python/tests/test_ch11.py::TestFiche::test_couverture_du_devis,1.33043,1.33043,verifie,2026-10-31
256 +CALC-11-002,11,Fiche d'état : 20 composantes couvrant les 33 postes directs ; Σ coûts neufs = 397 567,33 $ ; courte/moyenne durée 224 956,09 $ (56,6 %) ; structure 172 611,24 $ (43,4 %),analysis/python/cases/ch11_inspection.py
257 +CALC-11-003,11,"Âges effectifs cités (bardeaux 12,00 ; gouttières 23,38 ; fenêtres 21,50 ; VRC 20 (borné) ; structure 29,00 ; vie restante 31)",analysis/python/cases/ch11_inspection.py,analysis/python/tests/test_ch11.py::TestFiche::test_valeurs_citees ; TestConvention::test_cas_limites,"12 ; 23,375 ; 21,5 ; 20 ; 29 ; 31","12 ; 23,375 ; 21,5 ; 20 ; 29 ; 31",verifie,2026-10-31
258 +CALC-11-004,11,"Propriétés de la convention a_eff = (1−λ)a + λφ(c)L bornée : bornes [0, L], monotonie en a et c, a_eff = a si φ(c)L = a",analysis/python/cases/ch11_inspection.py,analysis/python/tests/test_ch11.py::TestConvention,propriétés vraies,propriétés vraies,verifie,2026-10-31
259 +CALC-11-005,11,"Indice d'état pondéré I_E = 2,809 ; répartition des notes {2: 7, 3: 8, 4: 4, 5: 1} ; fraction moyenne de vie consommée 0,547 (32,8 ans / 60)",analysis/python/cases/ch11_inspection.py,analysis/python/tests/test_ch11.py::TestFiche::test_indice_etat,"2,809 ; 0,547","2,809 ; 0,547",verifie,2026-10-31
260 +CALC-11-006,11,Travaux immédiats (5) : total curable 10 479,98 $ = 2,64 % de C_N ; entrée 3 438,43 $ ; thermos 2 788,93 $,analysis/python/cases/ch11_inspection.py,analysis/python/tests/test_ch11.py::TestTravaux
261 +CALC-11-007,11,"Dépréciation ventilée : courte durée 130 584,55 $ ; base longue 165 569,69 $ ; longue 80 025,35 $ ; total 221 089,88 $ = 55,61 % ; contribution 176 477,45 $ ; identités (somme, base = C_N − curable − Σ courtes)",analysis/python/cases/ch11_inspection.py,analysis/python/tests/test_ch11.py::TestDepreciation,221089.88 ; 55.61 %,221089.88 ; 55.61 %,verifie,2026-10-31
262 +CALC-11-008,11,"Âge-vie simple : 29 ans → 192 157,55 $ (48,33 %) ; 28 ans → 185 531,42 $ (46,67 %) ; écart ventilée − âge-vie = 28 932,34 $",analysis/python/cases/ch11_inspection.py,analysis/python/tests/test_ch11.py::TestDepreciation::test_valeurs_citees,192157.55 ; 185531.42 ; 28932.34,192157.55 ; 185531.42 ; 28932.34,verifie,2026-10-31
263 +CALC-11-009,11,"Sensibilité λ × état de la structure (25 cellules) : 63,68 % à λ = 0 ; 46,24-66,02 % à λ = 0,5 ; 27,38-66,94 % à λ = 1 ; monotonie en état ; indépendance de l'état à λ = 0",analysis/python/cases/ch11_inspection.py,analysis/python/tests/test_ch11.py::TestSensibilite,grille du tableau 11.8,grille du tableau 11.8,verifie,2026-10-31
264 +CALC-11-010,11,"Valeur de l'information : radon (p 10 %, C 3 000 $, t 50 $) VEI 250 $ ; pyrite (5 %, 25 000 $, 1 200 $) 50 $ ; pyrite (2 %) −700 $ ; seuil p* = t/C = 0,048 ; p* radon 50/3000 ≈ 1,7 %",analysis/python/cases/ch11_inspection.py,analysis/python/tests/test_ch11.py::TestValeurInformation,250 ; 50 ; −700 ; 0,048,250 ; 50 ; −700 ; 0,048
265 +CALC-11-011,11,"Exercices : ex. 3 âges effectifs (13,5 ; 6 ; 21,25 ; 30) ; ex. 4 ventilée 159 350 $ (53,12 %) vs âge-vie 150 000 $ ; ex. 5 VEI 700 $, p* 0,045 et 0,02 ; ex. 6 λ = 0,25 → 59,77 % (28,5 ans), λ = 0,75 → 50,93 % (29,5 ans)",analysis/python/cases/ch11_inspection.py ; traite.cout,analysis/python/tests/test_ch11.py::TestExercices,voir solutions/ch11.tex,voir solutions/ch11.tex,verifie,2026-10-31
266 +CALC-12-001,12,"Superficies du sujet : aire au sol 7,92 × 12,00 ; périmètre ; aire des étages (2 niveaux) ; périmètre des étages",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestPlan::test_superficies_sujet,"95,04 ; 39,84 ; 190,08 ; 79,68","95,04 ; 39,84 ; 190,08 ; 79,68",verifie,2026-10-31
267 +CALC-12-002,12,"Superficie habitable = 2 × (7,36 × 11,44) − trémie 3,40 ; nette (− cloisons 5,06) ; habitable + sous-sol fini 70",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestPlan::test_superficies_sujet,"165,0 ; 159,9 ; 235,0","164,9968 ; 159,9368 ; 234,9968",verifie,2026-10-31
268 +CALC-12-003,12,Formule des murs A_int/A = 1 − tP/A + 4t²/A (= calcul direct) ; rapport brute/habitable,analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestPlan::test_formule_murs,"0,8859 ; 1,152","0,8859 ; 1,152",verifie,2026-10-31
269 +CALC-12-004,12,"Conversions : 1 pi² = 0,3048² m² ; 165 m² = 1 776 pi² ; 190,08 m² = 2 046 pi²",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestConversions::test_facteurs,"0,09290304 ; 1 776 ; 2 046","0,09290304 ; 1 776,0 ; 2 046,0",verifie,2026-10-31
270 +CALC-12-005,12,"Prix unitaires implicites du sujet (V = 637 953 $, traite.cas1.predire) selon la convention",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestPrixUnitaires::test_valeurs,6 712 / 3 989 / 3 866 / 3 356 / 2 715 $/m²,6 712 / 3 989 / 3 866 / 3 356 / 2 715 $/m²,verifie,2026-10-31
271 +CALC-12-006,12,"Prix marginal ∂V/∂S = β V/S avec β = 0,539 (traite.cas1.hedonique)",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestPropagation::test_derivee,2 084 $/m² ; rapport au prix moyen = β,"2 084,25 $/m² ; 0,5391",verifie,2026-10-31
272 +CALC-12-007,12,"Erreur de 5 % sur S : linéaire 17 195 $ (2,695 %) ; exact +17 002 / −17 398 $ ; naïf 31 898 $ ; 10 % : +5,27 / −5,52 %",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestPropagation::test_cinq_pour_cent,17 195 ; 17 002 ; −17 398 ; 31 898,"17 195,07 ; 17 001,60 ; −17 398,19 ; 31 897,64",verifie,2026-10-31
273 +CALC-12-008,12,"Mélange de conventions : sujet brut (190,08) vs habitable (165) → +7,93 % = +50 573 $ ; inverse −7,35 % = −46 858 $",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestPropagation::test_melange_conventions,"+7,93 % ; 50 573 $ ; −7,35 % ; −46 858 $","+7,9274 % ; 50 573,11 $ ; −7,3451 % ; −46 858,45 $",verifie,2026-10-31
274 +CALC-12-009,12,"Ajustement de superficie du comparable G0404 (506 500 $, 160 m²) : +8 472 $ ; avec S erronée 170 m² : −8 086 $ ; erreur −16 558 $ (approx. −17 065 $)",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestPropagation::test_comparable,8 472 ; −8 086 ; −16 558,"8 471,94 ; −8 085,79 ; −16 557,72",verifie,2026-10-31
275 +CALC-12-010,12,"Variance combinée β√(σs² + σc²/n) : 2 %/2 %/5 → 1,181 % = 7 535 $ (part du sujet 83,3 %) ; sujet seul 6 878 $ ; comparables seuls 3 076 $ ; 5 %/2 % → 17 468 $",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestPropagation::test_variance_combinee,7 535 ; 6 878 ; 3 076 ; 17 468,7 535 ; 6 878 ; 3 076 ; 17 468,verifie,2026-10-31
276 +CALC-12-011,12,"Atténuation : Var(ẽ) = 0,0609 ; sd(ln S) = 0,2487 ; λ(0,10) = 0,859, β̂ moyen 0,465 ; λ(0,20) = 0,604, β̂ 0,328 ; 200 réplications, graine 20261031",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestAttenuation::test_theorie_et_simulation,"0,0609 ; 0,859/0,465 ; 0,604/0,328","0,06092 ; 0,8590/0,4651 ; 0,6037/0,3280",verifie,2026-10-31
277 +CALC-12-012,12,"Exercices 1, 3, 4, 5, 6 : 1 176 pi² = 109,25 m², 42,67 m ; 87,36 m², 171,52 m², 1,154 ; 2 042,86 $/m², 8 580 $, +8 523/−8 639 $, 15 600 $ ; +7,91 %, 10 447 $, 55 572 $ ; λ = 0,905, 0,488",analysis/python/tests/test_ch12.py,analysis/python/tests/test_ch12.py::TestExercices,voir solutions/ch12.tex,idem,verifie,2026-10-31
added verification/chapters/ch11-calculations.csv +12 −0
@@ -0,0 +1,12 @@
1 +id,chapitre,description,script,test,resultat_attendu,resultat_obtenu,statut,date
2 +CALC-11-001,11,Facteur de majoration C_N/D des coûts de composantes (coût neuf ch. 21 / coûts directs),analysis/python/cases/ch11_inspection.py,analysis/python/tests/test_ch11.py::TestFiche::test_couverture_du_devis,1.33043,1.33043,verifie,2026-10-31
3 +CALC-11-002,11,Fiche d'état : 20 composantes couvrant les 33 postes directs ; Σ coûts neufs = 397 567,33 $ ; courte/moyenne durée 224 956,09 $ (56,6 %) ; structure 172 611,24 $ (43,4 %),analysis/python/cases/ch11_inspection.py,analysis/python/tests/test_ch11.py::TestFiche,397567.33 ; 224956.09 ; 172611.24,397567.33 ; 224956.09 ; 172611.24,verifie,2026-10-31
4 +CALC-11-003,11,"Âges effectifs cités (bardeaux 12,00 ; gouttières 23,38 ; fenêtres 21,50 ; VRC 20 (borné) ; structure 29,00 ; vie restante 31)",analysis/python/cases/ch11_inspection.py,analysis/python/tests/test_ch11.py::TestFiche::test_valeurs_citees ; TestConvention::test_cas_limites,"12 ; 23,375 ; 21,5 ; 20 ; 29 ; 31","12 ; 23,375 ; 21,5 ; 20 ; 29 ; 31",verifie,2026-10-31
5 +CALC-11-004,11,"Propriétés de la convention a_eff = (1−λ)a + λφ(c)L bornée : bornes [0, L], monotonie en a et c, a_eff = a si φ(c)L = a",analysis/python/cases/ch11_inspection.py,analysis/python/tests/test_ch11.py::TestConvention,propriétés vraies,propriétés vraies,verifie,2026-10-31
6 +CALC-11-005,11,"Indice d'état pondéré I_E = 2,809 ; répartition des notes {2: 7, 3: 8, 4: 4, 5: 1} ; fraction moyenne de vie consommée 0,547 (32,8 ans / 60)",analysis/python/cases/ch11_inspection.py,analysis/python/tests/test_ch11.py::TestFiche::test_indice_etat,"2,809 ; 0,547","2,809 ; 0,547",verifie,2026-10-31
7 +CALC-11-006,11,Travaux immédiats (5) : total curable 10 479,98 $ = 2,64 % de C_N ; entrée 3 438,43 $ ; thermos 2 788,93 $,analysis/python/cases/ch11_inspection.py,analysis/python/tests/test_ch11.py::TestTravaux,10479.98,10479.98,verifie,2026-10-31
8 +CALC-11-007,11,"Dépréciation ventilée : courte durée 130 584,55 $ ; base longue 165 569,69 $ ; longue 80 025,35 $ ; total 221 089,88 $ = 55,61 % ; contribution 176 477,45 $ ; identités (somme, base = C_N − curable − Σ courtes)",analysis/python/cases/ch11_inspection.py,analysis/python/tests/test_ch11.py::TestDepreciation,221089.88 ; 55.61 %,221089.88 ; 55.61 %,verifie,2026-10-31
9 +CALC-11-008,11,"Âge-vie simple : 29 ans → 192 157,55 $ (48,33 %) ; 28 ans → 185 531,42 $ (46,67 %) ; écart ventilée − âge-vie = 28 932,34 $",analysis/python/cases/ch11_inspection.py,analysis/python/tests/test_ch11.py::TestDepreciation::test_valeurs_citees,192157.55 ; 185531.42 ; 28932.34,192157.55 ; 185531.42 ; 28932.34,verifie,2026-10-31
10 +CALC-11-009,11,"Sensibilité λ × état de la structure (25 cellules) : 63,68 % à λ = 0 ; 46,24-66,02 % à λ = 0,5 ; 27,38-66,94 % à λ = 1 ; monotonie en état ; indépendance de l'état à λ = 0",analysis/python/cases/ch11_inspection.py,analysis/python/tests/test_ch11.py::TestSensibilite,grille du tableau 11.8,grille du tableau 11.8,verifie,2026-10-31
11 +CALC-11-010,11,"Valeur de l'information : radon (p 10 %, C 3 000 $, t 50 $) VEI 250 $ ; pyrite (5 %, 25 000 $, 1 200 $) 50 $ ; pyrite (2 %) −700 $ ; seuil p* = t/C = 0,048 ; p* radon 50/3000 ≈ 1,7 %",analysis/python/cases/ch11_inspection.py,analysis/python/tests/test_ch11.py::TestValeurInformation,250 ; 50 ; −700 ; 0,048,250 ; 50 ; −700 ; 0,048,verifie,2026-10-31
12 +CALC-11-011,11,"Exercices : ex. 3 âges effectifs (13,5 ; 6 ; 21,25 ; 30) ; ex. 4 ventilée 159 350 $ (53,12 %) vs âge-vie 150 000 $ ; ex. 5 VEI 700 $, p* 0,045 et 0,02 ; ex. 6 λ = 0,25 → 59,77 % (28,5 ans), λ = 0,75 → 50,93 % (29,5 ans)",analysis/python/cases/ch11_inspection.py ; traite.cout,analysis/python/tests/test_ch11.py::TestExercices,voir solutions/ch11.tex,voir solutions/ch11.tex,verifie,2026-10-31
added verification/chapters/ch11-claims.csv +26 −0
@@ -0,0 +1,26 @@
1 +id,chapitre,affirmation,source_id,verification,date,statut,notes
2 +CLM-11-001,11,"NPP OEAQ règle 2.4 : tout immeuble évalué doit faire l'objet d'une inspection sauf circonstances exceptionnelles expliquées au rapport ; règle 2.3 : douze éléments (identification, étendue du travail, réserves et hypothèses) ; règle 2.1 c) : hypothèses extraordinaires avec incidence",SRC-C-001,notes-C.md (texte lu par le domaine C),2026-10-31,verifie,
3 +CLM-11-002,11,"Norme AIBQ (V03 2011, m.à.j. 2023) : inspection « visuelle et attentive, qui n'est cependant pas techniquement exhaustive » (art. 7), examen des composantes installées facilement accessibles (art. 8) ; exclusions 12.1 (durée de vie résiduelle), 12.2 (coûts), 12.3 (valeur marchande), 12.10 (conformité aux codes) ; 11.4 (substances dangereuses) ; art. 14 (rapport) ; art. 16 (déclarations du vendeur) ; art. 18 (sonder les composantes détériorées)",SRC-11-011,PDF lu le 2026-10-31 (pages 1-10),2026-10-31,verifie,"Le titre exact est « Norme de pratique professionnelle pour l'inspection visuelle de bâtiments principalement résidentiels » ; la page HTML de l'AIBQ ne le donne pas."
4 +CLM-11-003,11,"InterNACHI SOP (oct. 2022) : « non-invasive, visual examination of the accessible areas » ; ne détermine ni la valeur marchande (2.1.5) ni l'espérance de vie (2.1.8)",SRC-11-013,Page lue le 2026-10-31 par agent web-fetch,2026-10-31,verifie,
5 +CLM-11-004,11,"Art. 1726 C.c.Q. (vices cachés ; vice apparent = constatable par un acheteur prudent et diligent sans expert) ; art. 1732-1733 (exclusion conventionnelle, vente aux risques et périls) ; Marcoux c. Picard 2008 QCCA 259 par. 20 (« acheteur prudent et diligent et non pas celui de l'expert tatillon ») ; ABB c. Domtar 2007 CSC 50 (existence)",SRC-A-036,notes-A.md §2.1 et §9 (texte de Marcoux lu dans une copie PDF ; ABB non lu : aucune citation textuelle),2026-10-31,verifie,"ABB c. Domtar cité pour son existence seulement."
6 +CLM-11-005,11,"Formulaire DV OACIQ obligatoire depuis juillet 2012 (personne physique, < 5 logements, courtier) ; version V24 06/2022, sections D1 à D15 ; annexé à la PA clause 13.1 ; PA clause 8 (inspection), 12.1 (garantie légale) ; DVD pour la copropriété divise",SRC-A-026;SRC-A-027,notes-A.md §2.4 (PDF DV et PA lus),2026-10-31,verifie,
7 +CLM-11-006,11,"Loi 16 (L.Q. 2019, c. 28) vise notamment « l'encadrement des inspections en bâtiment » ; état du règlement d'application au 2026-10-31 non vérifié",SRC-A-015,Intitulé vérifié (library.bib loi16_2019) ; pages RBQ/Québec.ca sur l'inspection en bâtiment introuvables (404) le 2026-10-31,2026-10-31,partiellement_verifie,"Formulé prudemment : « l'état du règlement d'application n'a pas pu être vérifié »."
8 +CLM-11-007,11,"Loi sur le bâtiment art. 46 (licence), art. 29 (exclusion unifamiliale et résidentiel < 3 étages ou < 9 logements du chapitre Bâtiment) ; Règlement B-1.1, r. 8 : art. 2 (bâtiments visés), art. 10 (1 an malfaçons, 3 ans vices cachés, 5 ans vices de conception/sol art. 2118), art. 13 (300 000 $ unifamiliale), art. 14 (200 000 $/logement), art. 16 (transférable)",SRC-B-057;SRC-B-058,notes-B.md §44.1,2026-10-31,verifie,
9 +CLM-11-008,11,"Code de construction chapitre I fondé sur le CNB 2020 modifié Québec, en vigueur le 17 avril 2025 (D. 437-2025) ; dispositions antérieures applicables aux travaux débutés avant le 17 octobre 2027 (RBQ 26 août 2026)",SRC-B-053;SRC-B-055,notes-B.md §44.1,2026-10-31,verifie,
10 +CLM-11-009,11,"Radon : ligne directrice 200 Bq/m³ (moyenne annuelle) ; mesures correctives d'ici un an ; mesure à long terme ≥ 91 jours dont 91 en saison de chauffage, au niveau le plus bas normalement occupé",SRC-11-001;SRC-11-002,Pages Santé Canada lues le 2026-10-31,2026-10-31,verifie,
11 +CLM-11-010,11,"Québec : concentration moyenne dans les sous-sols ≈ 37 Bq/m³ ; dépassements jusqu'à > 1 000 Bq/m³ ; dosimètre passif ≥ 3 mois de préférence octobre-avril, ≈ 50 $ ; 2e cause de cancer du poumon après le tabagisme",SRC-11-003,Page Québec.ca lue le 2026-10-31,2026-10-31,verifie,"Aucune proportion de maisons québécoises au-dessus de 200 Bq/m³ n'est citée dans le chapitre (non disponible sur les pages officielles consultées ; l'estimation INSPQ « environ 10 % » n'a pas été reprise)."
12 +CLM-11-011,11,"Moisissures (Santé Canada) : humidité relative 30-50 % ; trois catégories de surface (petite ≤ 3 plaques < 1 m² ; moyenne jusqu'à 3 m² ; grande > 3 m²) ; professionnel si zone importante ; aucune quantité sans risque ; identification de l'espèce généralement inutile",SRC-11-004,Page lue le 2026-10-31,2026-10-31,verifie,
13 +CLM-11-012,11,"Amiante : avant 1990 surtout utilisé pour isoler contre le froid et le bruit ; fabrication/importation/vente/utilisation interdites (règlement de 2018, en vigueur le 30 déc. 2018) ; matériaux anciens pouvant en contenir ; pas de risque important si intact",SRC-11-006,Page Santé Canada lue le 2026-10-31 ; date du 30 déc. 2018 confirmée par la page ECCC « Liste des substances toxiques : amiante »,2026-10-31,verifie,
14 +CLM-11-013,11,"Vermiculite : minerai de la mine de Libby (Montana) 1920-1990, vendu au Canada sous la marque Zonolite Attic Insulation ; supposer la présence d'amiante dans un vieux produit en l'absence de preuve contraire",SRC-11-005,Emprisonnons la chaleur §1.4 (RNCan) lu le 2026-10-31 via extraits de recherche et page « Rénovations écoénergétiques »,2026-10-31,verifie,
15 +CLM-11-014,11,"MIUF interdite au Canada depuis décembre 1980 en vertu de la Loi sur les produits dangereux",SRC-11-005,Page RNCan lue le 2026-10-31,2026-10-31,verifie,
16 +CLM-11-015,11,"Pyrite (RBQ) : acide sulfurique par oxydation au contact de l'humidité et de l'oxygène ; soulèvement et fissuration de la dalle ; pyrrhotite : sulfure de fer cristallisant en lamelles, fissures dans le béton",SRC-11-008,Page RBQ lue le 2026-10-31,2026-10-31,verifie,
17 +CLM-11-016,11,"Pratique du marché : échantillonnage du remblai selon un protocole reconnu et indice pétrographique du potentiel de gonflement (IPPG) ; remblais certifiés non gonflants depuis la fin des années 1990 ; secteurs touchés région de Montréal, constructions 1970-1990",,Sources secondaires seulement (ACQC, laboratoires, Centris) ; aucune page gouvernementale trouvée le 2026-10-31,2026-10-31,partiellement_verifie,"Formulé comme « pratique du marché » sans nom de protocole ni seuils ; périodes = repères indicatifs de la frise."
18 +CLM-11-017,11,"Programme pyrrhotite (SHQ) : mis en œuvre en 2011, terminé le 30 septembre 2026 ; volet I : 75 % des coûts admissibles jusqu'à 75 000 $ (bâtiment non couvert par le plan de garantie) ; condition : fondations endommagées ou taux minimal de 0,23 % de pyrrhotite ; Mauricie et Centre-du-Québec (communiqué 2018 : aussi Laurentides)",SRC-11-010,Pages Québec.ca lues le 2026-10-31 (volet I ; normes du programme PDF SHQ : « mis en œuvre en 2011 »),2026-10-31,verifie,"Période de construction 1996-2008 de la frise = repère indicatif, non vérifié."
19 +CLM-11-018,11,"Ocre ferreuse (RBQ) : réaction chimique (fer → hydroxyde de fer) et processus biologique (ferrobactérie → masse gélatineuse) ; colmatage des drains ; infiltration à la jonction murs-dalle ; sols sablonneux/silteux plus à risque ; dépôts rougeâtres, odeur de soufre ; évaluation du site et cheminées de nettoyage recommandées",SRC-11-007,Page RBQ lue le 2026-10-31,2026-10-31,verifie,
20 +CLM-11-019,11,"Sols argileux (RBQ) : variation de volume (sécheresse), arbres, affaissement des fondations ; vallée du Saint-Laurent, vallée de l'Outaouais, Saguenay–Lac-Saint-Jean",SRC-11-009,Page RBQ lue le 2026-10-31,2026-10-31,verifie,
21 +CLM-11-020,11,"RBQ équipements pétroliers : chapitre VIII du Code de construction ; équipements < 10 000 L d'une maison unifamiliale non considérés à risque élevé ; appareils de chauffage relèvent d'une norme d'installation adoptée par renvoi (CSA B139)",SRC-11-014,Pages RBQ lues le 2026-10-31 par agent web-fetch,2026-10-31,verifie,
22 +CLM-11-021,11,"Chauffez vert terminé le 31 mars 2026 ; programme fédéral de conversion abordable du mazout : date limite 31 juillet 2026 au Québec ; Rénoclimat refondu le 1er octobre 2026",SRC-B-062;SRC-B-066,notes-B.md §44.2,2026-10-31,verifie,
23 +CLM-11-022,11,"Périodes de la frise des pathologies (galvanisé, plomb, boutons et tubes, deux fils, aluminium 1965-1976, polybutylène 1978-1995, pyrite 1970-1999, pyrrhotite 1996-2008, réservoirs 1945-1980, simple vitrage, pare-vapeur) = repères indicatifs de connaissances générales",,Non vérifiées sur source publique ; signalées comme indicatives dans la figure, la légende et les limites,2026-10-31,non_verifie,"Seules les dates marquées (v) sont vérifiées (CLM-11-008, 012, 013, 014, 017)."
24 +CLM-11-023,11,"Vies utiles des composantes de l'exemple (bardeaux 20, gouttières 25, fenêtres 30, revêtement 40, peinture 10, bois franc 40, céramique 35, flottant 20, armoires 30, vanités 25, plomberie 50, chauffe-eau 12, électricité 50, VRC 20, thermopompe 15, balcon 25, asphalte 20, paysager 20, vie économique 60) et fractions φ(c)",,Hypothèses pédagogiques déclarées dans le script et le texte,2026-10-31,non_verifie,"Aucune source publique ; déclarées comme hypothèses (encadrés synthetique et limites, tabsource)."
25 +CLM-11-024,11,"Filage d'aluminium, boutons et tubes, polybutylène, galvanisé : incidence sur l'assurabilité et exigences des assureurs",,Connaissance générale de la pratique ; aucune source publique consultée,2026-10-31,non_verifie,"Formulé qualitativement (« les assureurs exigent souvent », « plusieurs assureurs traitent avec réserve »)."
26 +CLM-11-025,11,"Paramètres de la valeur de l'information (dosimètre 50 $ vérifié ; correction radon 3 000 $, test de pyrite 1 200 $, excavation 25 000 $ = paramètres d'illustration)",SRC-11-003,Le 50 $ est lu sur Québec.ca ; les autres montants sont déclarés comme paramètres hypothétiques dans le texte,2026-10-31,partiellement_verifie,
added verification/chapters/ch11-figures.csv +6 −0
@@ -0,0 +1,6 @@
1 +fichier,chapitre,script,donnees,type_donnees,legende_courte,statut
2 +ch11/schema-enveloppe (TikZ),11,manuscript/chapters/ch11.tex,,schema,"Coupe schématique de l'enveloppe d'un cottage et points d'attention de l'inspection",verifie
3 +ch11/ch11-fig-frise-pathologies.pdf,11,analysis/python/figures/ch11_frise_pathologies.py,repères indicatifs codés dans le script (FRISE),schema,"Frise indicative des matériaux et pratiques à risque selon la période de mise en place ; dates (v) vérifiées (RNCan, Canada.ca, Québec.ca, RBQ) ; le reste = connaissances générales non sourcées",verifie
4 +ch11/ch11-fig-matrice-age-effectif.pdf,11,analysis/python/figures/ch11_matrice_age_effectif.py,convention déclarée (analysis/python/cases/ch11_inspection.py : age_effectif),schema,"Matrice note d'état × âge chronologique → âge effectif (λ = 0,5, L = 60) et profils par note",verifie
5 +ch11/ch11-fig-fiche-etat.pdf,11,analysis/python/figures/ch11_fiche_etat.py,data/synthetic/cas1_gatineau_couts.csv;data/synthetic/cas1_gatineau_sujet.json,synthetique,"Fiche d'état du sujet du cas 1 : fraction de vie utile consommée par composante, âge chronologique vs âge effectif [données synthétiques]",verifie
6 +ch11/ch11-fig-sensibilite-age-effectif.pdf,11,analysis/python/figures/ch11_fiche_etat.py,data/synthetic/cas1_gatineau_couts.csv,synthetique,"Sensibilité de la dépréciation physique totale au poids λ et à la note d'état de la structure [données synthétiques]",verifie
added verification/chapters/ch12-calculations.csv +13 −0
@@ -0,0 +1,13 @@
1 +id,chapitre,description,script,test,resultat_attendu,resultat_obtenu,statut,date
2 +CALC-12-001,12,"Superficies du sujet : aire au sol 7,92 × 12,00 ; périmètre ; aire des étages (2 niveaux) ; périmètre des étages",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestPlan::test_superficies_sujet,"95,04 ; 39,84 ; 190,08 ; 79,68","95,04 ; 39,84 ; 190,08 ; 79,68",verifie,2026-10-31
3 +CALC-12-002,12,"Superficie habitable = 2 × (7,36 × 11,44) − trémie 3,40 ; nette (− cloisons 5,06) ; habitable + sous-sol fini 70",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestPlan::test_superficies_sujet,"165,0 ; 159,9 ; 235,0","164,9968 ; 159,9368 ; 234,9968",verifie,2026-10-31
4 +CALC-12-003,12,"Formule des murs A_int/A = 1 − tP/A + 4t²/A (= calcul direct) ; rapport brute/habitable",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestPlan::test_formule_murs,"0,8859 ; 1,152","0,8859 ; 1,152",verifie,2026-10-31
5 +CALC-12-004,12,"Conversions : 1 pi² = 0,3048² m² ; 165 m² = 1 776 pi² ; 190,08 m² = 2 046 pi²",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestConversions::test_facteurs,"0,09290304 ; 1 776 ; 2 046","0,09290304 ; 1 776,0 ; 2 046,0",verifie,2026-10-31
6 +CALC-12-005,12,"Prix unitaires implicites du sujet (V = 637 953 $, traite.cas1.predire) selon la convention",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestPrixUnitaires::test_valeurs,"6 712 / 3 989 / 3 866 / 3 356 / 2 715 $/m²","6 712 / 3 989 / 3 866 / 3 356 / 2 715 $/m²",verifie,2026-10-31
7 +CALC-12-006,12,"Prix marginal ∂V/∂S = β V/S avec β = 0,539 (traite.cas1.hedonique)",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestPropagation::test_derivee,"2 084 $/m² ; rapport au prix moyen = β","2 084,25 $/m² ; 0,5391",verifie,2026-10-31
8 +CALC-12-007,12,"Erreur de 5 % sur S : linéaire 17 195 $ (2,695 %) ; exact +17 002 / −17 398 $ ; naïf 31 898 $ ; 10 % : +5,27 / −5,52 %",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestPropagation::test_cinq_pour_cent,"17 195 ; 17 002 ; −17 398 ; 31 898","17 195,07 ; 17 001,60 ; −17 398,19 ; 31 897,64",verifie,2026-10-31
9 +CALC-12-008,12,"Mélange de conventions : sujet brut (190,08) vs habitable (165) → +7,93 % = +50 573 $ ; inverse −7,35 % = −46 858 $",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestPropagation::test_melange_conventions,"+7,93 % ; 50 573 $ ; −7,35 % ; −46 858 $","+7,9274 % ; 50 573,11 $ ; −7,3451 % ; −46 858,45 $",verifie,2026-10-31
10 +CALC-12-009,12,"Ajustement de superficie du comparable G0404 (506 500 $, 160 m²) : +8 472 $ ; avec S erronée 170 m² : −8 086 $ ; erreur −16 558 $ (approx. −17 065 $)",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestPropagation::test_comparable,"8 472 ; −8 086 ; −16 558","8 471,94 ; −8 085,79 ; −16 557,72",verifie,2026-10-31
11 +CALC-12-010,12,"Variance combinée β√(σs² + σc²/n) : 2 %/2 %/5 → 1,181 % = 7 535 $ (part du sujet 83,3 %) ; sujet seul 6 878 $ ; comparables seuls 3 076 $ ; 5 %/2 % → 17 468 $",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestPropagation::test_variance_combinee,"7 535 ; 6 878 ; 3 076 ; 17 468","7 535 ; 6 878 ; 3 076 ; 17 468",verifie,2026-10-31
12 +CALC-12-011,12,"Atténuation : Var(ẽ) = 0,0609 ; sd(ln S) = 0,2487 ; λ(0,10) = 0,859, β̂ moyen 0,465 ; λ(0,20) = 0,604, β̂ 0,328 ; 200 réplications, graine 20261031",analysis/python/cases/ch12_mesurage.py,analysis/python/tests/test_ch12.py::TestAttenuation::test_theorie_et_simulation,"0,0609 ; 0,859/0,465 ; 0,604/0,328","0,06092 ; 0,8590/0,4651 ; 0,6037/0,3280",verifie,2026-10-31
13 +CALC-12-012,12,"Exercices 1, 3, 4, 5, 6 : 1 176 pi² = 109,25 m², 42,67 m ; 87,36 m², 171,52 m², 1,154 ; 2 042,86 $/m², 8 580 $, +8 523/−8 639 $, 15 600 $ ; +7,91 %, 10 447 $, 55 572 $ ; λ = 0,905, 0,488",analysis/python/tests/test_ch12.py,analysis/python/tests/test_ch12.py::TestExercices,voir solutions/ch12.tex,idem,verifie,2026-10-31
added verification/chapters/ch12-claims.csv +20 −0
@@ -0,0 +1,20 @@
1 +id,chapitre,affirmation,source_id,verification,date,statut,notes
2 +CLM-12-001,12,"MEFQ partie 2C bloc *07 : aire au sol (R0701A) = surface brute de la projection au sol de tous les étages et sous-sols ; aire des étages (R0702A) = somme des aires des étages, mezzanines et attiques exclus ; mesures brutes à la face externe des murs ; étage si plancher ≤ 0,4 m sous le niveau moyen du sol ; garage/verrière intégrés dans l'étage ; murs mitoyens exclus du périmètre",SRC-B-009,notes-B.md §9.7 (p. 2C-53/54 lues),2026-10-31,verifie,Pied de page « 2022 » dans le PDF 2026
3 +CLM-12-002,12,"Exemple de mesurage du MEFQ : jumelé 8,53 × 13,41 + 3,05 × 4,87 = 129,2 m² au sol, périmètre 33,5 m, 2 étages = 258,4 m² et 67 m",SRC-B-009,notes-B.md §9.7 ; produit recalculé (114,39 + 14,85 = 129,24),2026-10-31,verifie,
4 +CLM-12-003,12,"Règlement sur le rôle (F-2.1, r. 13) art. 1-2 : consignes du MEFQ obligatoires ; art. 4 : dossier de propriété (2C) ; MEFQ 2026 applicable aux rôles entrant en vigueur le 1er janvier 2027",SRC-B-006;SRC-B-008,notes-B.md §9.7,2026-10-31,verifie,
5 +CLM-12-004,12,"Règlement C-73.2 r. 1 art. 5 (vérifier les renseignements conformément aux usages et règles de l'art) et art. 118 (fiche descriptive : déclarations du propriétaire) ; LCI art. 129.1-129.2 (formulaires obligatoires OACIQ) ; formulaire DV porte sur l'état de l'immeuble, non ses dimensions",SRC-A-024;SRC-A-025;SRC-A-026,notes-A.md §2.4,2026-10-31,verifie,
6 +CLM-12-005,12,"Le courtage résidentiel n'applique pas de norme publique de mesurage des superficies (usages variables ; sous-sol parfois inclus)",,Aucune norme trouvée dans les sources vérifiées (notes-A) ; affirmation formulée comme absence de norme vérifiée,2026-10-31,non_verifie,"Formulé « à la connaissance de l'auteur » ; signalé dans limites"
7 +CLM-12-006,12,"ANSI Z765-2021 : approuvée le 29 mars 2021, publiée par Home Innovation Research Labs ; mesure à la face extérieure finie des murs ; pouce ou 0,1 pi, pi² entier ; SI 0,01 m et 0,1 m² ; aires finies seulement ; hauteur 7 pi (2,13 m), 6 pi 4 po sous poutres, pente : moitié à 7 pi et rien sous 5 pi (1,52 m) ; hors sol = niveaux entièrement au-dessus du sol ; garages et non finis exclus ; ouvertures exclues sauf marches et paliers ; non applicable aux appartements/multifamilial ; volontaire",SRC-12-005,Texte intégral lu dans une copie PDF diffusée par un service d'inscriptions (mlsnow.com) le 2026-10-31 ; page de l'éditeur (HTTP 410) non consultée,2026-10-31,partiellement_verifie,Décrite en toutes lettres ; non citée en bibliographie (clé absente de library.bib)
8 +CLM-12-007,12,"Loi sur les arpenteurs-géomètres (A-23) art. 34 : officier public ; exercice de la profession = arpentages, mesurages, levés, plans, certificats de localisation, calculs de superficies, opérations par méthode directe, photogrammétrique, électronique…",SRC-12-001,Texte refondu lu sur LégisQuébec (à jour au 15 octobre 2026) le 2026-10-31,2026-10-31,verifie,
9 +CLM-12-008,12,"Règlement A-23 r. 10 (norme de pratique relative au certificat de localisation) : art. 2 (document en minute, rapport + plan, opinion sur la situation et la condition du bien-fonds), art. 3 (SI sauf demande du client, équivalence métrique obligatoire), art. 7 (visite des lieux ou validation), art. 9 (éléments du rapport : date du levé, description actualisée avec mesures et contenance, concordance occupation/cadastre/titres, servitudes, bornage, empiétements, bâtiments et dépendances, zone agricole, avis publiés ; par. 6 : concordance avec le plan avant rénovation)",SRC-12-002,Texte refondu lu sur LégisQuébec (à jour au 1er juillet 2026) le 2026-10-31,2026-10-31,verifie,
10 +CLM-12-009,12,"OAGQ : le certificat comprend un rapport et un plan ; un créancier ou un notaire peut exiger un certificat à jour lors de la vente ; le vendeur paie habituellement ; ne doit pas servir à établir les limites sur le terrain (piquetage)",SRC-12-004,Page oagq.qc.ca lue le 2026-10-31 (© OAGQ 2026),2026-10-31,verifie,Aucune durée de validité chiffrée sur la page
11 +CLM-12-010,12,"C.c.Q. art. 1719-1720 : remise des titres et du certificat de localisation que le vendeur possède ; garantie de contenance sauf vente sans égard à la contenance ; art. 3026-3027 : plan cadastral présumé exact, prime sur les titres",SRC-A-001,normes-A.csv NRM-A-051 et NRM-A-065 ; notes-A.md,2026-10-31,verifie,
12 +CLM-12-011,12,"Loi sur le cadastre (C-1) : plan cadastral, immatriculation, rénovation cadastrale (art. 1, 19)",SRC-A-029,notes-A.md §3.1,2026-10-31,verifie,Loi R-3.1 non ouverte
13 +CLM-12-012,12,"Données Québec — rôles : licence CC-BY 4.0, cadastres/noms/adresses caviardés, interdiction de réidentification ; Montréal : unités d'évaluation avec SUPERFICIE_TERRAIN, SUPERFICIE_BATIMENT, ETAGE_HORS_SOL, NOMBRE_LOGEMENT, ANNEE_CONSTRUCTION",SRC-B-012;SRC-B-015,notes-B.md §9.8,2026-10-31,verifie,
14 +CLM-12-013,12,"NPP OEAQ règle 2.4 : tout immeuble évalué doit faire l'objet d'une inspection sauf circonstances exceptionnelles expliquées au rapport ; les NPP ne fixent pas de convention de mesurage",SRC-C-001,notes-C.md (règle 2.4 citée) ; absence de convention : lecture de la structure des NPP (notes-C) sans dépouillement intégral,2026-10-31,partiellement_verifie,La seconde moitié est une absence constatée dans les notes, non une lecture exhaustive
15 +CLM-12-014,12,"1 pi = 0,3048 m exactement ; 1 pi² = 0,09290304 m² ; 1 m² = 10,7639 pi² (arrondi) ; 1 acre = 43 560 pi² = 4 046,856 m²",,Définition du pied international (accord de 1959) ; calcul arithmétique vérifié dans test_ch12.py,2026-10-31,verifie,
16 +CLM-12-015,12,"1 arpent = 180 pieds français ≈ 58,47 m ; 1 arpent carré ≈ 3 418,89 m²",SRC-12-006,Source secondaire (Wikipédia, extrait de recherche) ; page officielle de conversion du gouvernement (appli.foncier.gouv.qc.ca) en HTTP 404 le 2026-10-31,2026-10-31,partiellement_verifie,Présenté comme « usuel, source secondaire » dans le tableau et les limites
17 +CLM-12-016,12,"Sirmans, Macpherson & Zietz (2005) : recension de 125 études ; la superficie habitable est la variable la plus fréquente et la plus robuste ; Khoshnoud et al. (2023) : mise à jour 2005-2022",SRC-D (lib-D),notes-D.md §ch. 12 (clés vérifiées dans library.bib),2026-10-31,verifie,Le nombre « 125 » provient de notes-D
18 +CLM-12-017,12,"Élasticité valeur/superficie du cas 1 = 0,539 (ET HC1 0,013) ; V sujet = 637 953 $ ; σ résiduel 0,083",,traite.cas1.hedonique / predire (données synthétiques),2026-10-31,verifie,Propriété du jeu synthétique
19 +CLM-12-018,12,"Atténuation classique des erreurs sur les variables : plim β̂ = λβ, λ = Var(ẽ)/(Var(ẽ)+σu²) (Wooldridge 2010, chap. 4)",SRC-D (lib-D),Résultat standard ; démonstration dans le chapitre ; simulation test_ch12.py,2026-10-31,verifie,
20 +CLM-12-019,12,"Nuages de points lidar disponibles pour une large partie du Québec habité ; usage pour les rôles et les zones inondables",,Connaissance générale ; aucune source consultée dans ce lot,2026-10-31,non_verifie,Formulé sans chiffre ; à sourcer en harmonisation (MRNF lidar) ou à atténuer
added verification/chapters/ch12-figures.csv +6 −0
@@ -0,0 +1,6 @@
1 +fichier,chapitre,script,donnees,type_donnees,legende_courte,statut
2 +ch12/ch12-fig-conventions-sujet.pdf,12,analysis/python/figures/ch12_figures.py,"data/synthetic/cas1_gatineau_sujet.json;data/synthetic/cas1_gatineau_ventes.csv",synthetique,"Superficie du sujet du cas 1 selon cinq conventions et prix unitaire implicite [données synthétiques]",verifie
3 +ch12/ch12-fig-erreur-valeur.pdf,12,analysis/python/figures/ch12_figures.py,"data/synthetic/cas1_gatineau_ventes.csv;data/synthetic/cas1_gatineau_sujet.json",synthetique,"Valeur du sujet selon la superficie mesurée, tangente, erreur de ±5 %, propagation exacte vs linéaire vs proportionnelle [données synthétiques]",verifie
4 +ch12/ch12-fig-attenuation.pdf,12,analysis/python/figures/ch12_figures.py,data/synthetic/cas1_gatineau_ventes.csv,synthetique,"Atténuation du coefficient de la superficie par une erreur de mesure (théorie et 200 simulations) et aplatissement du gradient prix-taille [données synthétiques]",verifie
5 +ch12/plan-cote-sujet (TikZ),12,manuscript/chapters/ch12.tex,analysis/python/cases/ch12_mesurage.py,schema,"Plan coté reconstitué du sujet du cas 1 : rez-de-chaussée et étage, cotes extérieures et intérieures, trémie [données synthétiques]",verifie
6 +ch12/plan-implantation-sujet (TikZ),12,manuscript/chapters/ch12.tex,analysis/python/cases/ch12_mesurage.py,schema,"Plan d'implantation du sujet sur son lot de 620 m² à la manière d'un certificat de localisation (fictif) [données synthétiques]",verifie
added verification/chapters/ch12-registry.csv +7 −0
@@ -0,0 +1,7 @@
1 +id,type,titre,organisme,version_ou_date,url,date_consultation,statut,utilise_dans,notes
2 +SRC-12-001,loi,"Loi sur les arpenteurs-géomètres (RLRQ, c. A-23)",Québec (LégisQuébec),À jour au 15 octobre 2026,https://www.legisquebec.gouv.qc.ca/fr/document/lc/A-23,2026-10-31,verifie,12,"Art. 34 lu (officier public ; exercice de la profession : arpentages, mesurages, bornages, levés, plans, certificats de localisation, calculs de superficies ; méthodes directe, photogrammétrique, électronique). Page obtenue avec un en-tête User-Agent de navigateur (403 sinon)."
3 +SRC-12-002,reglement,"Règlement sur la norme de pratique relative au certificat de localisation (RLRQ, c. A-23, r. 10)",Québec (LégisQuébec),À jour au 1er juillet 2026 (D. 1058-2002),"https://www.legisquebec.gouv.qc.ca/fr/document/rc/A-23,%20r.%2010",2026-10-31,verifie,12,"Art. 1-9 lus : définition (art. 2), SI (art. 3), opérations (4-6), visite (7), réglementation municipale (8), contenu du rapport (9, par. 1 à 16)."
4 +SRC-12-003,reglement,"Règlement sur la norme de pratique relative au piquetage et à l'implantation (RLRQ, c. A-23, r. 11)",Québec (LégisQuébec),À jour au 1er juillet 2026 (D. 1057-2002),"https://www.legisquebec.gouv.qc.ca/fr/document/rc/A-23,%20r.%2011",2026-10-31,verifie,12,"Art. 1 (définitions implantation/piquetage), 4, 9-10 lus ; cité en toutes lettres pour distinguer piquetage et certificat."
5 +SRC-12-004,page_web,Certificat de localisation,Ordre des arpenteurs-géomètres du Québec,© OAGQ 2026 ; Guide des tarifs suggérés 2026 ; exemple de certificat 2025,https://oagq.qc.ca/services/certificat-de-localisation,2026-10-31,verifie,12,"Rapport + plan ; éléments vérifiés ; « un créancier ou un notaire peut exiger un certificat à jour lors de la vente » ; vendeur paie habituellement ; ne sert pas à établir les limites. Pas de durée de validité chiffrée."
6 +SRC-12-005,norme,"ANSI Z765-2021, Square Footage — Method for Calculating (American National Standard for Single-Family Residential Buildings)",Home Innovation Research Labs (ANSI),Approuvée le 29 mars 2021,https://www.mlsnow.com/pdf/ANSIsqft.pdf,2026-10-31,partiellement_verifie,12,"Copie PDF diffusée par un service d'inscriptions (texte intégral lu : sections 1-4 et annexe) ; page de l'éditeur homeinnovation.com/…/ansi_z765_standard en HTTP 410 ; original non acquis. Norme volontaire, protégée par le droit d'auteur : paraphrasée, non reproduite."
7 +SRC-12-006,page_web,Arpent (unités du Québec : 180 pieds français ; 3 418,89 m²),Wikipédia (extrait de recherche),Consulté le 2026-10-31,https://fr.wikipedia.org/wiki/Arpent,2026-10-31,partiellement_verifie,12,"Source secondaire ; l'outil officiel de conversion (https://appli.foncier.gouv.qc.ca/conversion-de-mesures/) renvoyait HTTP 404 le 2026-10-31."
added verification/chapters/ch12.md +36 −0
@@ -0,0 +1,36 @@
1 +# Chapitre 12 — Mesurage, superficies et plans : bilan de rédaction (2026-10-31)
2 +
3 +## Compteurs
4 +- Prose : ≈ 12 250 mots (regex de `tools/check_manuscript.py` sur `manuscript/chapters/ch12.tex`) ; 34 pages compilées (chapitre + solutions + bibliographie du chapitre).
5 +- Figures : 5 — 3 PDF Matplotlib (`ch12-fig-conventions-sujet.pdf`, `ch12-fig-erreur-valeur.pdf`, `ch12-fig-attenuation.pdf`, toutes synthétiques, cas 1) + 2 schémas TikZ (plan coté RDC/étage du sujet ; plan d'implantation sur le lot à la manière d'un certificat de localisation).
6 +- Tableaux : 8 (conventions comparées ; conversions ; divergences MEFQ/ANSI/ouvrage ; documents porteurs de dimensions ; superficies et prix unitaires du sujet ; sensibilité à l'erreur de superficie ; variance combinée ; atténuation).
7 +- Équations numérotées : 12 (lacet, murs, conversion, modèle, dérivée, delta, exact, dérivée du cas 1, mélange de conventions, ajustement, variance combinée, atténuation) ; 2 définitions, 2 propositions démontrées (propagation ; atténuation), 2 exemples.
8 +- Exercices : 6 (niveaux 1, 1, 2, 2, 3, 3) ; solutions détaillées dans `solutions/ch12.tex`, chiffres testés (`TestExercices`).
9 +- Encadrés : objectifs, synthetique, 2 × quebec (normes de mesurage du MEFQ ; certificat de localisation et contenance), pratique (protocole de mesurage), erreurs, limites, synthese.
10 +- Citations : 24 clés, toutes dans `references/library.bib` (aucune nouvelle clé citée) ; 5 nouvelles entrées non citées dans `references/lib-ch12.bib` (A-23, A-23 r. 10, A-23 r. 11, page OAGQ, ANSI Z765-2021 partiellement vérifiée).
11 +
12 +## Contrôles
13 +- `tools/compile_chapter.sh 12` → `OK ch12 : 34 pages, 0 references/citations non definies, 0 overfull hbox` (après réduction de la police du tableau des conversions et recadrage du plan TikZ).
14 +- `python3 -m pytest -q analysis/python/tests/test_ch12.py` → 15 passed.
15 +- `python3 tools/check_manuscript.py --no-log` : seuls signalements concernant ch12 = les 3 figures PDF « non inscrites dans verification/figures.csv » (fichier global) — attendu, puisque la consigne de phase 4 impose les fichiers par chapitre (`verification/chapters/ch12-figures.csv`) ; à fusionner par l'orchestrateur.
16 +- Scripts : `analysis/python/cases/ch12_mesurage.py` (imprime tous les chiffres cités ; hypothèses du plan déclarées dans `PLAN`) ; `analysis/python/figures/ch12_figures.py` (3 figures, `traite.style`). Aucune modification de `traite/*.py` : le modèle hédonique et la prédiction du sujet viennent de `traite.cas1` (β = 0,539, V = 637 953 $), l'atténuation réutilise `traite.hedonique.mco`.
17 +- PDF relu (planche de contact 34 pages + pages 13-14 des plans TikZ et page du tableau des conversions) : aucun débordement après correction.
18 +- Convention `\cref` sans article : articles et prépositions écrits à la main partout.
19 +
20 +## Cohérence avec les autres chapitres
21 +- Superficie habitable 165 m², empreinte 95,04 m², aire brute des étages 190,08 m² (≈ 190 m² du ch. 21), sous-sol fini 70 m² (poste « planchers flottants » du devis), terrain 620 m² : cohérents avec `cas1_gatineau_sujet.json`, `cas1_gatineau_couts.csv` et le ch. 21.
22 +- Comparable G0404 (506 500 $, 160 m²) et les ajustements multiplicatifs β-dérivés : mêmes conventions que ch. 16/17 (`traite.cas1.ajustements_hedoniques`).
23 +
24 +## Points non vérifiés ou partiellement vérifiés (voir ch12-claims.csv)
25 +1. ANSI Z765-2021 : texte intégral lu dans une copie diffusée par un service d'inscriptions (mlsnow.com) ; page de l'éditeur Home Innovation en HTTP 410 ; décrite en toutes lettres, non citée (CLM-12-006, partiellement_verifie). Les exigences de Fannie Mae/Freddie Mac relayées par des sources secondaires ne sont pas reprises.
26 +2. Absence de norme publique de mesurage dans le courtage (CLM-12-005, non_verifie) : formulée « à la connaissance de l'auteur », signalée dans les limites.
27 +3. Arpent / pied français (CLM-12-015, partiellement_verifie) : source secondaire ; outil officiel de conversion du gouvernement (appli.foncier.gouv.qc.ca) en HTTP 404 le 2026-10-31.
28 +4. « Les NPP ne fixent pas de convention de mesurage » (CLM-12-013, partiellement_verifie) : absence constatée dans notes-C, pas de dépouillement intégral des NPP.
29 +5. Disponibilité du lidar pour une large partie du Québec habité (CLM-12-019, non_verifie) : formulé sans chiffre ; à sourcer (MRNF) ou à atténuer en harmonisation.
30 +6. Plan coté du sujet : reconstitution pédagogique (épaisseur des murs 0,28 m, cloisons 44 m × 0,115 m, trémie 1,00 × 3,40, garage attaché 3,66 × 6,10 hors empreinte) ; la coïncidence de 165,0 m² résulte de ces hypothèses, dit dans les limites. Le garage attaché n'est pas un poste distinct du devis du ch. 21.
31 +7. Pied de page « 2022 » des pages de normes de mesurage dans le PDF 2026 du MEFQ (déjà signalé au ch. 21).
32 +
33 +## Suggestions pour l'harmonisation
34 +- Fusionner `lib-ch12.bib` dans `library.bib` puis remplacer les mentions en toutes lettres (A-23 art. 34, r. 10, OAGQ) par `\parencite{loiArpenteurs}`, `\parencite[art.~2, 9]{reglCertificatLocalisation}`, `\parencite{oagqCertificat}` ; décider si `ansiZ765` (partiellement vérifiée) est admise en bibliographie.
35 +- Le ch. 7 (certificat de localisation, cadastre) peut renvoyer à l'encadré de la §12.4 plutôt que de redécrire la norme r. 10 ; le ch. 14 reprendra la typologie convention / mesure / saisie et l'idée d'instrument (superficie du rôle) ; le ch. 17 peut citer la prop. 12.1 pour justifier l'usage du prix marginal βV/S dans l'ajustement de superficie ; le ch. 22 réutilisera le plan coté (`PLAN` dans `ch12_mesurage.py`) pour le métré.
36 +- Les deux schémas TikZ sont inscrits dans `ch12-figures.csv` avec `type_donnees=schema` et le script `ch12_mesurage.py` comme source des dimensions.
modified verification/claims.csv +44 −0
@@ -448,3 +448,47 @@ CLM-APPC-13,appC,Règle 2.3 élément 10 : justifier le rejet d'une méthode tra
448 448 CLM-APPC-14,appC,Convention de l'ouvrage : réserve incluse dans le RNE de la capitalisation directe ; CAPEX explicites dans le DCF ; composition semestrielle des hypothèques,CONV,manuscript/appendices/conventions-calcul.tex,2026-10-31,verifie,cohérence interne de l'ouvrage (pas une source externe)
449 449 CLM-APPC-15,appC,Identités (C.1)-(C.4) : RBE = RBP(1−v)+autres ; RNE = RBE−DE−RR ; V = RNE/R ; RCD = RNE/SD ; RPV = M0/V,CONV,CONVENTIONS.md §4 et traite/finance,2026-10-31,verifie,notation de l'ouvrage
450 450 CLM-17-14,17,"Taux hypothécaires canadiens à taux fixe : taux nominaux composés semestriellement (Loi sur l'intérêt, L.R.C. 1985, c. I-15, art. 6) ; taux mensuel équivalent (1+i/2)^(1/6) − 1 utilisé dans l'exemple de financement",app:conventions-calcul,manuscript/appendices/conventions-calcul.tex §Taux d'intérêt ; fonction traite.finance.taux_mensuel_canadien,2026-10-31,verifie,exemple de financement pédagogique (montants fictifs)
451 +CLM-11-001,11,"NPP OEAQ règle 2.4 : tout immeuble évalué doit faire l'objet d'une inspection sauf circonstances exceptionnelles expliquées au rapport ; règle 2.3 : douze éléments (identification, étendue du travail, réserves et hypothèses) ; règle 2.1 c) : hypothèses extraordinaires avec incidence",SRC-C-001,notes-C.md (texte lu par le domaine C),2026-10-31,verifie,
452 +CLM-11-002,11,"Norme AIBQ (V03 2011, m.à.j. 2023) : inspection « visuelle et attentive, qui n'est cependant pas techniquement exhaustive » (art. 7), examen des composantes installées facilement accessibles (art. 8) ; exclusions 12.1 (durée de vie résiduelle), 12.2 (coûts), 12.3 (valeur marchande), 12.10 (conformité aux codes) ; 11.4 (substances dangereuses) ; art. 14 (rapport) ; art. 16 (déclarations du vendeur) ; art. 18 (sonder les composantes détériorées)",SRC-11-011,PDF lu le 2026-10-31 (pages 1-10),2026-10-31,verifie,Le titre exact est « Norme de pratique professionnelle pour l'inspection visuelle de bâtiments principalement résidentiels » ; la page HTML de l'AIBQ ne le donne pas.
453 +CLM-11-003,11,"InterNACHI SOP (oct. 2022) : « non-invasive, visual examination of the accessible areas » ; ne détermine ni la valeur marchande (2.1.5) ni l'espérance de vie (2.1.8)",SRC-11-013,Page lue le 2026-10-31 par agent web-fetch,2026-10-31,verifie,
454 +CLM-11-004,11,"Art. 1726 C.c.Q. (vices cachés ; vice apparent = constatable par un acheteur prudent et diligent sans expert) ; art. 1732-1733 (exclusion conventionnelle, vente aux risques et périls) ; Marcoux c. Picard 2008 QCCA 259 par. 20 (« acheteur prudent et diligent et non pas celui de l'expert tatillon ») ; ABB c. Domtar 2007 CSC 50 (existence)",SRC-A-036,notes-A.md §2.1 et §9 (texte de Marcoux lu dans une copie PDF ; ABB non lu : aucune citation textuelle),2026-10-31,verifie,ABB c. Domtar cité pour son existence seulement.
455 +CLM-11-005,11,"Formulaire DV OACIQ obligatoire depuis juillet 2012 (personne physique, < 5 logements, courtier) ; version V24 06/2022, sections D1 à D15 ; annexé à la PA clause 13.1 ; PA clause 8 (inspection), 12.1 (garantie légale) ; DVD pour la copropriété divise",SRC-A-026;SRC-A-027,notes-A.md §2.4 (PDF DV et PA lus),2026-10-31,verifie,
456 +CLM-11-006,11,"Loi 16 (L.Q. 2019, c. 28) vise notamment « l'encadrement des inspections en bâtiment » ; état du règlement d'application au 2026-10-31 non vérifié",SRC-A-015,Intitulé vérifié (library.bib loi16_2019) ; pages RBQ/Québec.ca sur l'inspection en bâtiment introuvables (404) le 2026-10-31,2026-10-31,partiellement_verifie,Formulé prudemment : « l'état du règlement d'application n'a pas pu être vérifié ».
457 +CLM-11-007,11,"Loi sur le bâtiment art. 46 (licence), art. 29 (exclusion unifamiliale et résidentiel < 3 étages ou < 9 logements du chapitre Bâtiment) ; Règlement B-1.1, r. 8 : art. 2 (bâtiments visés), art. 10 (1 an malfaçons, 3 ans vices cachés, 5 ans vices de conception/sol art. 2118), art. 13 (300 000 $ unifamiliale), art. 14 (200 000 $/logement), art. 16 (transférable)",SRC-B-057;SRC-B-058,notes-B.md §44.1,2026-10-31,verifie,
458 +CLM-11-008,11,"Code de construction chapitre I fondé sur le CNB 2020 modifié Québec, en vigueur le 17 avril 2025 (D. 437-2025) ; dispositions antérieures applicables aux travaux débutés avant le 17 octobre 2027 (RBQ 26 août 2026)",SRC-B-053;SRC-B-055,notes-B.md §44.1,2026-10-31,verifie,
459 +CLM-11-009,11,"Radon : ligne directrice 200 Bq/m³ (moyenne annuelle) ; mesures correctives d'ici un an ; mesure à long terme ≥ 91 jours dont 91 en saison de chauffage, au niveau le plus bas normalement occupé",SRC-11-001;SRC-11-002,Pages Santé Canada lues le 2026-10-31,2026-10-31,verifie,
460 +CLM-11-010,11,"Québec : concentration moyenne dans les sous-sols ≈ 37 Bq/m³ ; dépassements jusqu'à > 1 000 Bq/m³ ; dosimètre passif ≥ 3 mois de préférence octobre-avril, ≈ 50 $ ; 2e cause de cancer du poumon après le tabagisme",SRC-11-003,Page Québec.ca lue le 2026-10-31,2026-10-31,verifie,Aucune proportion de maisons québécoises au-dessus de 200 Bq/m³ n'est citée dans le chapitre (non disponible sur les pages officielles consultées ; l'estimation INSPQ « environ 10 % » n'a pas été reprise).
461 +CLM-11-011,11,Moisissures (Santé Canada) : humidité relative 30-50 % ; trois catégories de surface (petite ≤ 3 plaques < 1 m² ; moyenne jusqu'à 3 m² ; grande > 3 m²) ; professionnel si zone importante ; aucune quantité sans risque ; identification de l'espèce généralement inutile,SRC-11-004,Page lue le 2026-10-31,2026-10-31,verifie,
462 +CLM-11-012,11,"Amiante : avant 1990 surtout utilisé pour isoler contre le froid et le bruit ; fabrication/importation/vente/utilisation interdites (règlement de 2018, en vigueur le 30 déc. 2018) ; matériaux anciens pouvant en contenir ; pas de risque important si intact",SRC-11-006,Page Santé Canada lue le 2026-10-31 ; date du 30 déc. 2018 confirmée par la page ECCC « Liste des substances toxiques : amiante »,2026-10-31,verifie,
463 +CLM-11-013,11,"Vermiculite : minerai de la mine de Libby (Montana) 1920-1990, vendu au Canada sous la marque Zonolite Attic Insulation ; supposer la présence d'amiante dans un vieux produit en l'absence de preuve contraire",SRC-11-005,Emprisonnons la chaleur §1.4 (RNCan) lu le 2026-10-31 via extraits de recherche et page « Rénovations écoénergétiques »,2026-10-31,verifie,
464 +CLM-11-014,11,MIUF interdite au Canada depuis décembre 1980 en vertu de la Loi sur les produits dangereux,SRC-11-005,Page RNCan lue le 2026-10-31,2026-10-31,verifie,
465 +CLM-11-015,11,"Pyrite (RBQ) : acide sulfurique par oxydation au contact de l'humidité et de l'oxygène ; soulèvement et fissuration de la dalle ; pyrrhotite : sulfure de fer cristallisant en lamelles, fissures dans le béton",SRC-11-008,Page RBQ lue le 2026-10-31,2026-10-31,verifie,
466 +CLM-11-016,11,"Pratique du marché : échantillonnage du remblai selon un protocole reconnu et indice pétrographique du potentiel de gonflement (IPPG) ; remblais certifiés non gonflants depuis la fin des années 1990 ; secteurs touchés région de Montréal, constructions 1970-1990",,Sources secondaires seulement (ACQC,laboratoires,Centris) ; aucune page gouvernementale trouvée le 2026-10-31,2026-10-31
467 +CLM-11-017,11,"Programme pyrrhotite (SHQ) : mis en œuvre en 2011, terminé le 30 septembre 2026 ; volet I : 75 % des coûts admissibles jusqu'à 75 000 $ (bâtiment non couvert par le plan de garantie) ; condition : fondations endommagées ou taux minimal de 0,23 % de pyrrhotite ; Mauricie et Centre-du-Québec (communiqué 2018 : aussi Laurentides)",SRC-11-010,Pages Québec.ca lues le 2026-10-31 (volet I ; normes du programme PDF SHQ : « mis en œuvre en 2011 »),2026-10-31,verifie,"Période de construction 1996-2008 de la frise = repère indicatif, non vérifié."
468 +CLM-11-018,11,"Ocre ferreuse (RBQ) : réaction chimique (fer → hydroxyde de fer) et processus biologique (ferrobactérie → masse gélatineuse) ; colmatage des drains ; infiltration à la jonction murs-dalle ; sols sablonneux/silteux plus à risque ; dépôts rougeâtres, odeur de soufre ; évaluation du site et cheminées de nettoyage recommandées",SRC-11-007,Page RBQ lue le 2026-10-31,2026-10-31,verifie,
469 +CLM-11-019,11,"Sols argileux (RBQ) : variation de volume (sécheresse), arbres, affaissement des fondations ; vallée du Saint-Laurent, vallée de l'Outaouais, Saguenay–Lac-Saint-Jean",SRC-11-009,Page RBQ lue le 2026-10-31,2026-10-31,verifie,
470 +CLM-11-020,11,RBQ équipements pétroliers : chapitre VIII du Code de construction ; équipements < 10 000 L d'une maison unifamiliale non considérés à risque élevé ; appareils de chauffage relèvent d'une norme d'installation adoptée par renvoi (CSA B139),SRC-11-014,Pages RBQ lues le 2026-10-31 par agent web-fetch,2026-10-31,verifie,
471 +CLM-11-021,11,Chauffez vert terminé le 31 mars 2026 ; programme fédéral de conversion abordable du mazout : date limite 31 juillet 2026 au Québec ; Rénoclimat refondu le 1er octobre 2026,SRC-B-062;SRC-B-066,notes-B.md §44.2,2026-10-31,verifie,
472 +CLM-11-022,11,"Périodes de la frise des pathologies (galvanisé, plomb, boutons et tubes, deux fils, aluminium 1965-1976, polybutylène 1978-1995, pyrite 1970-1999, pyrrhotite 1996-2008, réservoirs 1945-1980, simple vitrage, pare-vapeur) = repères indicatifs de connaissances générales",,Non vérifiées sur source publique ; signalées comme indicatives dans la figure,la légende et les limites,2026-10-31,non_verifie
473 +CLM-11-023,11,"Vies utiles des composantes de l'exemple (bardeaux 20, gouttières 25, fenêtres 30, revêtement 40, peinture 10, bois franc 40, céramique 35, flottant 20, armoires 30, vanités 25, plomberie 50, chauffe-eau 12, électricité 50, VRC 20, thermopompe 15, balcon 25, asphalte 20, paysager 20, vie économique 60) et fractions φ(c)",,Hypothèses pédagogiques déclarées dans le script et le texte,2026-10-31,non_verifie,"Aucune source publique ; déclarées comme hypothèses (encadrés synthetique et limites, tabsource)."
474 +CLM-11-024,11,"Filage d'aluminium, boutons et tubes, polybutylène, galvanisé : incidence sur l'assurabilité et exigences des assureurs",,Connaissance générale de la pratique ; aucune source publique consultée,2026-10-31,non_verifie,"Formulé qualitativement (« les assureurs exigent souvent », « plusieurs assureurs traitent avec réserve »)."
475 +CLM-11-025,11,"Paramètres de la valeur de l'information (dosimètre 50 $ vérifié ; correction radon 3 000 $, test de pyrite 1 200 $, excavation 25 000 $ = paramètres d'illustration)",SRC-11-003,Le 50 $ est lu sur Québec.ca ; les autres montants sont déclarés comme paramètres hypothétiques dans le texte,2026-10-31,partiellement_verifie,
476 +CLM-12-001,12,"MEFQ partie 2C bloc *07 : aire au sol (R0701A) = surface brute de la projection au sol de tous les étages et sous-sols ; aire des étages (R0702A) = somme des aires des étages, mezzanines et attiques exclus ; mesures brutes à la face externe des murs ; étage si plancher ≤ 0,4 m sous le niveau moyen du sol ; garage/verrière intégrés dans l'étage ; murs mitoyens exclus du périmètre",SRC-B-009,notes-B.md §9.7 (p. 2C-53/54 lues),2026-10-31,verifie,Pied de page « 2022 » dans le PDF 2026
477 +CLM-12-002,12,"Exemple de mesurage du MEFQ : jumelé 8,53 × 13,41 + 3,05 × 4,87 = 129,2 m² au sol, périmètre 33,5 m, 2 étages = 258,4 m² et 67 m",SRC-B-009,notes-B.md §9.7 ; produit recalculé (114,39 + 14,85 = 129,24)
478 +CLM-12-003,12,"Règlement sur le rôle (F-2.1, r. 13) art. 1-2 : consignes du MEFQ obligatoires ; art. 4 : dossier de propriété (2C) ; MEFQ 2026 applicable aux rôles entrant en vigueur le 1er janvier 2027",SRC-B-006;SRC-B-008,notes-B.md §9.7,2026-10-31,verifie,
479 +CLM-12-004,12,"Règlement C-73.2 r. 1 art. 5 (vérifier les renseignements conformément aux usages et règles de l'art) et art. 118 (fiche descriptive : déclarations du propriétaire) ; LCI art. 129.1-129.2 (formulaires obligatoires OACIQ) ; formulaire DV porte sur l'état de l'immeuble, non ses dimensions",SRC-A-024;SRC-A-025;SRC-A-026,notes-A.md §2.4,2026-10-31,verifie,
480 +CLM-12-005,12,Le courtage résidentiel n'applique pas de norme publique de mesurage des superficies (usages variables ; sous-sol parfois inclus),,Aucune norme trouvée dans les sources vérifiées (notes-A) ; affirmation formulée comme absence de norme vérifiée,2026-10-31,non_verifie,Formulé « à la connaissance de l'auteur » ; signalé dans limites
481 +CLM-12-006,12,"ANSI Z765-2021 : approuvée le 29 mars 2021, publiée par Home Innovation Research Labs ; mesure à la face extérieure finie des murs ; pouce ou 0,1 pi, pi² entier ; SI 0,01 m et 0,1 m² ; aires finies seulement ; hauteur 7 pi (2,13 m), 6 pi 4 po sous poutres, pente : moitié à 7 pi et rien sous 5 pi (1,52 m) ; hors sol = niveaux entièrement au-dessus du sol ; garages et non finis exclus ; ouvertures exclues sauf marches et paliers ; non applicable aux appartements/multifamilial ; volontaire",SRC-12-005,Texte intégral lu dans une copie PDF diffusée par un service d'inscriptions (mlsnow.com) le 2026-10-31 ; page de l'éditeur (HTTP 410) non consultée,2026-10-31,partiellement_verifie,Décrite en toutes lettres ; non citée en bibliographie (clé absente de library.bib)
482 +CLM-12-007,12,"Loi sur les arpenteurs-géomètres (A-23) art. 34 : officier public ; exercice de la profession = arpentages, mesurages, levés, plans, certificats de localisation, calculs de superficies, opérations par méthode directe, photogrammétrique, électronique…",SRC-12-001,Texte refondu lu sur LégisQuébec (à jour au 15 octobre 2026) le 2026-10-31,2026-10-31,verifie,
483 +CLM-12-008,12,"Règlement A-23 r. 10 (norme de pratique relative au certificat de localisation) : art. 2 (document en minute, rapport + plan, opinion sur la situation et la condition du bien-fonds), art. 3 (SI sauf demande du client, équivalence métrique obligatoire), art. 7 (visite des lieux ou validation), art. 9 (éléments du rapport : date du levé, description actualisée avec mesures et contenance, concordance occupation/cadastre/titres, servitudes, bornage, empiétements, bâtiments et dépendances, zone agricole, avis publiés ; par. 6 : concordance avec le plan avant rénovation)",SRC-12-002,Texte refondu lu sur LégisQuébec (à jour au 1er juillet 2026) le 2026-10-31,2026-10-31,verifie,
484 +CLM-12-009,12,OAGQ : le certificat comprend un rapport et un plan ; un créancier ou un notaire peut exiger un certificat à jour lors de la vente ; le vendeur paie habituellement ; ne doit pas servir à établir les limites sur le terrain (piquetage),SRC-12-004,Page oagq.qc.ca lue le 2026-10-31 (© OAGQ 2026),2026-10-31,verifie,Aucune durée de validité chiffrée sur la page
485 +CLM-12-010,12,"C.c.Q. art. 1719-1720 : remise des titres et du certificat de localisation que le vendeur possède ; garantie de contenance sauf vente sans égard à la contenance ; art. 3026-3027 : plan cadastral présumé exact, prime sur les titres",SRC-A-001,normes-A.csv NRM-A-051 et NRM-A-065 ; notes-A.md,2026-10-31,verifie,
486 +CLM-12-011,12,"Loi sur le cadastre (C-1) : plan cadastral, immatriculation, rénovation cadastrale (art. 1, 19)",SRC-A-029,notes-A.md §3.1,2026-10-31,verifie,Loi R-3.1 non ouverte
487 +CLM-12-012,12,"Données Québec — rôles : licence CC-BY 4.0, cadastres/noms/adresses caviardés, interdiction de réidentification ; Montréal : unités d'évaluation avec SUPERFICIE_TERRAIN, SUPERFICIE_BATIMENT, ETAGE_HORS_SOL, NOMBRE_LOGEMENT, ANNEE_CONSTRUCTION",SRC-B-012;SRC-B-015,notes-B.md §9.8,2026-10-31,verifie,
488 +CLM-12-013,12,NPP OEAQ règle 2.4 : tout immeuble évalué doit faire l'objet d'une inspection sauf circonstances exceptionnelles expliquées au rapport ; les NPP ne fixent pas de convention de mesurage,SRC-C-001,notes-C.md (règle 2.4 citée) ; absence de convention : lecture de la structure des NPP (notes-C) sans dépouillement intégral,2026-10-31,partiellement_verifie,La seconde moitié est une absence constatée dans les notes
489 +CLM-12-014,12,"1 pi = 0,3048 m exactement ; 1 pi² = 0,09290304 m² ; 1 m² = 10,7639 pi² (arrondi) ; 1 acre = 43 560 pi² = 4 046,856 m²",,Définition du pied international (accord de 1959) ; calcul arithmétique vérifié dans test_ch12.py,2026-10-31,verifie,
490 +CLM-12-015,12,"1 arpent = 180 pieds français ≈ 58,47 m ; 1 arpent carré ≈ 3 418,89 m²",SRC-12-006,Source secondaire (Wikipédia,extrait de recherche) ; page officielle de conversion du gouvernement (appli.foncier.gouv.qc.ca) en HTTP 404 le 2026-10-31,2026-10-31,partiellement_verifie
491 +CLM-12-016,12,"Sirmans, Macpherson & Zietz (2005) : recension de 125 études ; la superficie habitable est la variable la plus fréquente et la plus robuste ; Khoshnoud et al. (2023) : mise à jour 2005-2022",SRC-D (lib-D),notes-D.md §ch. 12 (clés vérifiées dans library.bib),2026-10-31,verifie,Le nombre « 125 » provient de notes-D
492 +CLM-12-017,12,"Élasticité valeur/superficie du cas 1 = 0,539 (ET HC1 0,013) ; V sujet = 637 953 $ ; σ résiduel 0,083",,traite.cas1.hedonique / predire (données synthétiques),2026-10-31,verifie,Propriété du jeu synthétique
493 +CLM-12-018,12,"Atténuation classique des erreurs sur les variables : plim β̂ = λβ, λ = Var(ẽ)/(Var(ẽ)+σu²) (Wooldridge 2010, chap. 4)",SRC-D (lib-D),Résultat standard ; démonstration dans le chapitre ; simulation test_ch12.py,2026-10-31,verifie,
494 +CLM-12-019,12,Nuages de points lidar disponibles pour une large partie du Québec habité ; usage pour les rôles et les zones inondables,,Connaissance générale ; aucune source consultée dans ce lot,2026-10-31,non_verifie,Formulé sans chiffre ; à sourcer en harmonisation (MRNF lidar) ou à atténuer
modified verification/figures.csv +10 −0
@@ -86,3 +86,13 @@ ch18/ch18-fig-predit-observe.pdf,18,analysis/python/figures/ch18_figures.py,data
86 86 ch18/ch18-fig-surface.pdf,18,analysis/python/figures/ch18_figures.py,data/synthetic/cas1_gatineau_ventes.csv,synthetique,"Surface du prix prédit selon superficie habitable et âge, autres attributs du sujet [données synthétiques]",verifie
87 87 ch18/ch18-fig-prix-implicites.pdf,18,analysis/python/figures/ch18_figures.py,data/synthetic/cas1_gatineau_ventes.csv,synthetique,"Prix implicites au sujet en dollars avec barres ± 1,96 e.-t. HC1 [données synthétiques]",verifie
88 88 ch18/ch18-fig-indice.pdf,18,analysis/python/figures/ch18_figures.py,data/synthetic/cas1_gatineau_ventes.csv,synthetique,"Indice trimestriel par indicatrices (IC 95 %) vs tendance linéaire, base 2023T1 = 100 [données synthétiques]",verifie
89 +ch11/schema-enveloppe (TikZ),11,manuscript/chapters/ch11.tex,,schema,Coupe schématique de l'enveloppe d'un cottage et points d'attention de l'inspection,verifie
90 +ch11/ch11-fig-frise-pathologies.pdf,11,analysis/python/figures/ch11_frise_pathologies.py,repères indicatifs codés dans le script (FRISE),schema,"Frise indicative des matériaux et pratiques à risque selon la période de mise en place ; dates (v) vérifiées (RNCan, Canada.ca, Québec.ca, RBQ) ; le reste = connaissances générales non sourcées",verifie
91 +ch11/ch11-fig-matrice-age-effectif.pdf,11,analysis/python/figures/ch11_matrice_age_effectif.py,convention déclarée (analysis/python/cases/ch11_inspection.py : age_effectif),schema,"Matrice note d'état × âge chronologique → âge effectif (λ = 0,5, L = 60) et profils par note",verifie
92 +ch11/ch11-fig-fiche-etat.pdf,11,analysis/python/figures/ch11_fiche_etat.py,data/synthetic/cas1_gatineau_couts.csv;data/synthetic/cas1_gatineau_sujet.json,synthetique,"Fiche d'état du sujet du cas 1 : fraction de vie utile consommée par composante, âge chronologique vs âge effectif [données synthétiques]",verifie
93 +ch11/ch11-fig-sensibilite-age-effectif.pdf,11,analysis/python/figures/ch11_fiche_etat.py,data/synthetic/cas1_gatineau_couts.csv,synthetique,Sensibilité de la dépréciation physique totale au poids λ et à la note d'état de la structure [données synthétiques],verifie
94 +ch12/ch12-fig-conventions-sujet.pdf,12,analysis/python/figures/ch12_figures.py,data/synthetic/cas1_gatineau_sujet.json;data/synthetic/cas1_gatineau_ventes.csv,synthetique,Superficie du sujet du cas 1 selon cinq conventions et prix unitaire implicite [données synthétiques],verifie
95 +ch12/ch12-fig-erreur-valeur.pdf,12,analysis/python/figures/ch12_figures.py,data/synthetic/cas1_gatineau_ventes.csv;data/synthetic/cas1_gatineau_sujet.json,synthetique,"Valeur du sujet selon la superficie mesurée, tangente, erreur de ±5 %, propagation exacte vs linéaire vs proportionnelle [données synthétiques]",verifie
96 +ch12/ch12-fig-attenuation.pdf,12,analysis/python/figures/ch12_figures.py,data/synthetic/cas1_gatineau_ventes.csv,synthetique,Atténuation du coefficient de la superficie par une erreur de mesure (théorie et 200 simulations) et aplatissement du gradient prix-taille [données synthétiques],verifie
97 +ch12/plan-cote-sujet (TikZ),12,manuscript/chapters/ch12.tex,analysis/python/cases/ch12_mesurage.py,schema,"Plan coté reconstitué du sujet du cas 1 : rez-de-chaussée et étage, cotes extérieures et intérieures, trémie [données synthétiques]",verifie
98 +ch12/plan-implantation-sujet (TikZ),12,manuscript/chapters/ch12.tex,analysis/python/cases/ch12_mesurage.py,schema,Plan d'implantation du sujet sur son lot de 620 m² à la manière d'un certificat de localisation (fictif) [données synthétiques],verifie
89 99
Menu

Theme