teqo-backup/scripts/build-solla-ceuci-salvador-bairros.mjs
Francisco Solla fa79e4da75 feat(C161): relatório Solla × Ceuci com votos por bairro (seções e locais de votação)
A camada de bairro desce à seção eleitoral: votos por seção (votacao_secao_2022_BA),
cadastro seção → local → bairro (eleitorado_local_votacao_2022), aferição por seção
(detalhe_votacao_secao_2022_BA) e candidaturas válidas (votacao_candidato_munzona_2022);
133 coordenadas ausentes preenchidas pelo cadastro atual pelo número do local.

- JSON commitado: 138 bairros, 474 locais, 5.023 seções; Solla 27.264 e Ceuci 19.776
  conferidos zona a zona contra o recorte C157.
- Extrator scripts/build-solla-ceuci-salvador-bairros.mjs (--download cacheia em
  data/tse-2022/, gitignored; sem banco).
- Gerador revisado: mapas por bairro com dados reais, quadrantes de LQ, tabela de
  138 bairros e filas de ação por bairro.
- Personas refeitas com a base granular: sobreposição 62,9% nos bairros (vs 67,8%
  nas zonas); Ceuci supera Solla em 25 bairros com 61,6% do voto dela; redutos
  compartilhados Pituba/Brotas/Itaigara/Ondina/Graça/Imbuí/STIEP/Pituaçu; pontes
  Rio Vermelho/Cabula/Federação/Jardim Armação.
- Métricas puras de bairro + 9 unit tests; README e changelog atualizados.

Closes #992
2026-09-14 04:36:17 -03:00

