Repository Public
1// UQO Éval — Métré : adaptateur « permis de construction » (Ville de Montréal, CC-BY 4.0). Module PUR.2/**3 * Deux ressources du jeu « Permis de construction, transformation et démolition » (vérifié le 2026-10-28) :4 * 1. `statistiques-permis-de-construction.csv` (≈ 92 Ko) : par année × arrondissement × code de type5 * (CO construction, TR transformation, DE démolition…) → nombre de permis, coût des permis émis,6 * **coût des travaux estimés** (valeur déclarée agrégée). C'est la SEULE ressource qui porte une valeur7 * de travaux : chaque ligne devient une observation `permit_declared_value` (montant = total déclaré,8 * lignage = nombre de permis et moyenne par permis). Jamais un prix unitaire.9 * 2. `permis-construction.csv` (≈ 187 Mo, 562 748 permis depuis 1990) : détail SANS valeur de travaux10 * (no_demande, dates, emplacement, arrondissement, type, catégorie de bâtiment, nature, logements,11 * coordonnées). Résumé statistique seulement (années, catégories, types) conservé dans les notes QA.12 */13import { normHeader, parseCsv, parseNumber } from "./csv";1415export const PERMITS_PARSER_VERSION = "mtl-permits-2.0";16export const PERMITS_DATASET_URL = "https://donnees.montreal.ca/api/3/action/package_show?id=permis-construction";17export const PERMIT_TYPE_LABEL: Record<string, string> = { CO: "Construction", TR: "Transformation", DE: "Démolition", RE: "Rénovation", CA: "Certificat d'autorisation", PL: "Plomberie", AA: "Abattage d'arbre", OC: "Occupation", AF: "Affichage", EX: "Travaux extérieurs" };1819export interface PermitAggregate {20 externalId: string; // annee:arrondissement:type21 year: number;22 borough: string;23 typeCode: string;24 typeLabel: string;25 permits: number;26 permitFees: number | null;27 declaredWorksValue: number; // cout_travaux_estimes (total)28 avgPerPermit: number | null;29}3031/** Ressource 1 : agrégats annuels avec valeur déclarée des travaux. */32export function parsePermitStatsCsv(text: string): { observations: PermitAggregate[]; rows: number; skipped: number; columns: string[] } {33 const { header, rows } = parseCsv(text, { maxRows: 200000 });34 const h = header.map(normHeader);35 const iY = h.indexOf("annee"), iB = h.indexOf("arrondissement"), iT = h.findIndex((x) => x.startsWith("code_type")), iN = h.findIndex((x) => x.startsWith("nombre_permis")), iF = h.findIndex((x) => x.startsWith("cout_permis")), iV = h.findIndex((x) => x.startsWith("cout_travaux"));36 if (iY < 0 || iB < 0 || iV < 0) throw new Error(`Colonnes attendues annee, arrondissement, cout_travaux_estimes (trouvées : ${header.join(", ")})`);37 const out: PermitAggregate[] = []; let skipped = 0;38 for (const r of rows) {39 const year = Number(r[iY]); const v = parseNumber(r[iV]); const n = iN >= 0 ? parseNumber(r[iN]) : null;40 if (!Number.isFinite(year) || v == null || v <= 0) { skipped++; continue; }41 const borough = (r[iB] ?? "").trim(); const code = (iT >= 0 ? r[iT] : "")?.trim().toUpperCase() || "?";42 out.push({ externalId: `${year}:${borough}:${code}`, year, borough, typeCode: code, typeLabel: PERMIT_TYPE_LABEL[code] ?? code, permits: n ?? 0, permitFees: iF >= 0 ? parseNumber(r[iF]) : null, declaredWorksValue: v, avgPerPermit: n && n > 0 ? Math.round((v / n) * 100) / 100 : null });43 }44 return { observations: out, rows: rows.length, skipped, columns: header };45}4647export interface PermitDetailSummary { rows: number; years: Record<string, number>; categories: Record<string, number>; types: Record<string, number>; columns: string[]; hasValueColumn: boolean; sinceYear: number }4849/** Ressource 2 : résumé statistique du détail (sans valeur) — n'écrit aucune observation. */50export function summarizePermitDetailCsv(text: string, sinceYear = 2023): PermitDetailSummary {51 const { header, rows } = parseCsv(text, { maxRows: 2_000_000 });52 const h = header.map(normHeader);53 const iDate = h.findIndex((x) => x === "date_emission" || x.startsWith("date")), iCat = h.findIndex((x) => x.includes("categorie")), iType = h.findIndex((x) => x === "description_type_demande" || x.includes("type_demande"));54 const iVal = h.findIndex((x) => x.includes("valeur") || x.includes("cout") || x.includes("montant"));55 const years: Record<string, number> = {}, categories: Record<string, number> = {}, types: Record<string, number> = {};56 let n = 0;57 for (const r of rows) {58 const y = Number((iDate >= 0 ? r[iDate] : "").slice(0, 4));59 if (!Number.isFinite(y) || y < sinceYear) continue;60 n++; years[y] = (years[y] ?? 0) + 1;61 const c = (iCat >= 0 ? r[iCat] : "")?.trim() || "?"; categories[c] = (categories[c] ?? 0) + 1;62 const t = (iType >= 0 ? r[iType] : "")?.trim() || "?"; types[t] = (types[t] ?? 0) + 1;63 }64 const top = (o: Record<string, number>, k = 12) => Object.fromEntries(Object.entries(o).sort((a, b) => b[1] - a[1]).slice(0, k));65 return { rows: n, years, categories: top(categories), types: top(types), columns: header, hasValueColumn: iVal >= 0, sinceYear };66}67