TypeScript 90.1%
JavaScript 3.2%
Python 3.2%
CSS 1.8%
HTML 1.8%
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