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.9 KB · 401 lines typescript
Raw Blame History
1// UQO Éval — laboratoire pédagogique d'évaluation immobilière2// Service d'estimation : relie la base SQLite au moteur (engine.ts).3import {4  getCandidates,5  getMarketIndex,6  getUnit,7  municipalityCenter,8  municipalityPercentile,9  type TxRow,10  type UnitRow,11} from "./db";12import {13  estimate,14  haversineM,15  type CompInput,16  type EstimateResult,17  type Subject,18} from "./engine";1920export interface UnitSpecs {21  cubf: number | null;22  cubfLibelle: string | null;23  anneeEstimee: string | null;24  frontTerrainM: number | null;25  nbEtages: number | null;26  nbLocauxNonResid: number | null;27  nbChambresLocatives: number | null;28  lienPhysique: string | null;29  genreConstruction: string | null;30  matricule: string | null;31  uniteVoisinage: string | null;32  nAdresses: number | null;33  datCondMarche: string | null;34  valeurTerrain: number | null;35  valeurBatiment: number | null;36  valeurAnterieure: number | null;37  arrond: string | null;38  apt: string | null;39}4041/** Ventes réelles des 12 derniers mois autour du sujet (rayon adaptatif). */42export interface SectorStats {43  nSales12m: number;44  medianPrice: number | null;45  medianPpm2: number | null;46  radiusKm: number;47}4849/** Contexte marché affiché avec l'estimation — même transparence que le calcul. */50export interface MarketContext {51  /** Type de propriété du sujet (clé i18n). */52  typeProp: string;53  /** Indice de marché mensuel du type (1.0 = niveau courant). */54  index: { month: string; idx: number }[];55  index12mPct: number | null;56  indexSince2021Pct: number | null;57  sector: SectorStats | null;58  /** % des propriétés de la municipalité valant moins que le sujet. */59  muniPercentile: number | null;60  muniTotal: number | null;61}6263export interface UnitEstimate {64  unit: {65    id: string;66    adresse: string | null;67    municipalite: string | null;68    typeProp: string;69    cubfLibelle: string | null;70    anneeConstruction: number | null;71    aireEtagesM2: number | null;72    superficieTerrainM2: number | null;73    nbLogements: number | null;74    valeurRole: number | null;75    lat: number;76    lng: number;77    history: { year: number; value: number | null }[];78    specs: UnitSpecs;79  } | null;80  result: EstimateResult;81  context?: MarketContext;82}8384/** Type de comparable attendu pour le type de tx du marché de l'indice. */85export function indexType(typeProp: string): string {86  if (typeProp === "condo_ou_multi") return "condo";87  if (typeProp === "plex") return "plex";88  return "unifamilial";89}9091export function toComps(rows: TxRow[]): CompInput[] {92  return rows.map((r) => ({93    id: r.id,94    date: r.date,95    amount: r.amount,96    lat: r.lat,97    lng: r.lng,98    propertyType: r.property_type,99    yearBuilt: r.year_built,100    floorArea: r.floor_area,101    street: r.street,102    city: r.city,103  }));104}105106function nowISO(): string {107  return new Date().toISOString().slice(0, 10);108}109110function since(months: number): string {111  const d = new Date();112  d.setMonth(d.getMonth() - months);113  return d.toISOString().slice(0, 10);114}115116export function candidatesAround(lat: number, lng: number): TxRow[] {117  // ~2,2 km puis élargit à ~11 km si marché mince118  for (const halfDeg of [0.02, 0.1]) {119    const rows = getCandidates(lat, lng, halfDeg, since(30), 600);120    if (rows.length >= 30) return rows;121  }122  return getCandidates(lat, lng, 0.25, since(36), 600);123}124125function median(xs: number[]): number | null {126  if (!xs.length) return null;127  const s = [...xs].sort((a, b) => a - b);128  const m = Math.floor(s.length / 2);129  return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;130}131132/** Stats du secteur à partir des candidats déjà chargés (aucune requête en plus). */133function sectorStats(lat: number, lng: number, rows: TxRow[]): SectorStats | null {134  const cutoff = since(12);135  const recent = rows.filter((r) => r.date >= cutoff);136  for (const radiusKm of [2, 5, 10]) {137    const within = recent.filter(138      (r) => haversineM(lat, lng, r.lat, r.lng) <= radiusKm * 1000139    );140    if (within.length >= 8 || radiusKm === 10) {141      if (within.length === 0) return null;142      return {143        nSales12m: within.length,144        medianPrice: median(within.map((r) => r.amount)),145        medianPpm2: median(146          within147            .filter((r) => r.floor_area != null && r.floor_area > 20)148            .map((r) => r.amount / r.floor_area!)149        ),150        radiusKm,151      };152    }153  }154  return null;155}156157function buildContext(158  typeProp: string,159  lat: number,160  lng: number,161  candidates: TxRow[],162  index: { month: string; idx: number }[],163  municipalite: string | null,164  estimateValue: number165): MarketContext {166  const last = index.length ? index[index.length - 1].idx : null;167  const yearAgo = index.length >= 13 ? index[index.length - 13].idx : null;168  const first = index.length >= 2 ? index[0].idx : null;169  const pr =170    municipalite && Number.isFinite(estimateValue) && estimateValue > 0171      ? municipalityPercentile(municipalite, estimateValue)172      : null;173  return {174    typeProp,175    index,176    index12mPct:177      last != null && yearAgo != null && yearAgo > 0178        ? (last / yearAgo - 1) * 100179        : null,180    indexSince2021Pct:181      last != null && first != null && first > 0 ? (last / first - 1) * 100 : null,182    sector: sectorStats(lat, lng, candidates),183    muniPercentile: pr ? (pr.below / pr.total) * 100 : null,184    muniTotal: pr ? pr.total : null,185  };186}187188export function estimateByUnitId(id: string): UnitEstimate | null {189  const u = getUnit(id);190  if (!u) return null;191  const subject: Subject = {192    lat: u.lat,193    lng: u.lng,194    typeProp: u.type_prop,195    floorArea: u.aire_etages_m2,196    yearBuilt: u.annee_construction,197    landArea: u.superficie_terrain_m2,198    modelEstimate: u.est_2026,199    modelP10: u.p10,200    modelP90: u.p90,201  };202  const candidates = candidatesAround(u.lat, u.lng);203  const index = getMarketIndex(indexType(u.type_prop));204  const result = estimate(subject, toComps(candidates), index, nowISO());205  const context = buildContext(206    u.type_prop,207    u.lat,208    u.lng,209    candidates,210    index,211    u.municipalite,212    result.estimate213  );214  return {215    context,216    unit: {217      id: u.id_provinc,218      adresse: u.adresse,219      municipalite: u.municipalite,220      typeProp: u.type_prop,221      cubfLibelle: u.cubf_libelle,222      anneeConstruction: u.annee_construction,223      aireEtagesM2: u.aire_etages_m2,224      superficieTerrainM2: u.superficie_terrain_m2,225      nbLogements: u.nb_logements,226      valeurRole: u.valeur_role,227      lat: u.lat,228      lng: u.lng,229      history: ([2021, 2022, 2023, 2024, 2025, 2026] as const).map((y) => ({230        year: y,231        value: u[`est_${y}` as keyof UnitRow] as number | null,232      })),233      specs: {234        cubf: u.cubf,235        cubfLibelle: u.cubf_libelle,236        anneeEstimee: u.annee_estimee,237        frontTerrainM: u.front_terrain_m,238        nbEtages: u.nb_etages,239        nbLocauxNonResid: u.nb_locaux_non_resid,240        nbChambresLocatives: u.nb_chambres_locatives,241        lienPhysique: u.lien_physique,242        genreConstruction: u.genre_construction,243        matricule: u.matricule,244        uniteVoisinage: u.unite_voisinage,245        nAdresses: u.n_adresses,246        datCondMarche: u.dat_cond_marche,247        valeurTerrain: u.valeur_terrain,248        valeurBatiment: u.valeur_batiment,249        valeurAnterieure: u.valeur_anterieure,250        arrond: u.arrond,251        apt: u.apt,252      },253    },254    result,255  };256}257258export interface PortfolioAggregates {259  count: number;260  totalEstimate: number;261  totalLow: number;262  totalHigh: number;263  totalRole: number;264  ecartRolePct: number | null;265  confidencePct: number; // moyenne pondérée par valeur266  confidenceLevel: "A" | "B" | "C" | "D";267  totalFloorArea: number;268  totalLandArea: number;269  totalDwellings: number;270  avgYearBuilt: number | null;271  municipalities: { name: string; count: number; total: number }[];272  types: { type: string; count: number; total: number }[];273  history: { year: number; total: number; nCovered: number }[];274  growthPct: number | null; // croissance du parc 2021→2026 (unités couvertes)275}276277export interface PortfolioResult {278  items: UnitEstimate[];279  aggregates: PortfolioAggregates;280}281282export function estimatePortfolio(ids: string[]): PortfolioResult {283  const items = ids284    .slice(0, 40)285    .map((id) => estimateByUnitId(id))286    .filter((x): x is UnitEstimate => x !== null && x.unit !== null);287288  const val = (n: number | null | undefined) => n ?? 0;289  const totalEstimate = items.reduce((s, i) => s + val(i.result.estimate), 0);290  const totalRole = items.reduce((s, i) => s + val(i.unit!.valeurRole), 0);291  const wConf =292    totalEstimate > 0293      ? items.reduce((s, i) => s + i.result.confidencePct * val(i.result.estimate), 0) /294        totalEstimate295      : 0;296  const level = wConf >= 75 ? "A" : wConf >= 60 ? "B" : wConf >= 45 ? "C" : "D";297298  const byKey = (key: (i: UnitEstimate) => string) => {299    const m = new Map<string, { count: number; total: number }>();300    for (const i of items) {301      const k = key(i);302      const e = m.get(k) ?? { count: 0, total: 0 };303      e.count += 1;304      e.total += val(i.result.estimate);305      m.set(k, e);306    }307    return [...m.entries()]308      .map(([name, v]) => ({ name, ...v }))309      .sort((a, b) => b.total - a.total);310  };311312  const years = [2021, 2022, 2023, 2024, 2025, 2026] as const;313  const history = years.map((y) => {314    let total = 0;315    let n = 0;316    for (const i of items) {317      const h = i.unit!.history.find((hh) => hh.year === y);318      if (h?.value != null) {319        total += h.value;320        n += 1;321      }322    }323    return { year: y, total, nCovered: n };324  });325  // croissance : somme 2026 vs 2021 sur les unités couvertes aux deux bornes326  let g21 = 0;327  let g26 = 0;328  for (const i of items) {329    const a = i.unit!.history.find((h) => h.year === 2021)?.value;330    const b = i.unit!.history.find((h) => h.year === 2026)?.value;331    if (a != null && b != null) {332      g21 += a;333      g26 += b;334    }335  }336337  const yb = items338    .map((i) => i.unit!.anneeConstruction)339    .filter((v): v is number => v != null && v > 1600);340341  return {342    items,343    aggregates: {344      count: items.length,345      totalEstimate,346      totalLow: items.reduce((s, i) => s + val(i.result.low), 0),347      totalHigh: items.reduce((s, i) => s + val(i.result.high), 0),348      totalRole,349      ecartRolePct: totalRole > 0 ? (totalEstimate / totalRole - 1) * 100 : null,350      confidencePct: Math.round(wConf),351      confidenceLevel: level,352      totalFloorArea: Math.round(items.reduce((s, i) => s + val(i.unit!.aireEtagesM2), 0)),353      totalLandArea: Math.round(items.reduce((s, i) => s + val(i.unit!.superficieTerrainM2), 0)),354      totalDwellings: items.reduce((s, i) => s + val(i.unit!.nbLogements), 0),355      avgYearBuilt: yb.length ? Math.round(yb.reduce((s, v) => s + v, 0) / yb.length) : null,356      municipalities: byKey((i) => i.unit!.municipalite ?? "—"),357      types: byKey((i) => i.unit!.typeProp).map(({ name, ...v }) => ({ type: name, ...v })),358      history,359      growthPct: g21 > 0 ? (g26 / g21 - 1) * 100 : null,360    },361  };362}363364export interface ManualParams {365  municipality: string;366  typeProp: string;367  floorArea?: number;368  yearBuilt?: number;369  landArea?: number;370}371372export function estimateManual(p: ManualParams): UnitEstimate | null {373  const center = municipalityCenter(p.municipality);374  if (!center || !center.n) return null;375  const subject: Subject = {376    lat: center.lat,377    lng: center.lng,378    typeProp: p.typeProp,379    floorArea: p.floorArea ?? null,380    yearBuilt: p.yearBuilt ?? null,381    landArea: p.landArea ?? null,382    modelEstimate: null,383    modelP10: null,384    modelP90: null,385  };386  const candidates = candidatesAround(center.lat, center.lng);387  const index = getMarketIndex(indexType(p.typeProp));388  const result = estimate(subject, toComps(candidates), index, nowISO());389  if (!Number.isFinite(result.estimate)) return null;390  const context = buildContext(391    p.typeProp,392    center.lat,393    center.lng,394    candidates,395    index,396    p.municipality,397    result.estimate398  );399  return { unit: null, result, context };400}401