# 05 — Coordenadas (la/lo) e província — auditoria de fidelidade

- **Painel:** MonumentosES — auditoria de fidelidade, agente 4/8
- **Data:** 2026-09-04
- **Datasets auditados:** `www/explorer/bic_data.json` (24.337 records; 23.606 com `la`/`lo`), `www/explorer/bic-geo.json` (23.606 pontos de mapa), `raw/interm/*.json`, ficheiros em `raw/`
- **Marcações:** [C] = confirmado programaticamente no estado atual do repositório; [I] = inferido/secundário (raciocínio ou documento não executado nesta auditoria)

---

## Âmbito

1. **Bounds por fonte/CCAA** (min/max la/lo de `www/explorer/bic_data.json`) e plausibilidade geográfica; outliers extremos fora da Península+Baleares+Canárias (esperado: 0); os 10 pontos mais isolados por haversine ao centróide da província atribuída (`d`).
2. **231 mismatches fonte-vs-PIP** (província `d` da fonte ≠ PIP, reportados por `tools/prepare_map_points.py`): re-derivação exata, classificação (fronteira / geocoding ruído / outlier conhecido Fuerte del Rapitán / entorns) e os 15 maiores desvios em km.
3. **Madrid sem coordenadas** (esperado: 443) e **records sem província** (esperado: 12) — confirmação de contagens.
4. **Sanidade UTM:** 10 registos aleatórios de fontes UTM (gva/nav/gal/ext/arg) re-convertidos com pyproj (EPSG conforme a fonte) e comparados com `la`/`lo` da vista (tolerância 1e-4 graus).
5. **Preparação do mapa:** `bic-geo.json` == todos os records com `la`/`lo`?

## Método

- Python 3.12 com **shapely 2.1.1** e **pyproj 3.7.1** [C]. Leitura apenas — nenhum ficheiro de dados foi reescrito.
- PIP (point-in-polygon) contra `www/data/spain-provinces.geojson` (52 províncias), **replicando exatamente** a lógica de `tools/prepare_map_points.py`: pré-filtro por bbox + `prepared.contains`, ordem dos features do ficheiro, `pips[0]` como primeira província [C].
- Distâncias: fórmula de haversine, R = 6371 km (idêntica ao script de produção); distância ponto→província via `shapely.ops.nearest_points` em graus + haversine (aprox. válida para as distâncias em jogo, <1% de erro) [C método; I precisão].
- Amostragem UTM com `random.seed(4)` (2 registos por fonte), leitura direta dos ficheiros brutos (`raw/gva_bic_inmuebles.csv`, `raw/nav/PATRIM_Sym_BIC*.shp` via pyshp cp1252, `raw/gal_bic.ods` via XML ODS, `raw/ext_bic.xlsx` via openpyxl, `raw/arg_bic_puntos.csv` via WKT) e re-conversão com os mesmos CRS/decisões dos ingesters.

## Achados

### 1. Bounds por fonte — plausíveis; 0 outliers extremos

Min/max por fonte (23.606 pontos com coordenadas) [C]:

| fonte | n | lat [min, max] | lon [min, max] | plausibilidade |
|---|---|---|---|---|
| eus | 10.295 | [42.49465, 43.44737] | [-3.43957, -1.76196] | País Vasco ✔ |
| cyl | 2.479 | [40.15250, 43.21248] | [-7.03410, -1.85647] | Castilla y León ✔ |
| cant | 2.400 | [42.79322, 43.49572] | [-4.70998, -3.19536] | Cantabria ✔ |
| cat | 2.161 | [40.54413, 42.81377] | [0.20978, 3.28619] | Catalunya (incl. Cap de Creus lon 3,29) ✔ |
| nav | 1.426 | [41.92271, 43.30676] | [-2.39342, -0.82933] | Navarra ✔ |
| gva | 1.139 | [37.86781, 40.74576] | [-1.46715, 0.51034] | C. Valenciana (incl. Castellón lon >0) ✔ |
| arg | 1.021 | [40.05621, 42.80830] | [-2.13110, 0.75663] | Aragón (franja oriental lon >0) ✔ |
| eiv | 728 | [38.83222, 39.11362] | [1.21036, 1.60857] | Eivissa ✔ |
| gal | 651 | [41.88841, 43.71510] | [-9.26361, -6.87586] | Galiza (cabo Fisterra −9,26) ✔ |
| clm | 471 | [38.36637, 41.26739] | [-5.22318, -0.97262] | Castilla-La Mancha ✔ |
| ast | 331 | [42.93942, 43.61671] | [-7.10655, -4.52694] | Asturias ✔ |
| ext | 240 | [38.05418, 40.32283] | [-7.26788, -5.08442] | Extremadura ✔ |
| ten | 199 | [28.00451, 28.58384] | [-16.84937, -16.16414] | Tenerife ✔ |
| rio | 65 | [41.95934, 42.62821] | [-3.04782, -1.94857] | La Rioja ✔ |

