/** * Builds static Bahia municipality demographics from IBGE SIDRA (Censo 2022). * * Provenance: * - SIDRA table 9514 — População residente, por sexo, idade e forma de declaração da idade (2022, N6): * https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022 * - SIDRA table 9515 — Idade mediana (2022, N6): * https://apisidra.ibge.gov.br/values/t/9515/n3/29/n6/all/v/10613/p/2022 * * Safety: does not touch any database — only downloads public SIDRA JSON and writes * versioned artifacts under src/lib/ and tests/fixtures/. * * Usage: * pnpm build:demographics * DEMOGRAPHICS_CACHE_DIR=./data/demographics pnpm build:demographics */ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { dieWithLabel, ensureCachedDownload, writeRepoFile } from './lib/cli.mjs' const { bahiaMunicipalityCodes } = await import('../src/lib/bahiaMunicipalityCodes.ts') const { downloadToBuffer } = await import('../src/lib/electionResultsZip.ts') const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') const SOURCES = { femalePopulation: { key: 'sidra-9514-ba-municipios-mulheres', url: 'https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/5/c287/100362', }, medianAge: { key: 'sidra-9515-ba-municipios-idade-mediana', url: 'https://apisidra.ibge.gov.br/values/t/9515/n3/29/n6/all/v/10613/p/2022', }, } /** SIDRA classification 287 (Idade) category ids grouped for the app bands. */ const AGE_BAND_CATEGORY_IDS = { '0-17': [93070, 93084, 93085, 6572, 6573, 6574], '18-29': [6575, 6576, 6577, 6578, 6579, 6580, 6581, 93088], '30-59': [93089, 93090, 93091, 93092, 93093, 93094], '60+': [93095, 93096, 93097, 93098, 49108, 49109, 60040, 60041, 6653], } const AGE_CATEGORY_TO_BAND = Object.fromEntries( Object.entries(AGE_BAND_CATEGORY_IDS).flatMap(([band, ids]) => ids.map((id) => [String(id), band]), ), ) const die = dieWithLabel('build:demographics') const cacheDir = () => process.env.DEMOGRAPHICS_CACHE_DIR || join(ROOT, 'data', 'demographics') const ensureCachedJson = ({ key, url }) => ensureCachedDownload({ label: 'build:demographics', key, url, ext: 'json', cacheDir: cacheDir(), download: downloadToBuffer, }) const writeText = (relativePath, body) => writeRepoFile({ label: 'build:demographics', root: ROOT, relativePath, body }) const parseSidraValue = (value) => { if (value === '...' || value === '-' || value === 'X' || value == null || value === '') return 0 const parsed = Number.parseInt(String(value).replace(/\./g, ''), 10) return Number.isFinite(parsed) ? parsed : 0 } const municipalityRows = (rows) => rows.filter((row) => row?.NC === '6' && String(row?.D1C ?? '').startsWith('29')) const buildDemographicsByCode = ({ ageCategoryRows, femaleRows, medianRows }) => { const byCode = new Map() for (const { categoryId, rows } of ageCategoryRows) { const band = AGE_CATEGORY_TO_BAND[String(categoryId)] if (!band) continue for (const row of municipalityRows(rows)) { const code = String(row.D1C) const current = byCode.get(code) ?? { population: 0, ageBands: { '0-17': 0, '18-29': 0, '30-59': 0, '60+': 0 }, femalePopulation: 0, medianAge: null, } current.ageBands[band] += parseSidraValue(row.V) byCode.set(code, current) } } for (const row of municipalityRows(femaleRows)) { const code = String(row.D1C) const current = byCode.get(code) if (!current) continue current.femalePopulation = parseSidraValue(row.V) } for (const row of municipalityRows(medianRows)) { const code = String(row.D1C) const current = byCode.get(code) if (!current) continue const median = Number.parseFloat(String(row.V).replace(',', '.')) current.medianAge = Number.isFinite(median) ? median : null } for (const record of byCode.values()) { record.population = Object.values(record.ageBands).reduce((sum, value) => sum + value, 0) record.sexShareFemale = record.population > 0 ? record.femalePopulation / record.population : 0 delete record.femalePopulation } return byCode } const buildModuleSource = (demographicsByCode, provenance) => { const entries = [...demographicsByCode.entries()].sort(([left], [right]) => left.localeCompare(right), ) const lines = entries .map(([code, record]) => { const bands = Object.entries(record.ageBands) .map(([band, value]) => `${JSON.stringify(band)}: ${value}`) .join(', ') const median = record.medianAge == null ? 'null' : JSON.stringify(record.medianAge) const sexShare = record.sexShareFemale.toFixed(6) return ` ${JSON.stringify(code)}: { population: ${record.population}, ageBands: { ${bands} }, sexShareFemale: ${sexShare}, medianAge: ${median} },` }) .join('\n') return `/** * IBGE Censo 2022 municipality demographics for Bahia (codarea → indicators). * * Source (SIDRA table 9514 — população por idade, UF BA N6; accessed ${provenance.accessed}): * ${provenance.ageCategoryUrls.join('\n * ')} * Download SHA-256: ${provenance.ageCategorySha256.join(', ')} * * Source (SIDRA table 9514 — população feminina total, UF BA N6; accessed ${provenance.accessed}): * ${provenance.femaleUrl} * Download SHA-256: ${provenance.femaleSha256} * * Source (SIDRA table 9515 — idade mediana, UF BA N6; accessed ${provenance.accessed}): * ${provenance.medianUrl} * Download SHA-256: ${provenance.medianSha256} * * Age bands aggregate SIDRA classification 287 categories documented in scripts/build-bahia-demographics.mjs. * Coverage is independently checked by tests/fixtures/bahia-municipality-demographics.official.json. */ import { codeForMunicipality } from '@/lib/bahiaMunicipalityCodes' export type MunicipalityAgeBandKey = '0-17' | '18-29' | '30-59' | '60+' export type MunicipalityDemographics = { population: number ageBands: Record sexShareFemale: number medianAge: number | null } export const bahiaMunicipalityDemographics: Readonly> = { ${lines} } export const demographicsForCode = (codarea: string): MunicipalityDemographics | undefined => bahiaMunicipalityDemographics[codarea] export const demographicsForMunicipalityName = ( municipality: string, ): MunicipalityDemographics | undefined => { const code = codeForMunicipality(municipality) return code ? demographicsForCode(code) : undefined } ` } const buildFixture = (demographicsByCode) => { const assignments = [...demographicsByCode.entries()] .sort(([left], [right]) => left.localeCompare(right)) .map(([code, record]) => ({ code, population: record.population, ageBands: record.ageBands, sexShareFemale: Number(record.sexShareFemale.toFixed(6)), medianAge: record.medianAge, })) const evidenceRows = assignments .map( ({ code, population, ageBands, sexShareFemale, medianAge }) => `D\t${code}\t${population}\t${ageBands['0-17']}\t${ageBands['18-29']}\t${ageBands['30-59']}\t${ageBands['60+']}\t${sexShareFemale}\t${medianAge ?? ''}\n`, ) .join('') return { municipalityCount: assignments.length, assignments, evidenceSha256: sha256(Buffer.from(evidenceRows, 'utf8')), } } const main = async () => { const accessed = new Date().toISOString().slice(0, 10) const uniqueCategoryIds = [ ...new Set(Object.values(AGE_BAND_CATEGORY_IDS).flatMap((ids) => ids)), ].sort((left, right) => left - right) const ageCategoryDownloads = [] for (const categoryId of uniqueCategoryIds) { const download = await ensureCachedJson({ key: `sidra-9514-ba-idade-${categoryId}`, url: `https://apisidra.ibge.gov.br/values/t/9514/n3/29/n6/all/v/93/p/2022/c2/6794/c287/${categoryId}`, }) ageCategoryDownloads.push({ categoryId, ...download }) } const female = await ensureCachedJson(SOURCES.femalePopulation) const median = await ensureCachedJson(SOURCES.medianAge) const demographicsByCode = buildDemographicsByCode({ ageCategoryRows: ageCategoryDownloads.map(({ categoryId, json }) => ({ categoryId, rows: json, })), femaleRows: female.json, medianRows: median.json, }) const expectedCodes = new Set(Object.values(bahiaMunicipalityCodes)) if (demographicsByCode.size !== expectedCodes.size) { die( `expected ${expectedCodes.size} municipality rows, got ${demographicsByCode.size} from SIDRA`, ) } for (const code of expectedCodes) { if (!demographicsByCode.has(code)) { die(`missing demographics for codarea ${code}`) } const record = demographicsByCode.get(code) if (record.population <= 0) { die(`non-positive population for codarea ${code}`) } } const provenance = { accessed, ageCategoryUrls: ageCategoryDownloads.map(({ url }) => url), ageCategorySha256: ageCategoryDownloads.map(({ hash }) => hash), femaleUrl: female.url, femaleSha256: female.hash, medianUrl: median.url, medianSha256: median.hash, } await writeText( 'src/lib/bahiaMunicipalityDemographics.ts', buildModuleSource(demographicsByCode, provenance), ) await writeText( 'tests/fixtures/bahia-municipality-demographics.official.json', `${JSON.stringify(buildFixture(demographicsByCode), null, 2)}\n`, ) console.log( `[build:demographics] done — ${demographicsByCode.size} municipalities, total population ${[...demographicsByCode.values()].reduce((sum, row) => sum + row.population, 0)}`, ) } main().catch((error) => { console.error(error) process.exit(1) })