SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
21.0 KB · 330 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Moteur de la méthode du coût — calcul PUR et DÉTERMINISTE :4 *   même entrée + même contexte de prix = même résultat, aucune IA.5 *6 *   matériau_i   = Q × qté_i × (1 + pertes_i) × prix_i × FacteurMatériau7 *   main-d'œuvre = Q × heures_i × taux_métier_i × FacteurMainDœuvre8 *   équipement   = Q × équip_i × FacteurÉquipement9 *   Direct       = Σ lignes ;  Indirects = Direct × Σ pct10 *   FG = (Direct + Indirects) × pct ; Profit = (Direct + Indirects + FG) × pct11 *   Contingence  = (Direct + Indirects) × pct12 *   RCN          = Direct + Indirects + FG + Profit + Contingence13 *   V_coût       = V_terrain + RCN − (D_phys + D_fonct + D_ext)14 */15import type {16  Assembly, AssemblyUnitCost, BenchmarkCheck, CategoryTotal, Condition, ConfidenceBreakdown, CostEstimate, CostInput, CostItem,17  CostLocation, DepreciationComponentRow, DepreciationResult, EstimateLine, ItemPrice, LabourRate, Provenance, QuantityLine, UxCategory,18} from "./types";19import { BUILDING_TYPES, CONDITION_GROUPS, INDIRECT_LABELS, METHOD_VERSION, UX_CATEGORIES, categoryLabel } from "./taxonomy";2021export interface BenchmarkRow {22  source: string;23  buildingType: string; // single_family | townhouse | plex | condo | cottage24  market: string;25  unit: string;26  low: number;27  high: number;28  midpoint: number | null;29  year: number;30  notes: string | null;31}3233export interface ConditionRule {34  effectiveAgeRatio: number;35  economicLife: number;36}3738/** Contexte de prix : tout ce dont le moteur a besoin, lu une seule fois (instantané). */39export interface PricingContext {40  asOf: string;41  items: Map<string, CostItem>;42  prices: Map<string, ItemPrice>;43  labour: Map<string, LabourRate>; // par code de métier (compagnon, résidentiel léger)44  assemblies: Map<string, Assembly>;45  location: CostLocation;46  conditionRules: Map<string, Map<Condition, ConditionRule>>;47  benchmarks: BenchmarkRow[];48  /** taux horaire de repli (hypothèse) si un métier manque */49  fallbackHourlyRate: number;50}5152export const FALLBACK_HOURLY_RATE = 85; // $/h coût employeur — hypothèse si aucune grille53const Z90 = 1.2816;54const MAX_DEPRECIATION_PCT = 0.9;5556const r2 = (x: number) => Math.round(x * 100) / 100;57const r0 = (x: number) => Math.round(x);5859/* ------------------------------------------------------ coût d'assemblage */6061function priceSigmaPct(p: ItemPrice | undefined): number {62  if (!p) return 0.2;63  if (p.sourceCount >= 2 && p.price > 0) return Math.max(0.04, (p.high - p.low) / (2 * Z90 * p.price));64  switch (p.kind) {65    case "observed": case "official": return 0.08;66    case "indexed": return 0.1;67    case "derived": return 0.1;68    case "reference": return 0.15;69    default: return 0.2;70  }71}7273export function assemblyUnitCost(asm: Assembly, ctx: PricingContext): AssemblyUnitCost {74  let material = 0, labour = 0, equipment = 0, varSum = 0, observedMaterial = 0;75  let freshest: string | null = null;76  const components: AssemblyUnitCost["components"] = [];77  for (const c of asm.components) {78    const item = c.itemCode ? ctx.items.get(c.itemCode) : undefined;79    const price = c.itemCode ? ctx.prices.get(c.itemCode) : undefined;80    const unitPrice = price?.price ?? 0;81    const isEquipItem = item?.materialClass === "equipment" || item?.materialClass === "rental";82    const qty = c.quantity * (1 + c.wasteFactor);83    const matCost = item && !isEquipItem ? qty * unitPrice : 0;84    const eqItem = item && isEquipItem ? qty * unitPrice : 0;85    const trade = c.trade ?? item?.trade ?? null;86    const rate = trade ? ctx.labour.get(trade) : undefined;87    const hourly = c.labourHours > 0 ? (rate?.totalEmployerCost ?? ctx.fallbackHourlyRate) : null;88    const labCost = c.labourHours * (hourly ?? 0);89    const eqCost = c.equipmentCost + eqItem;90    material += matCost;91    labour += labCost;92    equipment += eqCost;93    if (price && (price.kind === "observed" || price.kind === "official" || price.kind === "indexed")) observedMaterial += matCost;94    if (price?.observedAt && (!freshest || price.observedAt > freshest)) freshest = price.observedAt;95    const sp = priceSigmaPct(price);96    varSum += (matCost * sp) ** 2 + (labCost * 0.15) ** 2 + (eqCost * 0.2) ** 2;97    const prov: Provenance = price98      ? { kind: price.kind, source: price.sources.map((s) => s.source).join(", ") || "—", sourceUrl: price.sources[0]?.url ?? null, observedAt: price.observedAt, confidence: price.confidence, note: price.note }99      : { kind: "assumption", source: c.itemCode ? "aucun prix" : "Vrai-Prix — hypothèse de productivité", confidence: c.itemCode ? 0 : 40, note: c.notes };100    const rateProv: Provenance | null = c.labourHours > 0101      ? rate102        ? { kind: "official", source: rate.source, sourceUrl: rate.sourceUrl, effectiveDate: rate.effectiveFrom, confidence: rate.confidence, note: `${rate.tradeNameFr} — ${rate.classification}, ${rate.sector}` }103        : { kind: "assumption", source: "Vrai-Prix — taux horaire de repli", confidence: 30, note: `Aucune grille pour le métier « ${trade ?? "?"} » : ${ctx.fallbackHourlyRate} $/h (hypothèse)` }104      : null;105    components.push({106      itemCode: c.itemCode, nameFr: item?.nameFr ?? (c.labourHours ? "Main-d'œuvre" : "Équipement"), nameEn: item?.nameEn ?? (c.labourHours ? "Labour" : "Equipment"), unit: item?.unit ?? "h",107      quantity: c.quantity, wasteFactor: c.wasteFactor, unitPrice: r2(unitPrice), materialCost: r2(matCost), labourHours: c.labourHours, trade, hourlyRate: hourly, labourCost: r2(labCost), equipmentCost: r2(eqCost),108      provenance: prov, rateProvenance: rateProv,109    });110  }111  const direct = material + labour + equipment;112  return {113    code: asm.code, unit: asm.unit, material: r2(material), labour: r2(labour), equipment: r2(equipment), direct: r2(direct),114    sigmaPct: direct > 0 ? Math.sqrt(varSum) / direct : 0.2, observedShare: material > 0 ? observedMaterial / material : 0, components, freshestObservation: freshest,115  };116}117118/* ------------------------------------------------------------- estimation */119120const QTY_UNCERTAINTY: Record<string, number> = { derived: 0.1, AI: 0.15, user: 0.03, listing: 0.05, MAMH: 0.03, cadastral: 0.02, assumed: 0.2 };121122export function computeEstimate(input: CostInput, quantities: QuantityLine[], ctx: PricingContext, meta: { id: string; createdAt: string; costDatabaseVersion: string; assemblyVersion: string }): CostEstimate {123  const loc = ctx.location;124  const warnings: string[] = [];125  const lines: EstimateLine[] = [];126  let unpriced = 0;127128  for (const q of quantities) {129    const asm = ctx.assemblies.get(q.assemblyCode);130    if (!asm) { warnings.push(`assemblage inconnu : ${q.assemblyCode}`); continue; }131    const u = assemblyUnitCost(asm, ctx);132    if (u.direct <= 0) unpriced++;133    const material = u.material * q.quantity;134    const labour = u.labour * q.quantity;135    const equipment = u.equipment * q.quantity;136    const direct = material + labour + equipment;137    const adjusted = material * loc.materialFactor + labour * loc.labourFactor + equipment * loc.equipmentFactor;138    const qUnc = QTY_UNCERTAINTY[q.source] ?? 0.1;139    const sigma = Math.sqrt((adjusted * u.sigmaPct) ** 2 + (adjusted * qUnc) ** 2);140    const conf = Math.round(100 * (0.6 * u.observedShare + 0.4 * (1 - Math.min(1, u.sigmaPct / 0.3))));141    lines.push({142      assemblyCode: asm.code, nameFr: asm.nameFr, nameEn: asm.nameEn, category: asm.category, quantity: q.quantity, unit: asm.unit, quantitySource: q.source, quantityFormula: q.formula,143      unitCost: u.direct, material: r0(material), labour: r0(labour), equipment: r0(equipment), direct: r0(direct), locationAdjustment: r0(adjusted - direct), adjusted: r0(adjusted),144      sigma: r0(sigma), observedShare: u.observedShare, confidence: conf, economicLife: asm.economicLife, conditionGroup: asm.conditionGroup, unitDetail: u,145    });146  }147  lines.sort((a, b) => (UX_CATEGORIES.find((c) => c.key === a.category)?.order ?? 99) - (UX_CATEGORIES.find((c) => c.key === b.category)?.order ?? 99) || a.nameFr.localeCompare(b.nameFr));148149  const directMaterial = r0(lines.reduce((s, l) => s + l.material * loc.materialFactor, 0));150  const directLabour = r0(lines.reduce((s, l) => s + l.labour * loc.labourFactor, 0));151  const directEquipment = r0(lines.reduce((s, l) => s + l.equipment * loc.equipmentFactor, 0));152  // le direct est la somme exacte des trois composantes (les lignes sont arrondies individuellement)153  const directCost = directMaterial + directLabour + directEquipment;154155  const categories: CategoryTotal[] = UX_CATEGORIES.filter((c) => lines.some((l) => l.category === c.key)).map((c) => {156    const ls = lines.filter((l) => l.category === c.key);157    const adjusted = ls.reduce((s, l) => s + l.adjusted, 0);158    return {159      category: c.key, labelFr: c.fr, labelEn: c.en,160      material: r0(ls.reduce((s, l) => s + l.material * loc.materialFactor, 0)), labour: r0(ls.reduce((s, l) => s + l.labour * loc.labourFactor, 0)), equipment: r0(ls.reduce((s, l) => s + l.equipment * loc.equipmentFactor, 0)),161      direct: r0(ls.reduce((s, l) => s + l.direct, 0)), adjusted: r0(adjusted), sharePct: directCost > 0 ? r2((100 * adjusted) / directCost) : 0,162    };163  });164165  const p = input.params;166  const indirect = (Object.keys(INDIRECT_LABELS) as (keyof typeof INDIRECT_LABELS)[]).map((key) => ({ key, labelFr: INDIRECT_LABELS[key].fr, labelEn: INDIRECT_LABELS[key].en, pct: p.indirect[key], amount: r0((directCost * p.indirect[key]) / 100) }));167  const indirectCost = indirect.reduce((s, i) => s + i.amount, 0);168  const contractorOverhead = r0(((directCost + indirectCost) * p.overheadPct) / 100);169  const contractorProfit = r0(((directCost + indirectCost + contractorOverhead) * p.profitPct) / 100);170  const contingency = r0(((directCost + indirectCost) * p.contingencyPct) / 100);171  const replacementCostNew = r0(directCost + indirectCost + contractorOverhead + contractorProfit + contingency);172173  // fourchette : composante aléatoire (racine des carrés des lignes) + systématique (localisation 3 %, modèle d'assemblages 5 %)174  const randomSigma = Math.sqrt(lines.reduce((s, l) => s + l.sigma ** 2, 0));175  const locUnc = loc.confidence >= 75 ? 0.02 : 0.04;176  const systematicSigma = directCost * Math.sqrt(locUnc ** 2 + 0.05 ** 2);177  const sigmaDirect = Math.sqrt(randomSigma ** 2 + systematicSigma ** 2);178  const scale = directCost > 0 ? replacementCostNew / directCost : 1;179  const sigma = sigmaDirect * scale;180  const range = { p10: r0(replacementCostNew - Z90 * sigma), p90: r0(replacementCostNew + Z90 * sigma), sigma: r0(sigma), low: r0(replacementCostNew - Z90 * sigma), high: r0(replacementCostNew + Z90 * sigma) };181182  const gfa = Math.max(1, input.building.grossFloorAreaSqft);183  const perSqft = r2(replacementCostNew / gfa);184  const perM2 = r2(replacementCostNew / (gfa * 0.092903));185186  const depreciation = computeDepreciation(input, lines, replacementCostNew, directCost, ctx);187  const landValue = input.land.value ?? 0;188  if (input.land.value == null) warnings.push("land_missing");189  const costApproachValue = r0(landValue + depreciation.depreciatedImprovementValue);190191  const benchmarks = checkBenchmarks(input, ctx, perSqft);192  const materialObservedShare = directMaterial > 0 ? lines.reduce((s, l) => s + l.material * loc.materialFactor * l.observedShare, 0) / directMaterial : 0;193  const coverage = { materialObservedShare: r2(materialObservedShare), assembliesPriced: lines.length - unpriced, assembliesTotal: lines.length, pricingCoverage: lines.length ? r2((lines.length - unpriced) / lines.length) : 0 };194  const confidence = computeConfidence(input, lines, ctx, benchmarks, coverage, directLabour, directCost);195  if (unpriced) warnings.push(`${unpriced} assemblage(s) sans prix`);196197  return {198    id: meta.id, createdAt: meta.createdAt, methodVersion: METHOD_VERSION, costDatabaseVersion: meta.costDatabaseVersion, assemblyVersion: meta.assemblyVersion, priceDate: ctx.asOf,199    input, location: loc, quantities, lines, categories,200    directCost: r0(directCost), directMaterial: r0(directMaterial), directLabour: r0(directLabour), directEquipment: r0(directEquipment),201    indirect, indirectCost: r0(indirectCost), contractorOverhead, contractorProfit, contingency, replacementCostNew, range, perSqft, perM2,202    depreciation, landValue: r0(landValue), costApproachValue, confidence, benchmarks, coverage, otherReadings: null, warnings,203  };204}205206/* ----------------------------------------------------------- dépréciation */207208export function computeDepreciation(input: CostInput, lines: EstimateLine[], rcn: number, directCost: number, ctx: PricingContext): DepreciationResult {209  const d = input.depreciation;210  const year = Number(ctx.asOf.slice(0, 4));211  const chronologicalAge = input.building.yearBuilt && input.building.yearBuilt > 1600 ? Math.max(0, year - input.building.yearBuilt) : null;212  const economicLife = d.economicLife || BUILDING_TYPES.find((t) => t.key === input.building.type)?.economicLife || 60;213  const scale = directCost > 0 ? rcn / directCost : 1;214  let physical = 0;215  let physicalPct = 0;216  let effectiveAge: number | null = d.effectiveAge ?? chronologicalAge;217  const components: DepreciationComponentRow[] = [];218219  if (d.method === "components") {220    for (const g of CONDITION_GROUPS) {221      const ls = lines.filter((l) => l.conditionGroup === g.key);222      if (!ls.length) continue;223      const groupRcn = ls.reduce((s, l) => s + l.adjusted, 0) * scale;224      const lifeW = ls.reduce((s, l) => s + (l.economicLife ?? g.economicLife) * l.adjusted, 0) / Math.max(1, ls.reduce((s, l) => s + l.adjusted, 0));225      const life = Math.max(5, Math.round(lifeW));226      const cond = d.componentConditions[g.key] ?? null;227      const rule = cond ? ctx.conditionRules.get(g.key)?.get(cond) : undefined;228      let effAge: number;229      let ruleTxt: string;230      if (cond && rule) {231        effAge = rule.effectiveAgeRatio * life;232        ruleTxt = `condition « ${cond} » → âge effectif = ${rule.effectiveAgeRatio} × vie ${life} ans`;233      } else if (d.effectiveAge != null && g.key === "structure") {234        effAge = Math.min(d.effectiveAge, life);235        ruleTxt = `âge effectif saisi (${d.effectiveAge} ans), plafonné à la vie de la composante`;236      } else {237        effAge = Math.min(chronologicalAge ?? 0, life);238        ruleTxt = chronologicalAge != null ? `âge chronologique (${chronologicalAge} ans) plafonné à la vie ${life} ans — aucune condition saisie` : "âge inconnu → 0";239      }240      const pct = Math.min(MAX_DEPRECIATION_PCT, life > 0 ? effAge / life : 0);241      const dep = groupRcn * pct;242      physical += dep;243      components.push({ conditionGroup: g.key, labelFr: g.fr, labelEn: g.en, rcn: r0(groupRcn), economicLife: life, condition: cond, effectiveAge: r2(effAge), depreciationPct: r2(pct * 100), depreciation: r0(dep), remaining: r0(groupRcn - dep), rule: ruleTxt });244    }245    physicalPct = rcn > 0 ? physical / rcn : 0;246    effectiveAge = r2(physicalPct * economicLife);247  } else {248    const ea = effectiveAge ?? 0;249    physicalPct = Math.min(MAX_DEPRECIATION_PCT, economicLife > 0 ? ea / economicLife : 0);250    physical = rcn * physicalPct;251  }252  const functionalCurable = d.functional.filter((f) => f.curable).reduce((s, f) => s + (f.costToCure || 0), 0);253  const functionalIncurable = d.functional.filter((f) => !f.curable).reduce((s, f) => s + (f.valueLoss || 0), 0);254  const functional = functionalCurable + functionalIncurable;255  const external = Math.max(0, d.externalValueLoss || 0);256  const total = Math.min(rcn, physical + functional + external);257  return {258    method: d.method, chronologicalAge, effectiveAge, economicLife, physicalPct: r2(physicalPct * 100), physical: r0(physical), components,259    functionalCurable: r0(functionalCurable), functionalIncurable: r0(functionalIncurable), functional: r0(functional), external: r0(external), total: r0(total),260    depreciatedImprovementValue: r0(Math.max(0, rcn - total)),261  };262}263264/* -------------------------------------------------------------- confiance */265266function computeConfidence(input: CostInput, lines: EstimateLine[], ctx: PricingContext, benchmarks: BenchmarkCheck[], coverage: CostEstimate["coverage"], directLabour: number, directCost: number): ConfidenceBreakdown {267  const notesFr: string[] = [];268  const notesEn: string[] = [];269  // fraîcheur : âge moyen pondéré des observations270  let wAge = 0, wSum = 0;271  for (const l of lines) for (const c of l.unitDetail.components) {272    if (c.provenance.observedAt && (c.provenance.kind === "observed" || c.provenance.kind === "official")) {273      const age = (new Date(ctx.asOf).getTime() - new Date(c.provenance.observedAt).getTime()) / 86400000;274      const w = c.materialCost * l.quantity;275      wAge += age * w; wSum += w;276    }277  }278  const freshness = wSum > 0 ? Math.round(20 * Math.max(0, 1 - wAge / wSum / 180)) : 4;279  if (wSum === 0) { notesFr.push("Aucun prix observé récent : base de référence seulement."); notesEn.push("No recent observed price: reference base only."); }280  const cov = Math.round(20 * (0.7 * coverage.materialObservedShare + 0.3 * coverage.pricingCoverage));281  if (coverage.materialObservedShare < 0.5) { notesFr.push(`${Math.round(coverage.materialObservedShare * 100)} % du coût des matériaux repose sur des prix observés ; le reste sur des prix de référence (hypothèses).`); notesEn.push(`${Math.round(coverage.materialObservedShare * 100)} % of material cost rests on observed prices; the rest on reference prices (assumptions).`); }282  const location = ctx.location.confidence >= 75 ? 15 : ctx.location.confidence >= 55 ? 11 : 8;283  // main-d'œuvre : part des $ de main-d'œuvre appuyée sur une grille officielle284  let offLab = 0;285  for (const l of lines) for (const c of l.unitDetail.components) if (c.rateProvenance?.kind === "official") offLab += c.labourCost * l.quantity * ctx.location.labourFactor;286  const labour = directLabour > 0 ? Math.round(15 * Math.min(1, offLab / directLabour)) : 8;287  if (directLabour > 0 && offLab / directLabour < 0.9) { notesFr.push("Certains métiers utilisent le taux horaire de repli (aucune grille APCHQ/CCQ)."); notesEn.push("Some trades use the fallback hourly rate (no APCHQ/CCQ grid)."); }288  const withBench = benchmarks.filter((b) => b.status !== "unavailable");289  const bench = !withBench.length ? 5 : withBench.some((b) => b.status === "within") ? 10 : 3;290  if (!withBench.length) { notesFr.push("Aucun benchmark externe disponible pour ce type/marché."); notesEn.push("No external benchmark available for this type/market."); }291  else if (!withBench.some((b) => b.status === "within")) { notesFr.push("Écart important par rapport au benchmark externe."); notesEn.push("Large deviation from the external benchmark."); }292  const keys = ["grossFloorAreaSqft", "stories", "yearBuilt", "basement", "garage", "siding", "roof", "heating", "bathrooms", "kitchenQuality"];293  const known = keys.filter((k) => { const s = input.attributeSources[k]; return s && s !== "assumed" && s !== "derived"; }).length;294  const building = Math.round(20 * (known / keys.length));295  if (known < 7) { notesFr.push(`${keys.length - known} caractéristiques du bâtiment sur ${keys.length} sont supposées par défaut.`); notesEn.push(`${keys.length - known} of ${keys.length} building attributes are default assumptions.`); }296  const total = Math.max(0, Math.min(100, freshness + cov + location + labour + bench + building));297  const letter = total >= 80 ? "A" : total >= 65 ? "B" : total >= 50 ? "C" : "D";298  void directCost;299  return { freshness, coverage: cov, location, labour, benchmarks: bench, building, total, letter, notesFr, notesEn };300}301302/* ------------------------------------------------------------- benchmarks */303304export function benchmarkTypeFor(t: CostInput["building"]["type"]): string {305  switch (t) {306    case "detached": case "chalet": return "single_family";307    case "semi_detached": case "row": return "townhouse";308    case "plex": return "plex";309    case "condo": return "condo";310    default: return "single_family";311  }312}313314export function checkBenchmarks(input: CostInput, ctx: PricingContext, perSqft: number): BenchmarkCheck[] {315  const bt = benchmarkTypeFor(input.building.type);316  const market = ctx.location.code === "QC-QUE" || ctx.location.code === "QC-LEV" ? "Québec" : ctx.location.code === "QC-GAT" ? "Ottawa-Gatineau" : "Montréal";317  const rows = ctx.benchmarks.filter((b) => b.buildingType === bt);318  const chosen = rows.filter((b) => b.market === market);319  const use = chosen.length ? chosen : rows.filter((b) => b.market === "Montréal");320  return use.map((b) => {321    const within = perSqft >= b.low && perSqft <= b.high;322    const status: BenchmarkCheck["status"] = within ? "within" : perSqft < b.low ? "below" : "above";323    const ref = within ? null : perSqft < b.low ? b.low : b.high;324    return { source: b.source, buildingType: b.buildingType, market: b.market, unit: b.unit, low: b.low, high: b.high, midpoint: b.midpoint, year: b.year, estimatePerUnit: perSqft, status, deviationPct: ref ? r2(((perSqft - ref) / ref) * 100) : 0, notes: b.notes ? `${b.notes}${b.market !== market ? " — marché de substitution (Montréal)" : ""}` : b.market !== market ? "marché de substitution (Montréal)" : null };325  });326}327328/** Libellé de catégorie (réexport pratique). */329export const catLabel = (k: UxCategory, fr: boolean) => categoryLabel(k, fr);330