# Author: Simon-Pierre Boucher — contact@spboucher.ai """Parsers for the semi-structured raw MLS fields. The raw ``listings`` table stores every attribute as free text. These functions harmonise them to numeric analysis variables: - bedrooms reported as ``"3 + 1"`` (main + lower level) are summed; - living area is taken from the explicit floor-area measurement (``building.floor_area_measurements``), using the upper bound of banded entries such as ``"1100-1500 sqft"``, and falls back to ``building.size_interior``; square feet are converted at 1 ft^2 = 0.0929 m^2; - lot size is recovered from the free-text ``land.size_total`` where a numeric value with a recognisable unit (sqft, m2, acres, hectares, or frontage x depth in feet) is present. NOTE — reconstruction: the original cleaning code lived in the upstream RE_DB_QC pipeline, which no longer exists. These rules were reverse-engineered from the description in the paper's data section and validated against the original sample counts, summary statistics and regression estimates (see AUDIT.md, "Reproduction verification"). """ import re import numpy as np from .config import SQFT_TO_M2, ACRE_TO_M2, HA_TO_M2 _RANGE = re.compile(r"([\d.,]+)\s*-\s*([\d.,]+)") _NUMBER = re.compile(r"([\d.,]+)") _UNFORMATTED = re.compile(r'"area_unformatted":\s*"([^"]+)"') _SIZE_WITH_UNIT = re.compile(r"([\d.,]+)\s*(\w*)") _LOT_UNIT = re.compile(r"([\d.,]+)\s*(sqft|sq ft|m2|ac|acre|acres|hectare|ha)\b") _LOT_UNDER = re.compile(r"under\s+([\d/.]+)\s*acre") _LOT_ACRE_RANGE = re.compile(r"([\d/.]+)\s*-\s*([\d.]+)\s*acres") _LOT_DIMS = re.compile(r"(\d+(?:\.\d+)?)\s*x\s*(\d+(?:\.\d+)?)") def _to_float(text: str) -> float: return float(text.replace(",", "")) def parse_bedrooms(value) -> float: """Sum the integer components of a bedroom string ('3 + 1' -> 4).""" if not isinstance(value, str): return np.nan parts = re.findall(r"\d+", value) return float(sum(int(p) for p in parts)) if parts else np.nan def parse_count(value) -> float: """Parse a plain numeric count field (bathrooms, storeys, parking).""" try: return float(value) except (TypeError, ValueError): return np.nan def parse_floor_area(value) -> float: """Living area in m^2 from ``building.floor_area_measurements``. Banded entries ("1100-1500 sqft") are mapped to the upper bound of the band; exact entries ("1698 sqft") are used as reported. """ if not isinstance(value, str): return np.nan match = _UNFORMATTED.search(value) if not match: return np.nan text = match.group(1) band = _RANGE.match(text) if band: area = _to_float(band.group(2)) else: number = _NUMBER.match(text) if not number: return np.nan area = _to_float(number.group(1)) return area if "m2" in text else area * SQFT_TO_M2 def parse_size_interior(value) -> float: """Living area in m^2 from ``building.size_interior`` ('102.19 m2', '1698 sqft').""" if not isinstance(value, str): return np.nan match = _SIZE_WITH_UNIT.match(value) if not match: return np.nan area = _to_float(match.group(1)) unit = match.group(2).lower() return area if unit == "m2" else area * SQFT_TO_M2 # unitless values are sqft def parse_lot(value) -> float: """Lot area in m^2 from the free-text ``land.size_total`` field. Recognises ' sqft|m2|ac|acres|hectare|ha', 'under acre(s)', ' - acres' (upper bound), and ' x ' frontage-by-depth in feet. Returns NaN when no numeric value can be recovered. """ if not isinstance(value, str): return np.nan text = value.lower().strip() match = _LOT_UNIT.match(text) if match: size = _to_float(match.group(1)) unit = match.group(2) if unit in ("sqft", "sq ft"): return size * SQFT_TO_M2 if unit == "m2": return size if unit.startswith("ac"): return size * ACRE_TO_M2 return size * HA_TO_M2 match = _LOT_UNDER.match(text) if match: frac = match.group(1) if "/" in frac: num, den = frac.split("/", 1) acres = float(num) / float(den) else: acres = float(frac) return acres * ACRE_TO_M2 match = _LOT_ACRE_RANGE.match(text) if match: return float(match.group(2)) * ACRE_TO_M2 match = _LOT_DIMS.match(text) if match: return float(match.group(1)) * float(match.group(2)) * SQFT_TO_M2 return np.nan # Consolidation of raw dwelling types into the eight groups used in the paper BUILDING_TYPE_MAP = { "House": "House", "Apartment": "Apartment", "Row / Townhouse": "Row/Townhouse", "Duplex": "Duplex", "Triplex": "Triplex", "Fourplex": "Fourplex", "Manufactured Home": "Manufactured", "Manufactured Home/Mobile": "Manufactured", "Mobile Home": "Manufactured", "Park Model Mobile Home": "Manufactured", } def consolidate_building_type(value) -> str: """Map the raw ``building.type`` to the paper's eight dwelling-type groups.""" return BUILDING_TYPE_MAP.get(value, "Other") def consolidate_ownership(value) -> str: """Map the raw ``ownership.type`` to the paper's ownership-form groups.""" if not isinstance(value, str): return "Unknown" text = value.lower() if "lease" in text: return "Leasehold" if "condo" in text or "strata" in text: return "Condo/Strata" if "freehold" in text: return "Freehold" if "co-op" in text or "cooperative" in text or "co-ownership" in text: return "Co-op/Co-ownership" return "Other"