- **Fora do envelope Península+Baleares+Canárias (lat 27,5–44,1, lon −18,3–4,4): 0 pontos** [C]. `raw/map_outliers.json` tem **0 entradas**; `meta.outliers` do bic-geo: `fora_bounds: 0, graus_inteiros: 0, fora_poligonos_e_15km: 0` [C].
- **10 pontos mais isolados do centróide da província atribuída** [C]: 7 dos 10 são exatamente os records CLM mal geocodificados do §2 (não é isolamento geográfico real, são coordenadas erradas):

| km | id | nome | província `d` |
|---|---|---|---|
| 283,1 | clm:18 | Iglesia de la Natividad | Albacete |
| 259,3 | clm:180 | Iglesia de San Juan Bautista | Ciudad Real |
| 259,3 | clm:156 | Iglesia de San Juan Bautista | Ciudad Real |
| 251,6 | clm:1 | Iglesia de San Juan Bautista | Albacete |
| 225,4 | clm:71 | Iglesia de Santiago | Albacete |
| 209,4 | clm:17 | Acueducto | Albacete |
| 194,1 | clm:151 | Museo Arqueológico | Ciudad Real |
| 193,9 | clm:144 | Convento de la Merced | Ciudad Real |
| 193,6 | clm:517 | Iglesia de San Juan Bautista | Toledo |
| 174,9 | clm:534 | Iglesia de San Miguel | Toledo |

- Casos isolados **legítimos** (centróides provinciais amplos/multi-ilha, não desvios): eiv:0196 a 164,5 km do centróide da província das Balears (centróide no canal de Mallorca) [I]; cyl:864111 «Hórreo Caín_01» a 100,7 km (Caín, norte de León); ten:120 a 88,8 km (província de Santa Cruz inclui La Gomera/La Palma) [I].

### 2. Os 231 mismatches fonte-vs-PIP — re-derivados e classificados

Re-derivação com a lógica exata do `prepare_map_points.py`: **231 = 231 reportados** [C]. Decomposição:

| classe | n | observações |
|---|---|---|
| **string-only** (ponto DENTRO da província atribuída; só a escrita difere) | **126** | BADAJOZ→Badajoz 113 [C]; HUESCA→Huesca 8; Terue→Teruel **2 (typo na fonte arg)**; TERUEL 1; ZARAGOZA 1; «A Coruña / Pontevedra»→A Coruña 1 |
| **fronteira (0 < km ≤ 15)** | **87** | Megalitos do Euskadi «en el límite» (Vizcaya→Cantabria/Burgos, 0–2,2 km — os próprios nomes dizem «(en el Limitado)»/«(Actualmente en…)»); facerías Navarra↔Guipúzcoa/Álava; Robres (Huesca→Zaragoza, 4,9/4,8 km); Mosqueruela (Teruel→Castellón); Forcall (Castellón→Teruel); etc. |
| **geocoding ruído (> 15 km)** | **16** | **Todos clm** — ver Desvios |
| **entorn** | **1** | ext:8088 «Convento de San Andrés. Delimitación del entorno», Mérida (d=BADAJOZ, PIP=Badajoz) |
| **d não resolvida** | **1** | gal:BIC.000.785 «Casa Cornide», `d="A"` (truncado na fonte), PIP=A Coruña |

