candeia 0.0.0-stage → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DADOS.md ADDED
@@ -0,0 +1,33 @@
1
+ # Licença e fonte dos dados
2
+
3
+ O código deste repositório está sob a licença MIT (ver `LICENSE`). Os dados não fazem parte do repositório e têm licença própria.
4
+
5
+ ## Fonte
6
+
7
+ O índice publicado (`manifesto.json` e os arquivos `<UF>/<município>...json.gz`) é derivado do **Cadastro Nacional de Endereços para Fins Estatísticos (CNEFE), Censo Demográfico 2022, do Instituto Brasileiro de Geografia e Estatística (IBGE)**.
8
+
9
+ O índice é uma transformação do CNEFE feita por `pipeline/`: seleção de colunas, normalização do nome da via, descarte de coordenadas de centróide (NV_GEO_COORD 5 e 6), agrupamento por via e compressão. Nenhum dado de outra fonte é acrescentado. O IBGE não endossa este projeto nem os resultados do geocodificador.
10
+
11
+ ## Licença do índice
12
+
13
+ O índice é distribuído sob a [Creative Commons Atribuição 4.0 Internacional (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.pt-br). Quem redistribuir o índice, ou uma derivação dele, deve citar as duas fontes:
14
+
15
+ > Dados: IBGE, Cadastro Nacional de Endereços para Fins Estatísticos (CNEFE), Censo Demográfico 2022. Índice: Candeia (https://github.com/LucasRikelmi/candeia), CC BY 4.0.
16
+
17
+ Usar o geocodificador para consultar endereços não exige atribuição. A atribuição vale para quem copia ou republica os arquivos do índice.
18
+
19
+ ## Termos de uso do CNEFE (conferido em 2026-11-14)
20
+
21
+ O que foi encontrado:
22
+
23
+ - **Não há licença explícita para o CNEFE.** A [página de divulgação dos microdados do CNEFE 2022](https://www.ibge.gov.br/comunicados/40157-ibge-divulga-microdados-do-cadastro-nacional-de-enderecos-para-fins-estatisticos-cnefe-oriundos-do-censo-demografico-2022) não traz termo de uso, licença nem regra de citação. Ela só aponta o termo de uso e a política de privacidade gerais do portal.
24
+ - **A posição do IBGE sobre o conteúdo do portal** é a da resposta oficial do atendimento (protocolo 8171/2009, de 08/01/2009), divulgada na [lista do OpenStreetMap](https://lists.openstreetmap.org/pipermail/imports/2013-July/001981.html). Segundo ela, os conteúdos do portal são de domínio público, e o uso em outras publicações ou portais está condicionado à citação da fonte (IBGE). É com base nessa posição que o OpenStreetMap usa dados do IBGE ([wiki](https://wiki.openstreetmap.org/Pt-br:Tag:source=IBGE)).
25
+ - **Dados pessoais:** o índice não guarda nome de estabelecimento, complemento de unidade (apartamento, sala) nem qualquer campo além de via, tipo, número, quadra/lote, localidade, CEP, coordenada, nível da coordenada e a contagem de pontos repetidos.
26
+
27
+ Consequência: a licença CC BY 4.0 do índice está em linha com essa posição, porque exige atribuição e a atribuição acima cita o IBGE primeiro.
28
+
29
+ Limites desta conferência:
30
+ - A resposta de 2009 é anterior ao CNEFE 2022 e não é um termo formal.
31
+ - Ela não é uma análise jurídica.
32
+
33
+ Se o IBGE publicar termo específico para o CNEFE, ou exigir outra forma de atribuição, o termo do IBGE prevalece sobre este texto.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lucas Rikelmi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,156 @@
1
- # Temporary Holding Version
1
+ # Candeia
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Geocodificação de endereços brasileiros que roda no seu próprio código, sem servidor, sem chave de API e sem custo por consulta.
4
+
5
+ > **Status: v1.1.1.** O motor, o índice das 27 UFs e o loader funcionam. O índice está publicado em `https://dados.lucasrikelmi.dev/cnefe2022/v1/` (Cloudflare Pages, CC BY 4.0, ver [DADOS.md](DADOS.md)). Instalação pelo npm (ver [Uso](#uso)). Os números de acurácia vêm de um benchmark sintético sobre o próprio CNEFE (132 municípios, 19.800 endereços), descrito em [docs/ARQUITETURA.md](docs/ARQUITETURA.md). Um benchmark de regressão roda no CI a cada PR.
6
+
7
+ ## O problema
8
+
9
+ Quem precisa transformar um endereço brasileiro em coordenada hoje tem, na prática, duas saídas:
10
+
11
+ 1. **APIs comerciais (Google Maps e similares).** Funcionam bem, mas cobram por consulta, exigem chave e mandam os endereços dos seus clientes para um terceiro.
12
+ 2. **Ferramentas de pesquisa baseadas no CNEFE do IBGE.** Gratuitas e offline, mas pensadas para análise em lote por quem programa em R, com download de dados grande e sem suporte a quadra e lote, que é como muito endereço brasileiro realmente se escreve (Goiânia, Distrito Federal, Palmas, loteamentos em geral).
13
+
14
+ Falta uma opção gratuita que rode dentro de um site ou de um backend comum e entenda o jeito brasileiro de escrever endereço.
15
+
16
+ ## A premissa
17
+
18
+ O IBGE publica o CNEFE, o cadastro de endereços usado no Censo, com coordenadas. Com ele dá para montar um geocodificador **estático**:
19
+
20
+ - **Sem servidor.** O código roda no navegador ou no Node de quem usa. Os dados são arquivos estáticos públicos. Nenhuma lógica roda do nosso lado, e o host dos arquivos nunca vê o endereço consultado, só qual município foi baixado.
21
+ - **Dados por município.** Quem consulta Belo Horizonte baixa o arquivo de Belo Horizonte, não o Brasil. O arquivo fica em memória (e no cache HTTP do navegador), e a segunda consulta não usa rede. Quem quiser tudo no próprio ambiente pode espelhar os arquivos do índice (CC BY 4.0) e apontar `base` para lá.
22
+ - **Resposta com qualidade explícita.** Cada resultado traz o nível em que o endereço foi encontrado e uma estimativa de erro em metros, em vez de fingir precisão que não tem.
23
+ - **Endereço do jeito que é digitado.** Abreviações ("R.", "Av.", "Tv."), rodovias com a sigla da UF, quadra e lote no lugar do número de porta, complemento no campo errado.
24
+
25
+ ## Uso
26
+
27
+ Node 22 ou mais (sem dependências):
28
+
29
+ ```sh
30
+ npm install candeia
31
+ ```
32
+
33
+ No navegador, direto de um CDN:
34
+
35
+ ```js
36
+ import { criarGeocodificador } from "https://cdn.jsdelivr.net/npm/candeia@1.1.1/src/index.js";
37
+ ```
38
+
39
+ ```js
40
+ import { criarGeocodificador } from "candeia";
41
+
42
+ const geo = criarGeocodificador({ base: "https://dados.lucasrikelmi.dev/cnefe2022/v1/" });
43
+ const r = await geo.geocodificar("Rua 8, Qd 21 Lt 4", {
44
+ municipio: "5208707", // código IBGE
45
+ bairro: "Setor Central",
46
+ cep: "74000-000",
47
+ });
48
+
49
+ // {
50
+ // status: "verde" | "azul" | "amarelo" | "laranja" | "vermelho",
51
+ // metodo: "numero" | "quadra_lote" | "lote" | "quadra" | "interpolacao" | "eixo" | "cep" | "cruzamento" | "falha",
52
+ // coord: { lat, lon },
53
+ // raio, // metros: até onde o ponto pode estar (null quando não dá para estimar)
54
+ // confianca, // { ate100m, acima1km, amostra }: frações medidas no benchmark para essa cor e raio
55
+ // viaKey, motivo, ...
56
+ // }
57
+ ```
58
+
59
+ Município pequeno é um arquivo só. Município grande baixa o índice de vias e só a fatia da via pedida: no benchmark, uma consulta fria em São Paulo baixa ~1,3 MB e a seguinte na mesma via não baixa nada. O loader confere o sha256 de cada arquivo contra o manifesto. A página `exemplo/index.html` mostra o uso no navegador. Fora do navegador, passe um `fetch` com `User-Agent` próprio, por exemplo `criarGeocodificador({ base, fetch: (u) => fetch(u, { headers: { "User-Agent": "meu-sistema/1.0" } }) })`: a Cloudflare barra assinaturas genéricas de cliente, como a do Python (ver [docs/PUBLICACAO.md](docs/PUBLICACAO.md)).
60
+
61
+ Medido no benchmark (cenário com o endereço no índice, variante limpa; rodada 9): 74,5% até 100 m no geral e 92,9% com número de porta; com erro de digitação no nome, 73,7%. Verde acerta até 100 m em 99,8% dos casos. Endereço sem número fica em ~38%, limitado pela extensão da via, e o `raio` diz isso. Rua que não existe na cidade volta vermelho (centro do município) em 83% dos casos; no resto, casa com uma rua de nome parecido, e a cor e o `raio` mostram a incerteza.
62
+
63
+ Fluxo de uma consulta:
64
+
65
+ 1. **Normalização local.** Tipo da via, nome, número, quadra e lote são extraídos e padronizados.
66
+ 2. **Município** pelo código IBGE de 7 dígitos, informado por quem chama.
67
+ 3. **Download do arquivo do município** (ou do índice de vias e da fatia da via, nos grandes), ou leitura do que já está em memória.
68
+ 4. **Busca por token do nome da via**, para não comparar com todas as ruas da cidade.
69
+ 5. **Pontuação do nome** com Jaro-Winkler e Jaccard por tokens. Bairro e CEP desempatam ruas homônimas.
70
+ 6. **Refinamento por número, quadra e lote.** Sem número, o motor tenta interpolar entre vizinhos, depois cai para o eixo da via, o CEP e o centro do município. `metodo` diz até onde chegou e `raio` quanto o ponto pode estar longe.
71
+
72
+ ## Dados
73
+
74
+ - **Fonte:** CNEFE, Censo 2022, IBGE. O IBGE trata o conteúdo do portal como domínio público, com citação obrigatória da fonte. O índice sai sob CC BY 4.0. O que foi conferido e os limites dessa conferência estão em [DADOS.md](DADOS.md).
75
+ - **Formato:** o artefato canônico do pipeline é Parquet sem perda, particionado por UF e município (CSV 18,2 GB → Parquet 3,2 GB no país). O que o cliente baixa é um índice próprio em JSON comprimido com gzip: um arquivo por município (abaixo de 1 MB em 5.425 dos 5.570) e, nos 145 maiores, um índice de vias mais fatias de pontos de até ~0,6 MB. O país inteiro soma ~1 GB; o pior caso de uma consulta (São Paulo capital) baixa ~1,3 MB. Detalhes e medições em `docs/ARQUITETURA.md`.
76
+ - **Pipeline:** os dados nunca entram por pull request. Eles são gerados apenas pelo pipeline do repositório, de forma reproduzível.
77
+ - **Medições:** as análises que definiram as regras (tipos de logradouro, rodovias por UF, tamanho por município, DF) estão em `docs/ARQUITETURA.md` e nos scripts `pipeline/medir_*.py`.
78
+
79
+ Cada UF escreve endereço de um jeito. Na amostra inicial, o DF tem 96% dos registros com tipo "EDF" e 97% sem número de porta, com o endereço real em quadra, conjunto e lote. O AC tem 57% de CEPs genéricos da cidade e vias rurais como ramal, igarapé e seringal. Um motor que só funciona com "rua + número" não cobre isso.
80
+
81
+ ## Qualidade e honestidade sobre os números
82
+
83
+ - Um score de similaridade não é probabilidade. A `confianca` devolvida é a fração medida no benchmark para a mesma cor e faixa de raio, validada fora da amostra (metade dos municípios ajusta, a outra mede). Ela não é separada por UF de propósito: a tabela por UF, mesmo encolhida para a nacional, errou mais nos municípios que ficaram fora do ajuste (Brier 0,0959 contra 0,0917 da nacional). A diferença entre municípios da mesma UF é maior que a diferença entre UFs, e a cor e o raio já carregam o que importa.
84
+ - O benchmark sintético sorteia endereços do próprio CNEFE e os suja (abreviações, erros de digitação, campos faltando). Ele é otimista por construção, porque o gabarito vem da mesma base. Ainda não há validação com pontos conferidos em campo.
85
+ - O CNEFE é de 2022. Endereços novos não existem nele.
86
+ - Coordenadas aproximadas servem para mapa, roteirização grosseira, estatística e triagem. Não servem para localizar um portão específico quando o nível devolvido for `logradouro` ou menor.
87
+
88
+ ## Relação com outros projetos
89
+
90
+ O [geocodebr](https://ipeagit.github.io/geocodebr), do IPEA, também usa o CNEFE, funciona offline e retorna nível de precisão e desvio em metros. É mais maduro e cobre geocodificação reversa e busca por CEP. A proposta do Candeia é outra: rodar em JavaScript (navegador e Node), baixar só o município consultado e tratar quadra e lote.
91
+
92
+ Comparação nas mesmas 66 mil consultas (≤100 m: fração de respostas a até 100 m do ponto certo; >1 km: fração que errou por mais de 1 km) do benchmark (geocodebr 0.7.0, entrada em campos separados; scripts em `bench/geocodebr/`). Os dois usam o CNEFE 2022 e o gabarito também vem dele, então mede o que cada motor faz com a mesma entrada, não a acurácia no mundo real.
93
+
94
+ | grupo | ≤100 m Candeia (maior é melhor) | ≤100 m geocodebr (maior é melhor) | >1 km Candeia (menor é melhor) | >1 km geocodebr (menor é melhor) |
95
+ |---|---|---|---|---|
96
+ | endereço limpo | 75,8% | 73,1% | 8,0% | 8,1% |
97
+ | com número de porta | 93,2% | 92,5% | 1,8% | 1,8% |
98
+ | quadra e lote | 91,9% | 58,1% | 1,6% | 2,4% |
99
+ | sem número | 34,0% | 30,1% | 22,9% | 23,4% |
100
+ | sem bairro e CEP | 70,9% | 67,8% | 13,3% | 16,2% |
101
+ | erro de digitação no nome | 74,9% | 74,1% | 7,7% | 7,2% |
102
+ | erro dentro da margem declarada, ou até 100 m (`raio` × `desvio_metros`, limpo) | 91,7% | 93,7% | — | — |
103
+
104
+ Leitura:
105
+
106
+ - Com número de porta os dois empatam: a diferença (0,7 ponto) está dentro do ruído de uma amostra desse tamanho. A vantagem do Candeia no "endereço limpo" vem quase toda de quadra e lote e de sem número.
107
+ - A vantagem do Candeia em quadra e lote vem de o geocodebr não ter campo para quadra e lote: ele recebeu só o logradouro. É uma diferença de escopo, não de qualidade do casamento, e são só 627 consultas.
108
+ - Sem bairro e CEP o Candeia erra menos acima de 1 km (13,3% contra 16,2%).
109
+ - Com erro de digitação, a primeira medição deu vantagem ao geocodebr (74,1% contra 72,7%): ele usava o número de porta quando a rua só casava por semelhança, e o Candeia não. Depois da correção (rodada 9) o Candeia passa a 74,9% até 100 m, mas o geocodebr ainda erra menos acima de 1 km (7,2% contra 7,7%).
110
+ - O geocodebr também é mais honesto sobre a própria margem: o erro fica dentro do `desvio_metros` que ele declara em 93,7% dos casos, contra 91,7% do `raio` do Candeia.
111
+
112
+ Limites desta comparação:
113
+
114
+ - O gabarito sai do CNEFE, a mesma base dos dois motores. Isso favorece quem reproduz o CNEFE com fidelidade e não diz nada sobre endereço fora dele.
115
+ - As variantes com erro foram geradas pelo benchmark do Candeia, que também escolheu os grupos.
116
+ - O geocodebr recebeu os campos já separados. Na prática, quem usa o geocodebr precisa separar o endereço antes, e o Candeia recebe o texto livre: aqui o geocodebr teve essa ajuda.
117
+ - Não foram medidos tempo, memória nem tamanho de download.
118
+
119
+ ## Roadmap
120
+
121
+ - [x] Núcleo de normalização e matching em JavaScript, portado do geocodificador do NEXO, com testes de regressão
122
+ - [x] Pipeline de dados (CNEFE → Parquet por município → índice) com manifesto e hash
123
+ - [x] Medição de tamanho por município e escolha do formato de distribuição
124
+ - [x] Suporte a quadra e lote, incluindo o endereço estruturado do DF
125
+ - [x] Interpolação para número de porta inexistente
126
+ - [x] Benchmark sintético e calibração de confiança por cor e raio
127
+ - [x] Índice publicado e loader sob demanda
128
+ - [x] Benchmark de regressão no CI
129
+ - [ ] Validação com pontos conferidos em campo
130
+ - [x] Calibração de confiança por UF: medida e descartada, piora fora da amostra (ver abaixo)
131
+ - [x] Comparação com o geocodebr nos mesmos endereços (ver [Relação com outros projetos](#relação-com-outros-projetos))
132
+ - [x] Pacote no npm
133
+ - [x] Guia de contribuição com casos de teste por UF ([CONTRIBUTING.md](CONTRIBUTING.md))
134
+
135
+ ## Desenvolvimento
136
+
137
+ ```sh
138
+ npm test # Node >= 22, sem dependências
139
+ node bench/ci.mjs # benchmark de regressão sobre o índice publicado (~12 MB, ~25 s)
140
+ ```
141
+
142
+ ## Contribuindo
143
+
144
+ A ideia é que contribuir seja barato: o jeito de escrever endereço de cada UF fica em casos de teste legíveis (`test/casos/<UF>.json`, "entrada, resultado esperado"), sem precisar saber JavaScript. Ver [CONTRIBUTING.md](CONTRIBUTING.md). O CI roda um benchmark de regressão em todo pull request e falha se a acurácia cair mais de 1 ponto em qualquer grupo (`bench/ci.mjs`). Mudança intencional atualiza a referência com `node bench/ci.mjs --atualizar`.
145
+
146
+ ## Origem
147
+
148
+ O motor parte do geocodificador do NEXO, ferramenta de gestão de operações de campo do mesmo autor, hoje restrito a GO. O Candeia o generaliza para as 27 UFs e o separa como biblioteca independente.
149
+
150
+ ## Nome
151
+
152
+ Candeia é a lamparina de óleo. Na parábola da moeda perdida, a mulher acende a candeia e varre a casa até achar. É isso que o motor faz: procura com a luz que tem e diz até onde conseguiu enxergar.
153
+
154
+ ## Licença
155
+
156
+ Código: [MIT](LICENSE). Índice de dados: [CC BY 4.0](DADOS.md), derivado do CNEFE 2022 do IBGE, com atribuição obrigatória ao IBGE em redistribuições.
package/package.json CHANGED
@@ -1,6 +1,19 @@
1
1
  {
2
2
  "name": "candeia",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.1",
4
+ "description": "Geocodificação de endereços brasileiros a partir do CNEFE (IBGE), sem servidor e sem chave de API.",
5
+ "keywords": ["geocodificacao", "geocoding", "endereco", "cnefe", "ibge", "brasil", "quadra", "lote"],
6
+ "type": "module",
7
+ "exports": "./src/index.js",
8
+ "files": ["src/", "DADOS.md"],
9
+ "engines": { "node": ">=22" },
10
+ "scripts": {
11
+ "test": "node --test \"test/**/*.test.js\""
12
+ },
13
+ "repository": { "type": "git", "url": "git+https://github.com/LucasRikelmi/candeia.git" },
14
+ "homepage": "https://github.com/LucasRikelmi/candeia#readme",
15
+ "bugs": "https://github.com/LucasRikelmi/candeia/issues",
16
+ "author": "Lucas Rikelmi",
17
+ "license": "MIT",
18
+ "publishConfig": { "access": "public", "provenance": true }
19
+ }
@@ -0,0 +1,18 @@
1
+ // Candeia — probabilidade de acerto por cor e faixa de raio, medida no benchmark
2
+ // (bench/calibrar.mjs gera calibracao_tabela.js). É a calibração do gabarito sintético: o CNEFE
3
+ // contra ele mesmo, com ruído de digitação simulado. Não substitui validação de campo.
4
+ import { CALIBRACAO } from "./calibracao_tabela.js";
5
+
6
+ export const FAIXAS_RAIO = [0, 50, 100, 250, 500, 1000];
7
+
8
+ export function faixaRaio(raio) {
9
+ if (raio == null) return "?";
10
+ for (const t of FAIXAS_RAIO) if (raio <= t) return `<=${t}`;
11
+ return ">1000";
12
+ }
13
+
14
+ // { ate100m, acima1km, amostra } ou null quando a cor não tem célula medida.
15
+ export function confiancaDe(status, raio) {
16
+ const c = CALIBRACAO[`${status}|${faixaRaio(raio)}`] || CALIBRACAO[status];
17
+ return c ? { ate100m: c[0], acima1km: c[1], amostra: c[2] } : null;
18
+ }
@@ -0,0 +1,5 @@
1
+ // GERADO por bench/calibrar.mjs — não editar à mão.
2
+ // Fonte: benchmark sintético (119888 respostas, 132 municípios, gabarito = CNEFE 2022).
3
+ // chave "cor|faixa de raio" (ou só "cor") → [P(erro <= 100 m), P(erro > 1 km), n]
4
+ export const CALIBRACAO = { "amarelo": [ 0.598, 0.015, 36994 ], "amarelo|<=0": [ 0.959, 0.019, 537 ], "amarelo|<=100": [ 0.855, 0.011, 5197 ], "amarelo|<=1000": [ 0.392, 0.018, 1139 ], "amarelo|<=250": [ 0.494, 0.013, 12844 ], "amarelo|<=50": [ 0.944, 0.012, 8610 ], "amarelo|<=500": [ 0.262, 0.021, 8667 ], "azul": [ 0.857, 0.024, 509 ], "laranja": [ 0.245, 0.399, 24326 ], "laranja|<=100": [ 0.835, 0.076, 369 ], "laranja|<=1000": [ 0.124, 0.115, 4928 ], "laranja|<=250": [ 0.446, 0.034, 523 ], "laranja|<=50": [ 0.885, 0.077, 312 ], "laranja|<=500": [ 0.211, 0.182, 592 ], "laranja|>1000": [ 0.201, 0.552, 15775 ], "laranja|?": [ 0.66, 0.157, 1651 ], "verde": [ 0.99, 0.004, 57529 ], "verde|<=0": [ 0.988, 0.005, 40244 ], "verde|<=100": [ 0.984, 0.001, 1800 ], "verde|<=50": [ 0.995, 0.002, 15485 ], "vermelho": [ 0, 0.911, 530 ], "vermelho|?": [ 0, 0.911, 530 ] };
5
+ export const CALIBRACAO_VALIDACAO = {"dobra_0":{"cor_e_raio":{"erro_calib_100m":1.32,"erro_calib_1km":0.79,"brier_100m":0.0876,"brier_1km":0.0445},"so_cor":{"erro_calib_100m":1.46,"erro_calib_1km":0.55,"brier_100m":0.1134,"brier_1km":0.0524}},"dobra_1":{"cor_e_raio":{"erro_calib_100m":1.58,"erro_calib_1km":1.02,"brier_100m":0.0958,"brier_1km":0.0508},"so_cor":{"erro_calib_100m":1.5,"erro_calib_1km":0.61,"brier_100m":0.1216,"brier_1km":0.0587}}};
package/src/cnefe.js ADDED
@@ -0,0 +1,100 @@
1
+ // Portado do geo_core.js V2.2 do NEXO, sem mudança de comportamento.
2
+ import { normalizeBase, normNum, extensoParaNumeral } from "./normalizar.js";
3
+
4
+ // ----------------------------------------------------------------------
5
+ // 3) EXTRAÇÃO DE ENTIDADES — LADO ETL (linha bruta do CNEFE)
6
+ // Recebe um objeto com as colunas reais do CNEFE.
7
+ //
8
+ // NV_GEO_COORD semântica oficial:
9
+ // 1 = coordenada original da base pública (porta exata)
10
+ // 2 = modificada (aptos empilhados no mesmo número)
11
+ // 3 = estimada (sem coord original ou coord inválida)
12
+ // 4 = face de quadra (~dezenas de metros)
13
+ // 5 = centróide de localidade → NÃO indexar
14
+ // 6 = centróide de setor de coleta → NÃO indexar
15
+ // ----------------------------------------------------------------------
16
+
17
+ // #13 — Parser robusto para o bug de duplo ponto nas coordenadas do CNEFE.
18
+ // Exemplos: "-16.113.125" → -16.113125; "-47.948.869" → -47.948869.
19
+ // 4+ pontos = dado irreparável → NaN (será filtrado por isNaN).
20
+ export function parseCnefeLonLat(raw) {
21
+ const s = String(raw || "").trim().replace(",", ".");
22
+ const pts = s.split(".");
23
+ if (pts.length === 1) return parseFloat(s);
24
+ if (pts.length === 2) return parseFloat(s);
25
+ if (pts.length === 3) return parseFloat(pts[0] + "." + pts[1] + pts[2]);
26
+ return NaN; // 4+ pontos: dado inválido
27
+ }
28
+
29
+ // Extrai quadra e lote dos pares NOM_COMP_ELEMx / VAL_COMP_ELEMx (1–5).
30
+ // ATENÇÃO: NUM_QUADRA do CNEFE é a quadra do SETOR DE COLETA, não do endereço.
31
+ export function extrairCompCnefe(row) {
32
+ let quadra = null, lote = null;
33
+ for (let i = 1; i <= 5; i++) {
34
+ const nome = String(row[`NOM_COMP_ELEM${i}`] || "").toUpperCase().trim();
35
+ const val = String(row[`VAL_COMP_ELEM${i}`] || "").trim();
36
+ if (nome === "QUADRA" && quadra == null) quadra = normNum(val);
37
+ if (nome === "LOTE" && lote == null) lote = normNum(val);
38
+ }
39
+ return { quadra, lote };
40
+ }
41
+
42
+ // Constrói uma entrada de índice a partir de uma linha do CNEFE.
43
+ // Retorna null se a linha não deve ser indexada.
44
+ export function entradaDeLinhaCnefe(row) {
45
+ const g = parseInt(row.NV_GEO_COORD, 10);
46
+
47
+ // #3 — Descarta centróides de localidade (g=5) e setor de coleta (g=6).
48
+ // Esses pontos ficam longe do endereço real e causam o sintoma "pino distante".
49
+ // g=3 (estimada) e g=4 (face de quadra) são mantidos mas com clamp de cor em geocodificar.
50
+ if (g >= 5) return null;
51
+
52
+ const lat = parseCnefeLonLat(row.LATITUDE);
53
+ const lon = parseCnefeLonLat(row.LONGITUDE);
54
+ if (isNaN(lat) || isNaN(lon)) return null;
55
+
56
+ // Nome da via: extenso → numeral + normalização
57
+ const viaRaw = normalizeBase(
58
+ [row.NOM_TITULO_SEGLOGR, row.NOM_SEGLOGR].filter(Boolean).join(" "),
59
+ );
60
+ const via = extensoParaNumeral(viaRaw).replace(/\s+/g, " ").trim();
61
+ if (!via) return null;
62
+
63
+ const tipo = normalizeBase(row.NOM_TIPO_SEGLOGR || "");
64
+
65
+ const numBruto = String(row.NUM_ENDERECO || "").trim();
66
+ const modificador = String(row.DSC_MODIFICADOR || "").toUpperCase().trim();
67
+ const numero =
68
+ modificador === "SN" || numBruto === "0" || !numBruto ? null : normNum(numBruto);
69
+
70
+ const { quadra, lote } = extrairCompCnefe(row);
71
+
72
+ return {
73
+ codMunicipio: String(row.COD_MUNICIPIO || "").trim(),
74
+ via,
75
+ entry: {
76
+ tp: tipo,
77
+ n: numero,
78
+ q: quadra,
79
+ l: lote,
80
+ b: normalizeBase(row.DSC_LOCALIDADE || ""),
81
+ c: String(row.CEP || "").replace(/\D/g, ""),
82
+ la: lat,
83
+ lo: lon,
84
+ g, // NV_GEO_COORD 1–4 (5/6 descartados acima)
85
+ },
86
+ };
87
+ }
88
+
89
+ // Constrói o índice completo a partir de um array de linhas do CNEFE.
90
+ // Filtre COD_UF=52 e COD_MUNICIPIO (jurisdição) antes de chamar.
91
+ export function construirIndiceCnefe(linhasCnefe) {
92
+ const idx = {};
93
+ for (const row of linhasCnefe) {
94
+ const r = entradaDeLinhaCnefe(row);
95
+ if (!r || !r.via) continue;
96
+ idx[r.codMunicipio] ??= {};
97
+ (idx[r.codMunicipio][r.via] ??= []).push(r.entry);
98
+ }
99
+ return idx;
100
+ }
package/src/extrair.js ADDED
@@ -0,0 +1,254 @@
1
+ // Portado do geo_core.js V2.2 do NEXO; mudanças do Candeia marcadas com "Candeia".
2
+ import { normalizeBase, normNum, ABREV, normalizarPrefixoRodovia, TIPOS_VIA, ehCodigoRodovia, RUIDO_COMPLEMENTO, RE_COMPL_GLUED, _RE_COM_CRUZAMENTO, extensoParaNumeral } from "./normalizar.js";
3
+
4
+ // ----------------------------------------------------------------------
5
+ // 2) EXTRAÇÃO DE ENTIDADES — LADO RUNTIME (endereço digitado, "sujo")
6
+ // ----------------------------------------------------------------------
7
+
8
+ // #14 — Regexes de quadra/lote: prefixo de 1 letra (q/l) exige \s+
9
+ // para evitar falso-positivo. Ex.: "nivel5" não é lote 5.
10
+ // Versões com grupo de captura (extração) e sem (limpeza).
11
+ export const _RE_QUADRA_EXT = /\b(?:quadra\s*|qd\s*|q\s+)0*([0-9]+[a-z]?)\b/g;
12
+ export const _RE_LOTE_EXT = /\b(?:lote\s*|lt\s*|l\s+)0*([0-9]+[a-z]?(?:-[0-9]+)?)\b/g;
13
+ export const _RE_QUADRA_CLEAN = /\b(?:quadra\s*|qd\s*|q\s+)0*[0-9]+[a-z]?\b/g;
14
+ export const _RE_LOTE_CLEAN = /\b(?:lote\s*|lt\s*|l\s+)0*[0-9]+[a-z]?(?:-[0-9]+)?\b/g;
15
+ // Km também precisa sair do bloco antes da tokenização da via — sem isso,
16
+ // "GO-222 KM 5,5" (onde a vírgula DECIMAL confunde a detecção da 1ª vírgula
17
+ // do endereço, truncando a base ali) deixava "km 5" vazar para dentro do
18
+ // nome da via extraído (ex.: via="go 222 km 5" em vez de "go 222").
19
+ // Cobre "km 5", "km 5,5", o caso já truncado "km 5" (vírgula virou espaço)
20
+ // e "km-5"/"km -5" (hífen entre "km" e o número, ex.: "RODOVIA GO-040 KM-28").
21
+ export const _RE_KM_CLEAN = /\bkm\.?[\s-]*[0-9]+(?:[.,\s][0-9]+)?\b/gi;
22
+
23
+ // Helper interno: extrai via normalizada de um bloco de texto já normalizado.
24
+ export function _extrairViaDeBloco(bloco) {
25
+ let b = bloco
26
+ .replace(_RE_QUADRA_CLEAN, " ")
27
+ .replace(_RE_LOTE_CLEAN, " ")
28
+ .replace(_RE_KM_CLEAN, " ")
29
+ .replace(/\bs\s*\/?\s*n\b/g, " ")
30
+ .replace(/\bsem numero\b/g, " ");
31
+ _RE_QUADRA_CLEAN.lastIndex = 0;
32
+ _RE_LOTE_CLEAN.lastIndex = 0;
33
+ _RE_KM_CLEAN.lastIndex = 0;
34
+ let tokens = b.split(/\s+/).filter(Boolean);
35
+ tokens = tokens.filter((tok, i) => tok !== tokens[i - 1]);
36
+ let tipo = null;
37
+ if (tokens.length && (TIPOS_VIA.has(tokens[0]) || ehCodigoRodovia(tokens, 0))) {
38
+ tipo = tokens[0];
39
+ tokens = tokens.slice(1);
40
+ }
41
+ return { tipo, tokens };
42
+ }
43
+
44
+ // #19 — O campo bairro às vezes vem preenchido com quadra/lote em vez de nome de
45
+ // bairro de verdade (ex.: "QD 10 LT 05 SALA 2"). Comparar isso com um bairro de verdade
46
+ // nunca vai bater — mas o valor não é lixo, é quadra/lote que foi digitado
47
+ // no campo errado. geocodificar() reaproveita como sinal de desempate
48
+ // quando o próprio endereço não trouxe essa informação (ver uso).
49
+ export function _extrairQLDeBairro(bairroNorm) {
50
+ let quadra = null, lote = null;
51
+ bairroNorm.replace(_RE_QUADRA_EXT, (_, n) => { if (quadra == null) quadra = normNum(n); });
52
+ _RE_QUADRA_EXT.lastIndex = 0;
53
+ bairroNorm.replace(_RE_LOTE_EXT, (_, n) => { if (lote == null) lote = normNum(n); });
54
+ _RE_LOTE_EXT.lastIndex = 0;
55
+ return { quadra, lote };
56
+ }
57
+
58
+ // Captura {tipo, via, quadra, lote, numero, km} de uma string de endereço livre.
59
+ export function extrairEntidades(enderecoCru) {
60
+ const cru = String(enderecoCru || "");
61
+
62
+ // 1) Número da porta — posição canônica: primeiro token após a 1ª vírgula
63
+ let numero = null;
64
+ const idxVirg = cru.indexOf(",");
65
+ if (idxVirg >= 0) {
66
+ const pos = cru.slice(idxVirg + 1).trim().split(/[\s,]+/)[0] || "";
67
+ if (/^\d{1,5}$/.test(pos)) numero = pos.replace(/^0+/, "") || "0";
68
+ }
69
+
70
+ // 1b) Km — endereço rural sem numeração urbana (ex.: "BR 153, KM 540, s/n",
71
+ // "RODOVIA GO-222 KM 5,5"). Extraído do texto CRU (antes de normalizeBase,
72
+ // que substituiria a vírgula decimal de "5,5" por espaço e destruiria o
73
+ // valor). Convenção observada no CNEFE para rodovias: o Km costuma ser
74
+ // gravado no próprio campo de número de porta — por isso vira fallback de
75
+ // `numero` logo abaixo, reaproveitando o desempate por número (peso 5 em
76
+ // geocodificar()) sem precisar de um critério novo no motor de pontuação.
77
+ let km = null;
78
+ const mKm = cru.match(/\bkm\.?[\s-]*(\d+(?:[.,]\d+)?)/i);
79
+ if (mKm) km = mKm[1].replace(",", ".");
80
+
81
+ // 2) Base = parte pré-vírgula (ou string inteira se não houver vírgula)
82
+ let base = normalizeBase(idxVirg >= 0 ? cru.slice(0, idxVirg) : cru);
83
+ for (const [re, rep] of ABREV) base = base.replace(re, rep);
84
+ base = normalizarPrefixoRodovia(base);
85
+ // #16 — Guarda o bloco do cruzamento (ao invés de só descartar) para servir
86
+ // de via alternativa em geocodificar() quando a via principal não bate em
87
+ // nada no índice — ver extração de viaCruzamento mais abaixo. "c/" não
88
+ // exige espaço depois (é comum "C/RUA X" colado, sem
89
+ // espaço após a barra) — "esq"/"esquina" continuam exigindo os dois
90
+ // lados por extenso, pra não colidir com outra palavra no meio do nome.
91
+ let partesCruzamento = base.split(/\s+(?:c\/\s*|esq\s+|esquina\s+)/);
92
+ // Candeia — "Estrada Esquina Borges" (RS): "esquina" logo depois do tipo é parte do nome
93
+ // (localidade), não marcador de cruzamento. Sem via antes do marcador, não há cruzamento.
94
+ const antes = partesCruzamento[0].trim().split(/\s+/).filter(Boolean);
95
+ if (partesCruzamento.length > 1 && antes.length <= 1 && (!antes.length || TIPOS_VIA.has(antes[0]))) {
96
+ partesCruzamento = [base];
97
+ }
98
+ base = partesCruzamento[0];
99
+ let blocoCruzamento = partesCruzamento[1] || null;
100
+
101
+ // "com" bruto, sem "esq"/"esquina"/"c/" — só entra se o lado direito
102
+ // começar com um tipo de via reconhecido (ver _RE_COM_CRUZAMENTO acima).
103
+ if (!blocoCruzamento) {
104
+ const mCom = base.match(_RE_COM_CRUZAMENTO);
105
+ if (mCom) {
106
+ base = mCom[1];
107
+ blocoCruzamento = mCom[2];
108
+ }
109
+ }
110
+
111
+ if (blocoCruzamento) {
112
+ // Sobra de "esquina COM rua X" / "esq C rua X" (duplo marcador) — sem
113
+ // isso "com"/"c" ficava na frente do bloco e o tipo da via ("rua"/
114
+ // "avenida"...) nunca era reconhecido por só olhar o 1º token, vazando
115
+ // pro nome extraído (ex.: viaCruzamento="rua 07" em vez de "07", que
116
+ // não bate com a chave limpa do CNEFE).
117
+ const RUIDO_CRUZAMENTO = new Set(["com", "c/", "c", "esq", "esquina"]);
118
+ let tokensCruz = blocoCruzamento.split(/\s+/).filter(Boolean);
119
+ while (tokensCruz.length > 1 && RUIDO_CRUZAMENTO.has(tokensCruz[0])) {
120
+ tokensCruz = tokensCruz.slice(1);
121
+ }
122
+ blocoCruzamento = tokensCruz.join(" ");
123
+ }
124
+
125
+ // 3) Quadra e lote — de qualquer ponto da string
126
+ let full = normalizeBase(cru);
127
+ for (const [re, rep] of ABREV) full = full.replace(re, rep);
128
+ full = normalizarPrefixoRodovia(full);
129
+
130
+ let quadra = null;
131
+ full.replace(_RE_QUADRA_EXT, (_, n) => { if (quadra == null) quadra = normNum(n); });
132
+ _RE_QUADRA_EXT.lastIndex = 0;
133
+ let lote = null;
134
+ full.replace(_RE_LOTE_EXT, (_, n) => { if (lote == null) lote = normNum(n); });
135
+ _RE_LOTE_EXT.lastIndex = 0;
136
+
137
+ // #8 — Fallback: "N°10", "N 10", "Nº 10" sem vírgula (forma explícita de número)
138
+ if (numero == null) {
139
+ const mN = full.match(/\bn[°o.]?\s*(\d{1,5})\b/);
140
+ if (mN) {
141
+ numero = mN[1].replace(/^0+/, "") || "0";
142
+ // Candeia — tira o "nº 500" do nome da via ("Av. Getulio Vargas nº 500" → via "getulio vargas").
143
+ // Só com o símbolo º/° escrito: "Avenida N2", "Rua N 113" e "Rua No 7" são nomes de via reais.
144
+ if (/\bn\s*[º°]\s*\d/i.test(cru)) {
145
+ const semN = base.replace(/\s+n\s*\d{1,5}\b.*$/, "");
146
+ const resto = semN.trim().split(/\s+/).filter((t) => !TIPOS_VIA.has(t));
147
+ if (semN !== base && resto.length) base = semN;
148
+ }
149
+ }
150
+ }
151
+
152
+ // Km como fallback de número — só quando não havia número real, nunca
153
+ // sobrescreve endereço urbano legítimo (ex.: "Rua X, 45" mantém 45).
154
+ if (numero == null && km != null) {
155
+ const kmInt = Math.round(parseFloat(km));
156
+ if (!isNaN(kmInt)) numero = String(kmInt);
157
+ }
158
+
159
+ // 4) Extrai tokens da via a partir da base pré-vírgula
160
+ let { tipo, tokens } = _extrairViaDeBloco(base);
161
+
162
+ // #15 — Fallback: endereço com Q/L antes da vírgula ("QUADRA 5 LOTE 3, RUA 8").
163
+ // Se a base ficou vazia após limpeza, tenta a parte pós-vírgula como via.
164
+ if (!tokens.length && idxVirg >= 0) {
165
+ let altBase = normalizeBase(cru.slice(idxVirg + 1));
166
+ for (const [re, rep] of ABREV) altBase = altBase.replace(re, rep);
167
+ altBase = normalizarPrefixoRodovia(altBase);
168
+ const alt = _extrairViaDeBloco(altBase);
169
+ if (alt.tokens.length) { tipo = alt.tipo; tokens = alt.tokens; }
170
+ }
171
+
172
+ // Candeia — "esquina" logo depois do tipo é localidade ("Estrada Esquina Borges"), não ruído.
173
+ const nomeComEsquina = tipo != null && tokens[0] === "esquina";
174
+ const via0 = tokens
175
+ .filter((tok) => (nomeComEsquina && tok === "esquina") || (!RUIDO_COMPLEMENTO.has(tok) && !RE_COMPL_GLUED.test(tok)))
176
+ .join(" ")
177
+ .trim();
178
+ const via = extensoParaNumeral(via0);
179
+
180
+ // #16 — Via do cruzamento ("Rua X esq Rua Y" → viaCruzamento = "rua y"),
181
+ // mesmo tratamento (tipo fora, ruído fora, extenso→numeral) da via
182
+ // principal. null quando o endereço não menciona cruzamento.
183
+ let viaCruzamento = null;
184
+ if (blocoCruzamento) {
185
+ const alt = _extrairViaDeBloco(blocoCruzamento);
186
+ const viaCruzamento0 = alt.tokens
187
+ .filter((tok) => !RUIDO_COMPLEMENTO.has(tok) && !RE_COMPL_GLUED.test(tok))
188
+ .join(" ")
189
+ .trim();
190
+ if (viaCruzamento0) viaCruzamento = extensoParaNumeral(viaCruzamento0);
191
+ }
192
+
193
+ // Candeia — número de porta sem vírgula ("Rua Goias 125"). Ambíguo com via que termina em
194
+ // número ("Avenida T 63", "Rua 15"), então aqui só se propõe a alternativa; geocodificar()
195
+ // fica com ela apenas se a via sem o número casar melhor no índice.
196
+ let viaSemNumero = null, numeroSemVirgula = null;
197
+ if (numero == null && km == null) {
198
+ const tv = via.split(" ");
199
+ if (tv.length >= 2 && /^\d{1,5}[a-z]?$/.test(tv[tv.length - 1])) {
200
+ viaSemNumero = tv.slice(0, -1).join(" ");
201
+ numeroSemVirgula = normNum(tv[tv.length - 1]);
202
+ }
203
+ }
204
+
205
+ // Candeia — via "estruturada" (DF, condomínios, conjuntos habitacionais): o CNEFE grava a
206
+ // quadra/conjunto/bloco no próprio nome do logradouro ("QNN 25 CONJUNTO B", "SQS 308 BLOCO C",
207
+ // "QUADRA 37"), que a extração acima desmonta (quadra vira campo, bloco vira ruído). Aqui se
208
+ // propõe a via com essas peças preservadas; geocodificar() só a usa se ela casar exato.
209
+ const { via: viaEstruturada, lote: loteCasa } = _viaEstruturada(base);
210
+ if (lote == null && loteCasa != null) lote = loteCasa;
211
+
212
+ return { tipo, via, viaCruzamento, quadra, lote, numero, km, viaSemNumero, numeroSemVirgula,
213
+ viaEstruturada: viaEstruturada && viaEstruturada !== via ? viaEstruturada : null };
214
+ }
215
+
216
+ const _ABREV_ESTRUTURA = [
217
+ [/\b(?:conj|cj)\b/g, "conjunto"],
218
+ [/\b(?:bl|blc)\b/g, "bloco"],
219
+ [/\b(?:qd|qda)\b/g, "quadra"],
220
+ [/\b(?:chac|ch)\b/g, "chacara"],
221
+ [/\bae\b/g, "area especial"],
222
+ [/\bcond\b/g, "condominio"],
223
+ ];
224
+ // Complementos que não fazem parte do logradouro: unidade dentro do lote ou do prédio.
225
+ const _RE_UNIDADE = /\b(?:ap|apto|apartamento|sala|sl|loja|lj|andar|cobertura|kitnet|box)\s*[0-9]+[a-z]?\b/g;
226
+ // Tipos que fazem parte da chave estruturada ("quadra 37", "conjunto 5"); os outros saem do início.
227
+ const _TIPO_ESTRUTURA = new Set(["conjunto", "quadra", "chacara", "setor", "area", "modulo"]);
228
+ // Só propõe a via estruturada quando ela tem peça de estrutura seguida de identificador.
229
+ const _RE_ESTRUTURA = /\b(?:quadra|conjunto|bloco|chacara|area especial|q[a-z]{1,3})\s+[0-9a-z]+\b/;
230
+
231
+ export function _viaEstruturada(base) {
232
+ let b = base;
233
+ for (const [re, rep] of _ABREV_ESTRUTURA) b = b.replace(re, rep);
234
+ // "casa N" no DF e em conjuntos é o lote; só vira lote quando o endereço não trouxe um.
235
+ let lote = null;
236
+ const mCasa = b.match(/\b(?:casa|cs)\s*0*([0-9]+[a-z]?)\b/);
237
+ if (mCasa) lote = normNum(mCasa[1]);
238
+ b = b
239
+ .replace(/\b(?:casa|cs)\s*[0-9]+[a-z]?\b/g, " ")
240
+ .replace(_RE_UNIDADE, " ")
241
+ .replace(_RE_LOTE_CLEAN, " ")
242
+ .replace(_RE_KM_CLEAN, " ")
243
+ .replace(/\bs\s*\/?\s*n\b/g, " ")
244
+ .replace(/\bsem numero\b/g, " ");
245
+ _RE_LOTE_CLEAN.lastIndex = 0;
246
+ _RE_KM_CLEAN.lastIndex = 0;
247
+ let tokens = b.split(/\s+/).filter(Boolean);
248
+ // "EDF" (tipo dos logradouros do DF no CNEFE) e tipos genéricos no início não entram na chave.
249
+ while (tokens.length > 1 && (tokens[0] === "edf" || (TIPOS_VIA.has(tokens[0]) && !_TIPO_ESTRUTURA.has(tokens[0])))) {
250
+ tokens = tokens.slice(1);
251
+ }
252
+ const v = extensoParaNumeral(tokens.join(" ").trim());
253
+ return { via: _RE_ESTRUTURA.test(v) ? v : null, lote };
254
+ }