"""Transparent cost-approach engine for WP20. V_cost = L + (RCN_hard × (1 + soft) × (1 + EI) + S) × (1 − D) All parameters come from configs/cost_schedule.yaml; every function is pure and vectorised over a DataFrame of property-level inputs so that sensitivity analyses and Monte Carlo draws can override any parameter. Inputs expected per property (property-level table built by src/extraction/aggregate.py): floor_area_m2 (ADMIN), storeys (str '1'…'3'), attached ('detached'|'semi_detached'|'row_or_townhouse'), grade ('basic'…'luxury'), condition (1–5 float), renovation ('none_apparent'|'partial'|'extensive'|'unsure'), basement ('finished'|'partially_finished'|'unfinished'), garage ('attached'|'detached'|'carport'|'none'), pool ('inground'|'above_ground'|'none'), age (years at 2026), cma, roll_land_value, roll_market_date, roll_building_value. """ from __future__ import annotations import json from pathlib import Path import numpy as np import pandas as pd import yaml ROOT = Path(__file__).resolve().parents[2] CFG = yaml.safe_load(open(ROOT / "configs" / "cost_schedule.yaml")) SQFT_PER_M2 = 10.7639 # ----------------------------------------------------------------------------- indices def load_bcpi() -> pd.DataFrame: """StatCan 18-10-0289 single-detached composite series by CMA key → DataFrame[refPer, series, value].""" raw = json.load(open(ROOT / "data" / "raw" / "statcan" / "bcpi_18100289_raw.json")) inv = {v: k for k, v in CFG["temporal_regional"]["statcan_vectors"].items()} rows = [] for s in raw: o = s["object"] key = inv.get(int(o["vectorId"])) if key is None: continue for p in o["vectorDataPoint"]: if p["value"] is not None: rows.append({"refPer": p["refPer"], "series": key, "value": float(p["value"])}) return pd.DataFrame(rows) def bcpi_factor(cma: pd.Series, from_date: pd.Series, to_quarter: str | None = None) -> pd.Series: """Ratio BCPI(to_quarter)/BCPI(quarter of from_date) for the CMA's series (composite when the CMA has none).""" b = load_bcpi() to_q = to_quarter or CFG["valuation_quarter"] series_of = CFG["temporal_regional"]["cma_to_series"] lut = {(r.series, r.refPer): r.value for r in b.itertuples()} def q_of(d): d = pd.Timestamp(d) return f"{d.year}-{(d.month - 1) // 3 * 3 + 1:02d}-01" out = [] for c, d in zip(cma, from_date): s = series_of.get(c, "composite") q = q_of(d) num = lut.get((s, to_q)) or lut.get(("composite", to_q)) den = lut.get((s, q)) or lut.get(("composite", q)) out.append(num / den if (num and den) else np.nan) return pd.Series(out, index=cma.index) def land_index_factor(roll_market_date: pd.Series, share: float | None = None) -> pd.Series: """Vrai Prix single-family market index ratio from the roll month to the last available month, blended with 1 by `share`.""" mi = pd.read_csv(ROOT / "data" / "processed" / "market_index.csv") mi = mi[mi.type_prop == "unifamilial"].set_index("month")["idx"] last = mi.iloc[-1] share = CFG["land"]["index_share_applied"] if share is None else share months = pd.to_datetime(roll_market_date).dt.strftime("%Y-%m") f = months.map(lambda m: last / mi.get(m, mi.iloc[0])) return 1.0 + share * (f - 1.0) # ----------------------------------------------------------------------------- RCN def anchor(p: dict | None = None) -> dict: p = p or {} return CFG["rcn"]["anchors"][p.get("anchor", CFG["rcn"]["anchor_default"])] def rcn_hard(df: pd.DataFrame, p: dict | None = None) -> pd.Series: """Hard replacement cost new (CAD, valuation quarter) before soft costs and incentive.""" p = p or {} r = CFG["rcn"] a = anchor(p) grade_cost = df["grade"].map(p.get("grade_per_sqft", a["grade_per_sqft"])).fillna(a["grade_per_sqft"]["standard"]) storey_mult = df["storeys"].astype(str).map(p.get("storey_multiplier", a["storey_multiplier"])).fillna(1.0) + p.get("storey_shift", 0.0) attached_mult = df["attached"].map(r["attached_multiplier"]).fillna(1.0) area_sqft = df["floor_area_m2"] * SQFT_PER_M2 base = grade_cost * storey_mult * attached_mult * area_sqft # temporal update of the published band to the valuation quarter (Montréal series) and regional level factor base_q = a["reference_quarter"] tfac = bcpi_factor(pd.Series("montreal", index=df.index), pd.Series(base_q, index=df.index)).fillna(1.0) level = p.get("regional_level_factor", CFG["temporal_regional"]["regional_level_factor"]) if isinstance(level, pd.Series): base = base * tfac * level else: base = base * tfac * level addon_scale = 1.0 + p.get("add_on_shift", 0.0) bsmt_sqft = area_sqft / df["storeys"].astype(str).map({"1": 1, "1.5": 1.5, "2": 2, "2.5": 2.5, "3": 3}).fillna(1.0) bsmt = df["basement"].map(r["basement_finish_per_sqft"]).fillna(0.0) * bsmt_sqft garage = df["garage"].map(r["garage"]).fillna(0.0) return base + addon_scale * (bsmt + garage) def site_improvements(df: pd.DataFrame, rcn: pd.Series, p: dict | None = None) -> pd.Series: p = p or {} s = CFG["site_improvements"] share = p.get("site_share", s["baseline_share_of_rcn"]) pool = df["pool"].map(s["pool"]).fillna(0.0) * (1.0 + p.get("pool_shift", 0.0)) return share * rcn + pool # ----------------------------------------------------------------------------- depreciation def effective_age(df: pd.DataFrame, p: dict | None = None) -> pd.Series: p = p or {} d = CFG["depreciation"] strength = p.get("condition_strength", 1.0) cond = df["condition"].clip(1, 5) # interpolate the factor table on a continuous condition score keys = np.array(sorted(d["condition_factor"])) vals = np.array([d["condition_factor"][k] for k in keys]) f = np.interp(cond, keys, vals) f = 1.0 + strength * (f - 1.0) eff = df["age"].clip(lower=0) * f eff = eff - df["renovation"].map({"extensive": d["renovation_extensive_years_removed"], "partial": d["renovation_partial_years_removed"]}).fillna(0.0) return eff.clip(lower=d["min_effective_age"]) def depreciation(df: pd.DataFrame, p: dict | None = None) -> pd.Series: p = p or {} d = CFG["depreciation"] life = p.get("economic_life", d["economic_life_years"]) phys = (effective_age(df, p) / life).clip(upper=d["max_physical"]) ext = p.get("external_obsolescence", 0.0) return 1.0 - (1.0 - phys) * (1.0 - ext) # ----------------------------------------------------------------------------- full indication def cost_indication(df: pd.DataFrame, p: dict | None = None) -> pd.DataFrame: """Return a DataFrame with L, RCN_hard, RCN (incl. soft+EI), S, D, V_cost, improvements_depreciated.""" p = p or {} r = CFG["rcn"] hard = rcn_hard(df, p) soft = p.get("soft_cost_share", r["soft_cost_share"]) ei = p.get("entrepreneurial_incentive", r["entrepreneurial_incentive"]) rcn = hard * (1.0 + soft) * (1.0 + ei) S = site_improvements(df, hard, p) D = depreciation(df, p) L = df["roll_land_value"] * land_index_factor(df["roll_market_date"], p.get("land_index_share")) impr = (rcn + S) * (1.0 - D) out = pd.DataFrame({"L": L, "RCN_hard": hard, "RCN": rcn, "S": S, "D": D, "effective_age": effective_age(df, p), "improvements_depreciated": impr, "V_cost": L + impr}, index=df.index) # assessor benchmark: roll building value indexed with BCPI from the roll market date to the valuation quarter out["assessor_building_indexed"] = df["roll_building_value"] * bcpi_factor(df["cma"], df["roll_market_date"]) return out