- **Fuerte del Rapitán (arg:761, Jaca): NÃO é mismatch no estado atual.** `la/lo` = 42.589067 / −0.536155 → PIP = ['Huesca'] = `d`; distância ao polígono de Huesca 0,0 km [C]. O outlier conhecido de auditorias anteriores aparece **resolvido** nos dados atuais [C estado atual; I sobre quando/como foi corrigido — não verificado em git].
- Mismatches por fonte: ext 114, eus 63, clm 20, arg 19, nav 8, gal 3, gva 3, ast 1 [C].

### 3. Sem coordenadas e sem província

- **Madrid: 443 de 443 records sem `la`/`lo`** [C]. A fonte `raw/mad_inmuebles_bic.csv` não tem quaisquer colunas de geometria (cabeçalho: `denominacion_registral;autor;situacion;incoacion;declaracion;numero_registro;naturaleza;categoria;municipio`) [C] — geocoding pendente, como esperado.
- Total sem coordenadas: **731** = mad 443 + clm 160 + gal 72 + gva 46 + ext 10 [C].
- **Sem província: 12** [C] = 11 `cat` + 1 `cyl`. Todos têm `la/lo` mas PIP devolve **0** províncias — por isso o preenchimento automático (que exige exatamente 1) não os tocou [C]:
  - Os 11 cat são **pontos costeiros no mar** (polígonos provinciais não cobrem água): CASA ROZES (Roses), MEMORIAL WALTER BENJAMIN (Portbou), JARDÍ DE MAR I MURTRA (Blanes), CASTELL DE SANT JOAN DE LLORET, CONJUNT HISTÒRIC DE S'AGARÓ, MUSEU DEL CAU FERRAT (Sitges), TORRE DE SANT JOAN (Amposta), TORRE DEL TORN (Vandellòs), TORRE DE L'ALIGA (l'Ametlla), FORTÍ DE LA REINA e FORTÍ DE SANT JORDI (Tarragona) [C].
  - cyl:2118138 «EL FUERTE NUEVO» (41.49457, −6.26183) fica a **0,1 km do polígono de Zamora** — no intervalo/limiar da fronteira com Portugal [C].
  - Todos entram no mapa via tolerância <15 km a vértice de fronteira (`out_poly = 0`) [C].

### 4. Sanidade UTM — 10/10 dentro da tolerância

Re-conversão (pyproj) dos UTM brutos com o CRS usado por cada ingester, comparada com `la/lo` de `bic_data.json`; tolerância 1e-4° [C]:

| fonte | id | CRS aplicado | Δlat | Δlon | OK |
|---|---|---|---|---|---|
| gva | gva:12.033-9999-000004 Castillo de Miravet | EPSG:25830 (colunas trocadas [C]) | 3,4e-7 | 4,2e-7 | ✔ |
| gva | gva:12.081-9999-000007 Torre Altomira | EPSG:25830 | 5,6e-8 | 1,9e-7 | ✔ |
| nav | nav:2471 Crucero de Pamplona | EPSG:25830 (.prj [C]) | 2,9e-7 | 3,0e-7 | ✔ |
| nav | nav:1925 Arbilleta | EPSG:25830 | 2,5e-7 | 1,9e-7 | ✔ |
| gal | gal:BIC.000.566 Cemiterio San Francisco | EPSG:25829 [I] | 3,5e-7 | 4,4e-8 | ✔ |
| gal | gal:BIC.000.184 Petróglifo Laxe da Portela | EPSG:25829 [I] | 3,0e-7 | 3,2e-7 | ✔ |
| ext | ext:RI-51-0005450-00000 | 29/30 por bbox [I] | 5,0e-7 | 3,0e-7 | ✔ |
| ext | ext:RI-51-0011235-00000 | 29/30 por bbox [I] | 3,5e-8 | 2,5e-7 | ✔ |
| arg | arg:70 Abrigo de las Figuras Amarillas | EPSG:25830 (WKT) | 3,8e-7 | 3,9e-7 | ✔ |
| arg | arg:189 LA TORRETA | EPSG:25830 (WKT) | 3,9e-7 | 1,2e-7 | ✔ |

**10/10 aprovados; Δ máximo ≈ 5e-7° (≈5 cm), residual do arredondamento a 6 casas decimais nos ingesters** [C]. Notas documentadas no código e confirmadas: o CSV da GVA tem `utmeste`/`utmnorte` **trocados** na fonte (defeito contornado no ingester) [C]; Galiza usa EPSG:25829 por inferência (CRS não declarado) [I]; Extremadura decide huso 29/30 por bbox (CRS não declarado) [I].

### 5. Preparação do mapa — 23.606 == 23.606, com ressalva de staleness cosmético

- `bic-geo.json`: 23.606 pontos = records com `la` = `meta.with_coords` de `bic_data.json`; `n_points == n_points_raw` (filtro não excluiu nenhum: 0 fora de bounds, 0 graus inteiros, 0 fora de polígono+15 km) [C].
- Conjunto de ids **idêntico**, 0 duplicados, **lat/lng bit-a-bit iguais** em todos os 23.606 pontos [C].
- **Ressalva:** `bic-geo.json` (mtime 2026-09-03 16:32) é **mais antigo** que `bic_data.json` (22:43) [C]: 45 `name` ficaram `null` no geo onde bic_data já traz «(sin denominación)»; 11.257 `tip` e 12 `district` divergem por `''` vs `null` [C]. **Posicionalmente o mapa está correto**; apenas campos descritivos ficaram atrás de uma reconstrução posterior de `bic_data.json`.

## Desvios

1. **16 coordenadas CLM erradas (o único desvio grave).** Todos os desvios >15 km são `clm`, com erros de 31,6 a 214,7 km (ex.: clm:18 «Iglesia de la Natividad», município ALBOREA/Albacete, geocodificado em Guadalajara a 214,7 km; clm:12 «Museo Arqueológico», município Albacete, colocado em Cuenca a 80 km). O CSV de origem CLM **não tem coordenadas** [C]; o ingester (`tools/ingest_castilla-la-mancha.py`) busca geometria num FeatureServer ArcGIS casando NOME+MUNICÍPIO normalizados e, havendo vários candidatos, **média dos centróides** [C código]. Nomes genéricos («Iglesia de San Juan Bautista») casam com o município errado, produzindo desvios de dezenas a centenas de km [I — causa mais provável, coerente com os padrões observados].
2. **`d` não normalizada entre fontes:** maiúsculas (BADAJOZ/HUESCA/TERUEL/ZARAGOZA em ext/arg), typo «Terue» (2 records arg), composto «Lugo / Ourense», «A Coruña / Pontevedra» (gal) e truncado «A» (gal:BIC.000.785). 126 dos 231 «mismatches» são só isto — ruído de comparação, sem erro posicional [C].
3. **12 sem província** (11 cat costeiros no mar + 1 cyl no limiar de Zamora) — sem impacto no mapa, mas ficam sem `d` na ficha [C].
4. **`bic-geo.json` obsoleto em campos descritivos** (names/fill's `''`→`null`) face a um `bic_data.json` mais recente — sem impacto posicional [C].
5. **Não-desvios confirmados:** os 87 mismatches de fronteira ≤15 km são legítimos (megalitos «en el límite» do Euskadi, facerías de Navarra, municípios raianos); Rapitán já coincide com Huesca; 0 outliers fora da geografia espanhola [C].

## Recomendações

1. **Corrigir o matching CLM** (prioridade alta): exigir MUNICÍPIO exato e candidato único; rejeitar/limpar matches ambíguos e re-geocodificar os 16 records afetados (lista no §2/§Achados 1).
2. **Normalizar `d` na ingest** para o canónico das 52 províncias (title-case): corrige 126 falsos mismatches; emenda o typo «Terue», resolve «A»→«A Coruña» e decide a representação de BICs multi-província («Lugo / Ourense»).
3. **Geocoding pendente (731 records):** mad 443 (fonte sem geometria) + clm 160 + gal 72 + gva 46 + ext 10 — agendar geocodificação por município+nome ou obter fonte com geometria.
4. **Re-correr `tools/prepare_map_points.py` no fim de qualquer rebuild** de `bic_data.json` (bic-geo stale) — é idempotente por desenho [C docstring].
5. **Preencher `d` dos 12 sem província** a partir do município `m` (todos o têm preenchido) ou por PIP com tolerância costeira (<2 km), em vez de os deixar a vazio.
6. Manter a tolerância de 15 km a vértice de fronteira — é ela que admite legitimamente os ~87 casos raianos (incl. os 11 costeiros) [C comportamento].

## Fontes

Ficheiros do repositório (todos sob `C:\Users\Atelier X\Nextcloud\Documents\00_Website\MonumentosES`):
- `www/explorer/bic_data.json`, `www/explorer/bic-geo.json`, `www/data/spain-provinces.geojson`, `raw/map_outliers.json`
- `tools/prepare_map_points.py`, `tools/ingest_valenciana.py`, `tools/ingest_navarra.py`, `tools/ingest_galicia.py`, `tools/ingest_extremadura.py`, `tools/ingest_aragon.py`, `tools/ingest_castilla-la-mancha.py`
- `raw/gva_bic_inmuebles.csv`, `raw/nav/PATRIM_Sym_BICarquite.shp` (+ `.prj`), `raw/nav/PATRIM_Sym_BICarqueo.shp`, `raw/gal_bic.ods`, `raw/ext_bic.xlsx`, `raw/arg_bic_puntos.csv`, `raw/clm_catalogo.csv`, `raw/mad_inmuebles_bic.csv`
- `raw/interm/{valenciana,navarra,galicia,extremadura,aragon,madrid,castilla-la-mancha}.json`

URLs das fontes de dados (dos ficheiros `raw/interm/*.json`):
- GVA (C. Valenciana): https://dadesobertes.gva.es/dataset/da443c69-9c66-4e25-bdd7-8fdb8fe7cda9/resource/2271efdd-d0cc-4126-a9a0-dd11d56acbde/download/bienes_inmuebles_interes_cultural.csv — CC BY
- Navarra (IDENA): https://idena.navarra.es/descargas/PATRIM_Sym_BICarquite.zip ; https://idena.navarra.es/descargas/PATRIM_Sym_BICarqueo.zip — CC BY 4.0
- Galiza (Xunta): https://ficheiros-web.xunta.gal/patrimonio/bic/relacion_bics.ods — CC BY-SA 4.0
- Extremadura (Juntaex): https://www.juntaex.es/documents/77055/5801338/Bienes_Interes_Cultural.xlsx — CC BY 4.0
- Aragão (Open Data): https://opendata.aragon.es/GA_OD_Core/download?resource_id=333&formato=csv — CC BY 4.0
- Madrid (Comunidad): https://datos.comunidad.madrid/dataset/dde86e1d-a06b-4f65-8668-920a566fa264/resource/1804d79a-8643-4f06-8c20-7a0e40641be0/download/inmuebles_bic.csv — CC BY 4.0
- Castilla-La Mancha: https://datosabiertos.castillalamancha.es/sites/datosabiertos.castillalamancha.es/files/CAT%C3%81LOGO%20PATRIMONIO%20CULTURAL%2015112024.csv (CC BY-SA 3.0 ES) + FeatureServer ArcGIS «Patrimonio_Cultural» (sem licença explícita; ver `docs/fuentes/castilla-la-mancha.md`)

---

## Comandos

Executados com Python 3.12 + shapely 2.1.1 + pyproj 3.7.1 (leitura apenas; nenhum dado reescrito). Resumo dos blocos principais (heredocs `python <<'EOF'`):

```bash
# 1. bounds por fonte + envelope + 10 mais isolados (haversine ao centróide de `d`)
python - <<'EOF'
import json, math
from collections import defaultdict
from shapely.geometry import shape
data = json.load(open('www/explorer/bic_data.json', encoding='utf-8'))
recs = data['records']
b = defaultdict(lambda: [99,-99,99,-99,0])
for r in recs:
    if r.get('la') is None: continue
    x = b[r['src']]
    x[0]=min(x[0],r['la']); x[1]=max(x[1],r['la']); x[2]=min(x[2],r['lo']); x[3]=max(x[3],r['lo']); x[4]+=1
def ha(a,b_,c,d_):
    p1,p2=math.radians(a),math.radians(c)
    x=math.sin((p2-p1)/2)**2+math.cos(p1)*math.cos(p2)*math.sin(math.radians(d_-b_)/2)**2
    return 2*6371.0*math.asin(math.sqrt(x))
# envelope Península+Baleares+Canárias: 0 fora
print('fora envelope:', sum(1 for r in recs if r.get('la') and not
      (27.5<=r['la']<=44.1 and -18.3<=r['lo']<=4.4)))
cents = {f['properties']['name']: shape(f['geometry']).centroid
         for f in json.load(open('www/data/spain-provinces.geojson', encoding='utf-8'))['features']}
dists = sorted(((ha(r['la'],r['lo'],cents[r['d']].y,cents[r['d']].x), r['i'], r['n'], r['d'])
                for r in recs if r.get('la') and r.get('d') in cents), reverse=True)
for d,i,n,p in dists[:10]: print(round(d,1), i, n, p)
EOF

# 2. re-derivar os 231 mismatches + classificação (lógica exata de prepare_map_points.py)
python - <<'EOF'
import json, math, unicodedata
from collections import Counter
from shapely.geometry import shape, Point
from shapely.ops import nearest_points
from shapely import prepared
items = json.load(open('www/explorer/bic_data.json', encoding='utf-8'))['records']
polys = [(f['properties']['name'], prepared.prep(shape(f['geometry'])), shape(f['geometry']))
         for f in json.load(open('www/data/spain-provinces.geojson', encoding='utf-8'))['features']]
def pips_of(it):
    return [n for n,p,g in polys if g.bounds[0]<=it['lo']<=g.bounds[2]
            and g.bounds[1]<=it['la']<=g.bounds[3] and p.contains(Point(it['lo'],it['la']))]
def ha(a,b_,c,d_):
    p1,p2=math.radians(a),math.radians(c)
    x=math.sin((p2-p1)/2)**2+math.cos(p1)*math.cos(p2)*math.sin(math.radians(d_-b_)/2)**2
    return 2*6371.0*math.asin(math.sqrt(x))
def dkm(la,lo,g):
    p=nearest_points(Point(lo,la),g)[1]; return ha(la,lo,p.y,p.x)
mis=[(it,pips_of(it)[0]) for it in items if it.get('la') and it.get('d')
     and pips_of(it) and pips_of(it)[0]!=it['d']]
print('mismatches:', len(mis))   # → 231
# classificar por km até ao polígono da província atribuída
#   km==0 → string-only; 0<km<=15 → fronteira; >15 → ruído; 'rapitan'/'entorn' no nome → classes próprias
EOF

# 3. contagens sem coords / sem província
python - <<'EOF'
import json
from collections import Counter
recs = json.load(open('www/explorer/bic_data.json', encoding='utf-8'))['records']
print(Counter(r['src'] for r in recs if r.get('la') is None))  # mad 443 … total 731
print(Counter(r['src'] for r in recs if not r.get('d')))       # cyl 1 + cat 11 = 12
EOF

# 4. UTM round-trip (amostra seed(4), 2/fonte) — gva colunas trocadas; nav pyshp cp1252;
#    gal ODS EPSG:25829; ext xlsx huso 29/30 por bbox; arg WKT EPSG:25830
#    → comparação |la_vista − la_pyproj| <= 1e-4  → 10/10 SIM (Δ máx ~5e-7)

# 5. bic-geo.json == records com la
python - <<'EOF'
import json
data = json.load(open('www/explorer/bic_data.json', encoding='utf-8'))
geo  = json.load(open('www/explorer/bic-geo.json', encoding='utf-8'))
w = {r['i']:(r['la'],r['lo']) for r in data['records'] if r.get('la') is not None}
p = geo['points']
print(len(w), len(p), {x['id'] for x in p}==set(w),
      all((w[x['id']][0],w[x['id']][1])==(x['lat'],x['lng']) for x in p))
EOF
```