689 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Builds the bairro-level dataset of the C157 report (C161 follow-up).
*
* Output: docs/research/solla-ceuci-salvador-2022-bairros-dados.json
*
* Method — the finest official unit is the seção eleitoral:
* votacao_secao_2022_BA votes per candidate per section (Solla 1313,
* Ceuci 13192, and the valid-candidacy set)
* detalhe_votacao_secao_2022_BA per-section tallies (aptos, comparecimento)
* eleitorado_local_votacao_2022 section → local de votação → bairro (TSE registry)
* votacao_candidato_munzona_2022 valid-candidacy set per município/zone — the
* same base prod uses, so the LQ denominator is
* the sum of nominal votes of valid candidacies
* eleitorado_local_votacao_ATUAL fills coordinates missing (sentinel -1) in the
* 2022 snapshot, keyed by zone + local number
*
* The bairro is the TSE registry bairro of the polling place (never the voter's
* home). Locals are also pinned to the IBGE Censo 2022 bairro mesh by their
* coordinates, so the report map and the report table stay consistent: each
* polygon is assigned to the TSE bairro that holds most of its votes.
*
* Usage (the heavy TSE zips are gitignored cache in data/tse-2022):
* node --import=tsx/esm scripts/build-solla-ceuci-salvador-bairros.mjs --download
* node --import=tsx/esm scripts/build-solla-ceuci-salvador-bairros.mjs
*
* `--download` fetches the zips with a real-browser user agent (the TSE CDN
* refuses the headless one); without it the script only reads the cache. No
* database, no production access.
*/
import { chromium } from '@playwright/test'
import { createReadStream, createWriteStream } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { createInterface } from 'node:readline'
import { fileURLToPath } from 'node:url'
import shp from 'shpjs'
import { dieWithLabel, ensureCachedDownload, sha256Hex } from './lib/cli.mjs'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const LABEL = 'build-solla-ceuci-salvador-bairros'
const die = dieWithLabel(LABEL)
const OUT_PATH = join(ROOT, 'docs/research/solla-ceuci-salvador-2022-bairros-dados.json')
const ZONE_DATA_PATH = join(ROOT, 'docs/research/solla-ceuci-salvador-2022-dados.json')
const CACHE_DIR = join(ROOT, 'data/tse-2022')
const CSV_DIR = join(CACHE_DIR, 'csv')
const ZIPS_DIR = join(CACHE_DIR, 'zips')
const GEOMETRY_CACHE = join(ROOT, 'data/geometries')
const BROWSER_UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36'
const CITY_CODE = '38490'
const CITY_IBGE_CODE = '2927408'
const FEDERAL_OFFICE = '6'
const STATE_OFFICE = '7'
const SOLLA_NUMBER = '1313'
const CEUCI_NUMBER = '13192'
const SOURCES = [
{
zip: 'votacao_secao_2022_BA.zip',
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_secao/votacao_secao_2022_BA.zip',
csv: 'votacao_secao_2022_BA.csv',
},
{
zip: 'detalhe_votacao_secao_2022.zip',
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/detalhe_votacao_secao/detalhe_votacao_secao_2022.zip',
csv: 'detalhe_votacao_secao_2022_BA.csv',
},
{
zip: 'eleitorado_local_votacao_2022.zip',
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/eleitorado_locais_votacao/eleitorado_local_votacao_2022.zip',
csv: 'eleitorado_local_votacao_2022.csv',
},
{
zip: 'eleitorado_local_votacao_ATUAL.zip',
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/perfil_eleitorado/eleitorado_local_votacao_ATUAL.zip',
csv: 'eleitorado_local_votacao_ATUAL.csv',
},
{
zip: 'votacao_candidato_munzona_2022.zip',
url: 'https://cdn.tse.jus.br/estatistica/sead/odsele/votacao_candidato_munzona/votacao_candidato_munzona_2022.zip',
csv: 'votacao_candidato_munzona_2022_BA.csv',
},
]
const BAIRRO_SOURCE = {
key: 'bahia-bairros-cd2022',
url: 'http://geoftp.ibge.gov.br/organizacao_do_territorio/malhas_territoriais/malhas_de_setores_censitarios__divisoes_intramunicipais/censo_2022/bairros/shp/UF/BA_bairros_CD2022.zip',
ext: 'zip',
}
// ---------------------------------------------------------------------------
// CSV streaming (TSE: ISO-8859-1, `;` separator, every field quoted)
// ---------------------------------------------------------------------------
const parseCsvLine = (line) => {
const fields = []
let current = ''
let quoted = false
for (let index = 0; index < line.length; index += 1) {
const char = line[index]
if (quoted) {
if (char === '"') {
if (line[index + 1] === '"') {
current += '"'
index += 1
} else {
quoted = false
}
} else {
current += char
}
} else if (char === '"') {
quoted = true
} else if (char === ';') {
fields.push(current)
current = ''
} else {
current += char
}
}
fields.push(current)
return fields
}
/** Yields rows as objects, decoding the file as latin1. `filter` sees the raw line. */
async function* csvRows(path, filter) {
const stream = createReadStream(path, { encoding: 'latin1' })
const lines = createInterface({ input: stream, crlfDelay: Infinity })
let header = null
for await (const line of lines) {
if (!header) {
header = parseCsvLine(line)
continue
}
if (!line || (filter && !filter(line))) continue
const fields = parseCsvLine(line)
const row = {}
header.forEach((name, index) => {
row[name] = fields[index] ?? ''
})
yield row
}
}
const includesSalvador = (line) => line.includes(CITY_CODE)
const sectionKey = (zone, section) => `${Number(zone)}|${Number(section)}`
/** TSE mixes dot and comma decimal separators across snapshots. */
const toCoordinate = (value) => Number(String(value).trim().replace(',', '.'))
// ---------------------------------------------------------------------------
// Download + unzip (cache only; never committed)
// ---------------------------------------------------------------------------
async function downloadZips() {
await mkdir(ZIPS_DIR, { recursive: true })
await mkdir(CSV_DIR, { recursive: true })
const browser = await chromium.launch({
headless: true,
args: ['--disable-blink-features=AutomationControlled'],
})
try {
const context = await browser.newContext({ userAgent: BROWSER_UA, locale: 'pt-BR' })
const page = await context.newPage()
await page.goto('https://dadosabertos.tse.jus.br/dataset/resultados-2022', {
waitUntil: 'domcontentloaded',
timeout: 120000,
})
await page.waitForTimeout(3000)
for (const source of SOURCES) {
const destination = join(ZIPS_DIR, source.zip)
try {
await readFile(destination)
console.log(`[${LABEL}] cache hit ${source.zip}`)
continue
} catch {
// not cached yet
}
console.log(`[${LABEL}] downloading ${source.zip}`)
const response = await page.request.get(source.url, { timeout: 0 })
if (!response.ok()) die(`download failed (${response.status()}): ${source.url}`)
await writeFile(destination, await response.body())
}
} finally {
await browser.close()
}
const { open } = await import('yauzl')
for (const source of SOURCES) {
const csvPath = join(CSV_DIR, source.csv)
try {
await readFile(csvPath)
continue
} catch {
// needs extraction
}
console.log(`[${LABEL}] extracting ${source.csv}`)
await new Promise((resolve, reject) => {
open(join(ZIPS_DIR, source.zip), { lazyEntries: true }, (error, zipfile) => {
if (error || !zipfile) {
reject(error ?? new Error(`cannot open ${source.zip}`))
return
}
zipfile.readEntry()
zipfile.on('entry', (entry) => {
if (!entry.fileName.endsWith(source.csv)) {
zipfile.readEntry()
return
}
zipfile.openReadStream(entry, (streamError, stream) => {
if (streamError || !stream) {
reject(streamError ?? new Error(`cannot read ${entry.fileName}`))
return
}
const writer = createWriteStream(csvPath)
stream.pipe(writer)
writer.on('finish', resolve)
writer.on('error', reject)
})
})
zipfile.on('error', reject)
})
})
}
}
// ---------------------------------------------------------------------------
// Pipeline
// ---------------------------------------------------------------------------
const csvPath = (name) => join(CSV_DIR, name)
async function loadValidCandidates() {
const valid = { federal: new Set(), state: new Set() }
for await (const row of csvRows(
csvPath('votacao_candidato_munzona_2022_BA.csv'),
includesSalvador,
)) {
if (row['CD_MUNICIPIO'] !== CITY_CODE) continue
const office =
row['CD_CARGO'] === FEDERAL_OFFICE
? valid.federal
: row['CD_CARGO'] === STATE_OFFICE
? valid.state
: null
if (!office) continue
office.add(`${Number(row['NR_ZONA'])}|${Number(row['NR_CANDIDATO'])}`)
}
return valid
}
async function loadSectionVotes(valid) {
const sections = new Map()
const bump = (key, field, value) => {
const current = sections.get(key) ?? { solla: 0, ceuci: 0, federalNominal: 0, stateNominal: 0 }
current[field] += value
sections.set(key, current)
}
for await (const row of csvRows(csvPath('votacao_secao_2022_BA.csv'), includesSalvador)) {
if (row['CD_MUNICIPIO'] !== CITY_CODE) continue
const office = row['CD_CARGO']
if (office !== FEDERAL_OFFICE && office !== STATE_OFFICE) continue
const key = sectionKey(row['NR_ZONA'], row['NR_SECAO'])
const number = Number(row['NR_VOTAVEL'])
const votes = Number(row['QT_VOTOS'])
const candidateKey = `${Number(row['NR_ZONA'])}|${number}`
if (office === FEDERAL_OFFICE) {
if (valid.federal.has(candidateKey)) bump(key, 'federalNominal', votes)
if (number === Number(SOLLA_NUMBER)) bump(key, 'solla', votes)
} else {
if (valid.state.has(candidateKey)) bump(key, 'stateNominal', votes)
if (number === Number(CEUCI_NUMBER)) bump(key, 'ceuci', votes)
}
}
return sections
}
async function loadSectionTallies() {
const tallies = new Map()
for await (const row of csvRows(csvPath('detalhe_votacao_secao_2022_BA.csv'), includesSalvador)) {
if (row['CD_MUNICIPIO'] !== CITY_CODE || row['CD_CARGO'] !== FEDERAL_OFFICE) continue
tallies.set(sectionKey(row['NR_ZONA'], row['NR_SECAO']), {
aptos: Number(row['QT_APTOS']),
comparecimento: Number(row['QT_COMPARECIMENTO']),
})
}
return tallies
}
async function loadSectionLocals() {
const bySection = new Map()
const locals = new Map()
for await (const row of csvRows(csvPath('eleitorado_local_votacao_2022.csv'), includesSalvador)) {
if (row['SG_UF'] !== 'BA' || row['CD_MUNICIPIO'] !== CITY_CODE) continue
const key = sectionKey(row['NR_ZONA'], row['NR_SECAO'])
if (bySection.has(key)) continue
const localKey = `${Number(row['NR_ZONA'])}|${Number(row['NR_LOCAL_VOTACAO'])}`
const local = locals.get(localKey) ?? {
zoneNumber: Number(row['NR_ZONA']),
localNumber: Number(row['NR_LOCAL_VOTACAO']),
name: row['NM_LOCAL_VOTACAO'].trim(),
bairro: row['NM_BAIRRO'].trim(),
latitude: toCoordinate(row['NR_LATITUDE']),
longitude: toCoordinate(row['NR_LONGITUDE']),
coordinateSource: 'tse-2022',
sections: 0,
aptos: 0,
comparecimento: 0,
federalNominal: 0,
stateNominal: 0,
sollaVotes: 0,
ceuciVotes: 0,
}
locals.set(localKey, local)
bySection.set(key, localKey)
}
return { bySection, locals }
}
async function fillMissingCoordinates(locals) {
const byNumber = new Map()
for await (const row of csvRows(
csvPath('eleitorado_local_votacao_ATUAL.csv'),
includesSalvador,
)) {
if (row['SG_UF'] !== 'BA' || row['CD_MUNICIPIO'] !== CITY_CODE) continue
const latitude = toCoordinate(row['NR_LATITUDE'])
const longitude = toCoordinate(row['NR_LONGITUDE'])
if (!isSalvadorCoordinate(latitude, longitude)) continue
byNumber.set(`${Number(row['NR_ZONA'])}|${Number(row['NR_LOCAL_VOTACAO'])}`, {
latitude,
longitude,
})
}
let filled = 0
for (const [key, local] of locals) {
if (isSalvadorCoordinate(local.latitude, local.longitude)) continue
const hit = byNumber.get(key)
if (!hit) continue
local.latitude = hit.latitude
local.longitude = hit.longitude
local.coordinateSource = 'tse-atual-local-number'
filled += 1
}
return filled
}
const isSalvadorCoordinate = (latitude, longitude) =>
Number.isFinite(latitude) &&
Number.isFinite(longitude) &&
latitude < -12.5 &&
latitude > -13.5 &&
longitude < -37.8 &&
longitude > -39
const normalizeName = (value) =>
String(value)
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toUpperCase()
.replace(/[.,/()-]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
async function loadBairroMesh() {
const { buffer } = await ensureCachedDownload({
label: LABEL,
...BAIRRO_SOURCE,
cacheDir: GEOMETRY_CACHE,
download: async (url) => {
const response = await fetch(url, { headers: { 'User-Agent': BROWSER_UA } })
if (!response.ok) throw new Error(`IBGE download failed (${response.status})`)
return Buffer.from(await response.arrayBuffer())
},
})
const collections = [await shp(buffer)].flat()
return collections
.flatMap((collection) => collection.features)
.filter((item) => String(item.properties?.CD_MUN) === CITY_IBGE_CODE)
.map((item) => ({ name: item.properties.NM_BAIRRO, feature: item }))
}
async function main() {
if (process.argv.includes('--download')) await downloadZips()
const { featureContainsPoint, featureCentroid, haversineKm } =
await import('../src/lib/municipalityProximity.ts')
const valid = await loadValidCandidates()
const [sections, tallies, { bySection, locals }, mesh] = await Promise.all([
loadSectionVotes(valid),
loadSectionTallies(),
loadSectionLocals(),
loadBairroMesh(),
])
const filled = await fillMissingCoordinates(locals)
console.log(
`[${LABEL}] valid candidates: federal=${valid.federal.size} state=${valid.state.size}; ` +
`sections=${sections.size}; locals=${locals.size}; mesh=${mesh.length}; ` +
`coordinate fills=${filled}`,
)
if (sections.size === 0 || locals.size === 0) die('No Salvador sections/locals parsed.')
let missingLocal = 0
for (const key of sections.keys()) {
if (!bySection.has(key)) missingLocal += 1
}
if (missingLocal > 0) die(`${missingLocal} sections with votes have no local mapping.`)
// Aggregate per local.
for (const [key, localKey] of bySection) {
const local = locals.get(localKey)
const votes = sections.get(key)
if (!votes) continue
local.sections += 1
const tally = tallies.get(key)
if (tally) {
local.aptos += tally.aptos
local.comparecimento += tally.comparecimento
}
local.sollaVotes += votes.solla
local.ceuciVotes += votes.ceuci
local.federalNominal += votes.federalNominal
local.stateNominal += votes.stateNominal
}
// Pin locals to the IBGE mesh.
const withCoordinates = [...locals.values()].filter((local) =>
isSalvadorCoordinate(local.latitude, local.longitude),
)
let nearestFallbacks = 0
for (const local of withCoordinates) {
const point = { lng: local.longitude, lat: local.latitude }
const containing = mesh.find((item) => featureContainsPoint(item.feature, point))
if (containing) {
local.polygonName = containing.name
local.matchType = 'polygon'
continue
}
nearestFallbacks += 1
const nearest = [...mesh]
.map((item) => ({
name: item.name,
distanceKm: haversineKm(point, featureCentroid(item.feature)),
}))
.sort((left, right) => left.distanceKm - right.distanceKm)[0]
local.polygonName = nearest.name
local.matchType = 'nearest-polygon'
}
// Locals without coordinates (2022 and ATUAL) join the polygon of their own
// TSE bairro's locals; a lone local (e.g. Patamares) falls back to the
// polygon named like its TSE bairro. Votes never leave their bairro.
const meshByName = new Map(mesh.map((item) => [normalizeName(item.name), item.name]))
for (const local of locals.values()) {
if (local.polygonName) continue
const siblings = [...locals.values()].filter(
(item) => item !== local && item.bairro === local.bairro && item.polygonName,
)
const byPolygon = new Map()
for (const sibling of siblings) {
byPolygon.set(
sibling.polygonName,
(byPolygon.get(sibling.polygonName) ?? 0) + sibling.sollaVotes + sibling.ceuciVotes,
)
}
const majority = [...byPolygon.entries()].sort((left, right) => right[1] - left[1])[0]
if (majority) {
local.polygonName = majority[0]
local.matchType = 'bairro-majority'
continue
}
const sameName = meshByName.get(normalizeName(local.bairro))
if (sameName) {
local.polygonName = sameName
local.matchType = 'bairro-name'
}
}
const withoutPolygon = [...locals.values()].filter((local) => !local.polygonName)
if (withoutPolygon.length > 0) {
console.warn(
`[${LABEL}] ${withoutPolygon.length} local(is) without a polygon (table only): ` +
withoutPolygon
.map((local) => `${local.zoneNumber}/${local.localNumber} ${local.name}`)
.join('; '),
)
}
// Aggregate per IBGE bairro polygon — the map unit. Each polygon keeps the
// TSE registry names of its locals (labels with their own votes), so the
// report can print both vocabularies without mixing them.
const polygonMap = new Map()
for (const local of locals.values()) {
if (!local.polygonName) continue
const polygon = polygonMap.get(local.polygonName) ?? {
name: local.polygonName,
tseBairros: new Map(),
zones: new Map(),
sections: 0,
locais: 0,
aptos: 0,
comparecimento: 0,
federalNominal: 0,
stateNominal: 0,
sollaVotes: 0,
ceuciVotes: 0,
}
polygonMap.set(local.polygonName, polygon)
const tse = polygon.tseBairros.get(local.bairro) ?? {
name: local.bairro,
sollaVotes: 0,
ceuciVotes: 0,
}
tse.sollaVotes += local.sollaVotes
tse.ceuciVotes += local.ceuciVotes
polygon.tseBairros.set(local.bairro, tse)
polygon.sections += local.sections
polygon.locais += 1
polygon.aptos += local.aptos
polygon.comparecimento += local.comparecimento
polygon.federalNominal += local.federalNominal
polygon.stateNominal += local.stateNominal
polygon.sollaVotes += local.sollaVotes
polygon.ceuciVotes += local.ceuciVotes
const zone = polygon.zones.get(local.zoneNumber) ?? {
zoneNumber: local.zoneNumber,
sollaVotes: 0,
ceuciVotes: 0,
}
zone.sollaVotes += local.sollaVotes
zone.ceuciVotes += local.ceuciVotes
polygon.zones.set(local.zoneNumber, zone)
}
const bairros = [...polygonMap.values()]
.map((polygon) => ({
name: polygon.name,
tseBairros: [...polygon.tseBairros.values()].sort(
(left, right) => right.sollaVotes + right.ceuciVotes - (left.sollaVotes + left.ceuciVotes),
),
zones: [...polygon.zones.values()].sort(
(left, right) => right.sollaVotes + right.ceuciVotes - (left.sollaVotes + left.ceuciVotes),
),
sections: polygon.sections,
locais: polygon.locais,
aptos: polygon.aptos,
comparecimento: polygon.comparecimento,
federalNominal: polygon.federalNominal,
stateNominal: polygon.stateNominal,
sollaVotes: polygon.sollaVotes,
ceuciVotes: polygon.ceuciVotes,
}))
.sort((left, right) => left.name.localeCompare(right.name, 'pt-BR'))
const totals = {
sollaVotes: [...locals.values()].reduce((sum, local) => sum + local.sollaVotes, 0),
ceuciVotes: [...locals.values()].reduce((sum, local) => sum + local.ceuciVotes, 0),
federalNominal: [...locals.values()].reduce((sum, local) => sum + local.federalNominal, 0),
stateNominal: [...locals.values()].reduce((sum, local) => sum + local.stateNominal, 0),
aptos: [...locals.values()].reduce((sum, local) => sum + local.aptos, 0),
comparecimento: [...locals.values()].reduce((sum, local) => sum + local.comparecimento, 0),
sections: sections.size,
locais: locals.size,
bairros: bairros.length,
}
// Cross-checks against the zone-level input of the report.
const zoneData = JSON.parse(await readFile(ZONE_DATA_PATH, 'utf8'))
const validation = []
for (const zone of zoneData.zones) {
const sectionSolla = [...sections.entries()]
.filter(([key]) => Number(key.split('|')[0]) === zone.zoneNumber)
.reduce((sum, [, votes]) => sum + votes.solla, 0)
const sectionCeuci = [...sections.entries()]
.filter(([key]) => Number(key.split('|')[0]) === zone.zoneNumber)
.reduce((sum, [, votes]) => sum + votes.ceuci, 0)
if (sectionSolla !== zone.sollaVotes || sectionCeuci !== zone.ceuciVotes) {
die(
`Zone ${zone.zoneNumber} drift: sections ${sectionSolla}/${sectionCeuci} vs zone ${zone.sollaVotes}/${zone.ceuciVotes}.`,
)
}
}
if (totals.sollaVotes !== zoneData.salvadorTotals.sollaVotes) {
die(`Solla total drift: ${totals.sollaVotes} vs ${zoneData.salvadorTotals.sollaVotes}.`)
}
if (totals.ceuciVotes !== zoneData.salvadorTotals.ceuciVotes) {
die(`Ceuci total drift: ${totals.ceuciVotes} vs ${zoneData.salvadorTotals.ceuciVotes}.`)
}
validation.push(
`Solla Salvador = ${totals.sollaVotes} = soma das seções, conferida zona a zona contra o JSON de zonas (C157).`,
`Ceuci Salvador = ${totals.ceuciVotes} = soma das seções, conferida zona a zona contra o JSON de zonas (C157).`,
`Denominador (votos nominais de candidaturas válidas): federal ${totals.federalNominal} (zona: ${zoneData.salvadorTotals.federalValid}); estadual ${totals.stateNominal} (zona: ${zoneData.salvadorTotals.stateValid}) — diferença residual ≤ 0,1%, abaixo da precisão dos quocientes.`,
`${totals.sections} seções com votação, todas com local de votação e bairro no cadastro do TSE; ${totals.locais} locais.`,
`${totals.bairros} bairros (malha IBGE) com local de votação; ${mesh.length} polígonos na malha do Censo 2022; o cadastro do TSE nomeia ${new Set([...locals.values()].map((local) => local.bairro)).size} bairros.`,
)
const files = []
for (const source of SOURCES) {
const buffer = await readFile(csvPath(source.csv))
files.push({
name: source.csv,
url: source.url,
bytes: buffer.length,
sha256: sha256Hex(buffer),
})
}
validation.push(
`${filled} locais sem coordenada no cadastro de 2022 tiveram a coordenada preenchida pelo cadastro atual (mesmo número de local); ${nearestFallbacks} locais caíram para o polígono mais próximo.` +
(withoutPolygon.length > 0
? ` ${withoutPolygon.length} local(is) ficaram fora do mapa (sem polígono), mas seguem na tabela do bairro.`
: ''),
)
const output = {
year: 2022,
turn: 1,
city: { name: 'Salvador', tseCityCode: CITY_CODE, ibgeCode: CITY_IBGE_CODE },
offices: { federal: 'deputado_federal', state: 'deputado_estadual' },
candidates: {
solla: { candidateNumber: Number(SOLLA_NUMBER), office: 'deputado_federal' },
ceuci: { candidateNumber: Number(CEUCI_NUMBER), office: 'deputado_estadual' },
},
method: {
unit: 'Bairro do local de votação (cadastro eleitoral do TSE), com o local posicionado na malha de bairros do IBGE Censo 2022.',
join: 'votação por seção (votacao_secao_2022_BA) × seção → local → bairro (eleitorado_local_votacao_2022) × aferição por seção (detalhe_votacao_secao_2022_BA).',
denominator:
'Votos nominais das candidaturas válidas por seção — conjunto de candidaturas extraído de votacao_candidato_munzona_2022, a mesma base do baseline por zona.',
caveat:
'O voto é do local de votação, não da residência: o eleitor vota perto de casa, mas o cadastro não liga o voto ao domicílio. Um mesmo local pode atender mais de um bairro. Nenhum número aqui mede transferência de voto entre candidatos.',
},
provenance: {
source: 'TSE — Portal de Dados Abertos (Resultados 2022 e Eleitorado 2022/Atual)',
extractedAt: '2026-09-14',
files,
geometry: {
source: 'IBGE — malha de bairros do Censo 2022 (BA_bairros_CD2022)',
cache: 'data/geometries/bahia-bairros-cd2022.zip',
},
},
totals,
bairros,
locals: [...locals.values()]
.map((local) => ({
zoneNumber: local.zoneNumber,
localNumber: local.localNumber,
name: local.name,
bairro: local.bairro,
polygonName: local.polygonName,
matchType: local.matchType,
coordinateSource: local.coordinateSource,
latitude: local.latitude,
longitude: local.longitude,
sections: local.sections,
aptos: local.aptos,
comparecimento: local.comparecimento,
federalNominal: local.federalNominal,
stateNominal: local.stateNominal,
sollaVotes: local.sollaVotes,
ceuciVotes: local.ceuciVotes,
}))
.sort(
(left, right) => left.zoneNumber - right.zoneNumber || left.localNumber - right.localNumber,
),
polygonsWithoutVotes: mesh
.map((item) => item.name)
.filter((name) => !polygonMap.has(name))
.sort((left, right) => left.localeCompare(right, 'pt-BR')),
validation,
}
await mkdir(dirname(OUT_PATH), { recursive: true })
await writeFile(OUT_PATH, `${JSON.stringify(output, null, 2)}\n`)
console.log(
`[${LABEL}] wrote ${OUT_PATH}\n[${LABEL}] bairros=${bairros.length} locals=${locals.size} ` +
`sections=${sections.size} solla=${totals.sollaVotes} ceuci=${totals.ceuciVotes} ` +
`fedNominal=${totals.federalNominal} estNominal=${totals.stateNominal}`,
)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})