SPB Git forge

spb/uqo-eval

Public
55commits 2branches 0releases
134.4 MBsize
maindefault branch
yesterdaylast push
TypeScript 90.1% JavaScript 3.2% Python 3.2% CSS 1.8% HTML 1.8%
11.8 KB · 304 lines typescript
Raw Blame History
1// UQO Éval — modélisation hédonique : constitution de l'échantillon (ventes réelles ou annonces).2import { getDb, getMarketIndex, municipalityCenter, type UnitRow } from "../db";3import { timeFactor } from "../engine";4import { indexType } from "../estimator";5import { getImmoDb } from "../immoka";6import { typeGroup } from "../listing-types";7import { LIMITS, type HedonicSpec } from "./spec";89export interface Obs {10  id: string;11  price: number;12  date: string; // YYYY-MM-DD13  year: number;14  month: string; // YYYY-MM15  quarter: string; // YYYY-Qn16  lat: number;17  lng: number;18  street: string | null;19  city: string | null;20  aire: number | null;21  terrain: number | null;22  annee: number | null;23  nb_logements: number | null;24  nb_etages: number | null;25  type: string; // unifamilial | condo | plex | indéterminé26  genre: string | null;27  lien: string | null;28  muni: string | null;29  arrond: string | null;30  voisinage: string | null;31  valeur_role: number | null;32  bedrooms: number | null;33  bathrooms: number | null;34  lab: number | null; // estimation du laboratoire comparable au prix (même date)35}3637export interface Sample {38  obs: Obs[];39  available: number; // observations répondant aux filtres avant échantillonnage40  trimmed: number;41  center: { lat: number; lng: number } | null;42}4344const SQFT = 0.092903;4546function timeKeys(date: string) {47  const year = Number(date.slice(0, 4));48  const m = Number(date.slice(5, 7));49  return { year, month: date.slice(0, 7), quarter: `${year}-T${Math.floor((m - 1) / 3) + 1}` };50}5152function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {53  const R = 6371;54  const toRad = (x: number) => (x * Math.PI) / 180;55  const dLat = toRad(lat2 - lat1);56  const dLng = toRad(lng2 - lng1);57  const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;58  return 2 * R * Math.asin(Math.sqrt(a));59}6061function areaClause(spec: HedonicSpec, latCol: string, lngCol: string, muniCol: string, cityCol?: string) {62  const where: string[] = [];63  const args: unknown[] = [];64  let center: { lat: number; lng: number } | null = null;65  if (spec.area.mode === "muni" && spec.area.muni) {66    if (cityCol) {67      where.push(`(${muniCol} = ? COLLATE NOCASE OR ${cityCol} = ? COLLATE NOCASE)`);68      args.push(spec.area.muni, spec.area.muni);69    } else {70      where.push(`${muniCol} = ? COLLATE NOCASE`);71      args.push(spec.area.muni);72    }73    const c = municipalityCenter(spec.area.muni);74    if (c && c.n) center = { lat: c.lat, lng: c.lng };75  } else if (spec.area.mode === "radius" && spec.area.muni) {76    const c = municipalityCenter(spec.area.muni);77    if (!c || !c.n) throw new Error(`municipalité inconnue : ${spec.area.muni}`);78    center = { lat: c.lat, lng: c.lng };79    const r = Math.min(Math.max(spec.area.radiusKm ?? 10, 1), 150);80    const dLat = r / 111;81    const dLng = r / (111 * Math.cos((c.lat * Math.PI) / 180));82    where.push(`${latCol} BETWEEN ? AND ? AND ${lngCol} BETWEEN ? AND ?`);83    args.push(c.lat - dLat, c.lat + dLat, c.lng - dLng, c.lng + dLng);84  }85  return { where, args, center };86}8788const clampN = (n: number) => Math.min(Math.max(Math.round(n) || 1000, LIMITS.minN), LIMITS.maxN);8990/* --------------------------------- ventes --------------------------------- */91interface SaleRow {92  id: string;93  date: string;94  price: number;95  lat: number;96  lng: number;97  ttype: string | null;98  street: string | null;99  city: string | null;100  aire: number | null;101  terrain: number | null;102  annee: number | null;103  nb_logements: number | null;104  nb_etages: number | null;105  genre: string | null;106  lien: string | null;107  muni: string | null;108  arrond: string | null;109  voisinage: string | null;110  valeur_role: number | null;111  est_2026: number | null;112}113114function sampleSales(spec: HedonicSpec): Sample {115  const d = getDb();116  const n = clampN(spec.maxN);117  const where = ["t.amount BETWEEN ? AND ?", "t.lat IS NOT NULL", "t.lng IS NOT NULL"];118  const args: unknown[] = [spec.price.min, spec.price.max];119  const from = /^\d{4}-\d{2}$/.test(spec.period.from) ? `${spec.period.from}-01` : "2021-01-01";120  const to = /^\d{4}-\d{2}$/.test(spec.period.to) ? `${spec.period.to}-31` : "2099-12-31";121  where.push("t.date BETWEEN ? AND ?");122  args.push(from, to);123  if (spec.types.length) {124    where.push(`t.property_type IN (${spec.types.map(() => "?").join(",")})`);125    args.push(...spec.types);126  }127  const ac = areaClause(spec, "t.lat", "t.lng", "u.municipalite");128  where.push(...ac.where);129  args.push(...ac.args);130  const base = `FROM transactions t JOIN units u ON u.id_provinc = t.id_provinc WHERE ${where.join(" AND ")}`;131  const available = (d.prepare(`SELECT COUNT(*) AS n ${base}`).get(...args) as { n: number }).n;132  // ordre pseudo-aléatoire déterministe (graine) → échantillon reproductible133  const seed = Math.abs(Math.round(spec.seed)) % 1000003;134  const over = spec.area.mode === "radius" ? 3 : 1.2; // la bbox du rayon est filtrée ensuite135  const rows = d136    .prepare(137      `SELECT t.id, t.date, t.amount AS price, t.lat, t.lng, t.property_type AS ttype, t.street, t.city,138              u.aire_etages_m2 AS aire, u.superficie_terrain_m2 AS terrain, u.annee_construction AS annee,139              u.nb_logements, u.nb_etages, u.genre_construction AS genre, u.lien_physique AS lien,140              u.municipalite AS muni, u.arrond, u.unite_voisinage AS voisinage, u.valeur_role, u.est_2026141       ${base}142       ORDER BY ((t.rowid * 2654435761 + ${seed}) % 4294967296) LIMIT ?`143    )144    .all(...args, Math.round(n * over * (1 + spec.price.trimPct / 50))) as SaleRow[];145146  const idx = new Map<string, { month: string; idx: number }[]>();147  const idxFor = (t: string) => {148    const k = indexType(t === "condo" ? "condo_ou_multi" : t);149    if (!idx.has(k)) idx.set(k, getMarketIndex(k));150    return idx.get(k)!;151  };152153  let obs: Obs[] = rows.map((r) => {154    const tk = timeKeys(r.date);155    const type = r.ttype && r.ttype !== "" ? r.ttype : "indéterminé";156    const tf = r.est_2026 ? timeFactor(tk.month, idxFor(type)) : 1;157    return {158      id: r.id,159      price: r.price,160      date: r.date,161      ...tk,162      lat: r.lat,163      lng: r.lng,164      street: r.street,165      city: r.city,166      aire: r.aire && r.aire > 10 ? r.aire : null,167      terrain: r.terrain && r.terrain > 10 ? r.terrain : null,168      annee: r.annee && r.annee > 1600 && r.annee <= tk.year ? r.annee : null,169      nb_logements: r.nb_logements,170      nb_etages: r.nb_etages,171      type,172      genre: r.genre || null,173      lien: r.lien || null,174      muni: r.muni,175      arrond: r.arrond || null,176      voisinage: r.voisinage || null,177      valeur_role: r.valeur_role && r.valeur_role > 1000 ? r.valeur_role : null,178      bedrooms: null,179      bathrooms: null,180      lab: r.est_2026 && tf > 0 ? r.est_2026 / tf : null,181    };182  });183  if (spec.area.mode === "radius" && ac.center) {184    const r = Math.min(Math.max(spec.area.radiusKm ?? 10, 1), 150);185    obs = obs.filter((o) => haversineKm(o.lat, o.lng, ac.center!.lat, ac.center!.lng) <= r);186  }187  return finalize(obs, n, spec, available, ac.center);188}189190/* -------------------------------- annonces -------------------------------- */191interface ListingRowLite {192  id: string;193  price: number;194  first_seen: number | null;195  lat: number;196  lng: number;197  ptype: string | null;198  bedrooms: number | null;199  bathrooms: number | null;200  area_sqft: number | null;201  lot_sqft: number | null;202  year_built: number | null;203  street: string | null;204  city: string | null;205  lab: number | null;206  unit_id: string | null;207  muni_e: string | null;208}209210const LIVE = "l.status = 'a-vendre' AND l.published = 1 AND l.active = 1 AND l.dup_hidden = 0 AND l.price > 0";211212function sampleListings(spec: HedonicSpec): Sample {213  const im = getImmoDb();214  const vp = getDb();215  const n = clampN(spec.maxN);216  const where = [LIVE, "l.price BETWEEN ? AND ?", "l.lat IS NOT NULL", "l.lng IS NOT NULL"];217  const args: unknown[] = [spec.price.min, spec.price.max];218  const ac = areaClause(spec, "l.lat", "l.lng", "e.municipalite", "l.city");219  where.push(...ac.where);220  args.push(...ac.args);221  const base = `FROM listings l LEFT JOIN uqo_eval e ON e.uid = l.uid WHERE ${where.join(" AND ")}`;222  const seed = Math.abs(Math.round(spec.seed)) % 1000003;223  const rows = im224    .prepare(225      `SELECT l.uid AS id, l.price, l.first_seen, l.lat, l.lng, l.property_type AS ptype, l.bedrooms, l.bathrooms,226              l.area_sqft, l.lot_sqft, l.year_built, l.address AS street, l.city, e.est AS lab, e.unit_id, e.municipalite AS muni_e227       ${base} ORDER BY ((l.rowid * 2654435761 + ${seed}) % 4294967296)`228    )229    .all(...args) as ListingRowLite[];230231  // types : groupe Immo-Ka → clé du moteur232  const toType = (pt: string | null): string => {233    const g = typeGroup(pt);234    if (g === "maison" || g === "chalet" || g === "mobile" || g === "fermette") return "unifamilial";235    if (g === "condo") return "condo";236    if (g === "plex") return "plex";237    return "indéterminé";238  };239  const wanted = new Set(spec.types.length ? spec.types : ["unifamilial", "condo", "plex"]);240  let pool = rows.filter((r) => wanted.has(toType(r.ptype)));241  if (spec.area.mode === "radius" && ac.center) {242    const r = Math.min(Math.max(spec.area.radiusKm ?? 10, 1), 150);243    pool = pool.filter((o) => haversineKm(o.lat, o.lng, ac.center!.lat, ac.center!.lng) <= r);244  }245  const available = pool.length;246  const take = pool.slice(0, Math.round(n * 1.2 * (1 + spec.price.trimPct / 50)));247248  const getUnit = vp.prepare("SELECT * FROM units WHERE id_provinc = ?");249  const nowISO = new Date().toISOString().slice(0, 10);250  const obs: Obs[] = take.map((r) => {251    const u = r.unit_id ? (getUnit.get(r.unit_id) as UnitRow | undefined) : undefined;252    const date = r.first_seen ? new Date(r.first_seen * 1000).toISOString().slice(0, 10) : nowISO;253    const tk = timeKeys(date);254    const aireL = r.area_sqft && r.area_sqft > 200 && r.area_sqft < 20000 ? r.area_sqft * SQFT : null;255    const terrL = r.lot_sqft && r.lot_sqft > 100 ? r.lot_sqft * SQFT : null;256    const annee = u?.annee_construction && u.annee_construction > 1600 ? u.annee_construction : r.year_built && r.year_built > 1600 && r.year_built <= tk.year ? r.year_built : null;257    return {258      id: r.id,259      price: r.price,260      date,261      ...tk,262      lat: r.lat,263      lng: r.lng,264      street: r.street,265      city: r.city,266      aire: u?.aire_etages_m2 && u.aire_etages_m2 > 10 ? u.aire_etages_m2 : aireL,267      terrain: u?.superficie_terrain_m2 && u.superficie_terrain_m2 > 10 ? u.superficie_terrain_m2 : terrL,268      annee,269      nb_logements: u?.nb_logements ?? null,270      nb_etages: u?.nb_etages ?? null,271      type: toType(r.ptype),272      genre: u?.genre_construction || null,273      lien: u?.lien_physique || null,274      muni: r.muni_e ?? u?.municipalite ?? r.city,275      arrond: u?.arrond || null,276      voisinage: u?.unite_voisinage || null,277      valeur_role: u?.valeur_role && u.valeur_role > 1000 ? u.valeur_role : null,278      bedrooms: r.bedrooms,279      bathrooms: r.bathrooms,280      lab: r.lab,281    };282  });283  return finalize(obs, n, spec, available, ac.center);284}285286/** Élagage des queues de prix puis plafond n. */287function finalize(obs: Obs[], n: number, spec: HedonicSpec, available: number, center: Sample["center"]): Sample {288  let trimmed = 0;289  const p = Math.min(Math.max(spec.price.trimPct, 0), 10) / 100;290  if (p > 0 && obs.length > 50) {291    const prices = obs.map((o) => o.price).sort((a, b) => a - b);292    const lo = prices[Math.floor(prices.length * p)];293    const hi = prices[Math.min(prices.length - 1, Math.ceil(prices.length * (1 - p)))];294    const before = obs.length;295    obs = obs.filter((o) => o.price >= lo && o.price <= hi);296    trimmed = before - obs.length;297  }298  return { obs: obs.slice(0, n), available, trimmed, center };299}300301export function buildSample(spec: HedonicSpec): Sample {302  return spec.source === "listings" ? sampleListings(spec) : sampleSales(spec);303}304