| name | rail-accident-visors |
| description | Use when building train-accident visors (ERA/eRAIL, CIAF). |
| version | 1.0.0 |
| tags | ["railways","accidents","era","erail","adif","geolocation","visor","pipeline"] |
Rail Accident Visors — pipeline y diseño de visores de accidentes ferroviarios
Patrones verificados en CIAF-visor y era-visor (Ntizar). Para scraping de fuentes
gubernamentales españolas en general y parsing PDF ver el skill
government-data-pipelines (user-owned: leer, no editar) — este skill añade lo
específico del dominio ferroviario europeo y las preferencias de diseño del usuario.
Pipeline por país (reanudable, un comando por fase)
scrape_pais.py — descubre informes en ERA. Listado: https://www.era.europa.eu/era-folder/{COD}-investigations; páginas de año /era-folder/{YYYY}-{N}. Nunca rel=next (el book Drupal global cuela otros países).
descargar_pdfs.py — cortesía 8s; ante 429 backoff 120s×intento. 374 PDFs ES, 0 fallos.
extraer_pais.py — PDF→md con PyMuPDF; los escaneados quedan "pendiente OCR" y se contabilizan (no se inventa texto).
estructurar_pais.py — md→json con qwen (NaN API): prompt anti-invención, víctimas SIEMPRE del Excel eRAIL.
enriquecer_ia.py — segunda pasada LLM añade taxonomía v2 (subsistema, ATP ASFA/ERTMS/LZB, tipo_red, explotación, precursores, mitigaciones, factores humanos, meteorología). Lee el .md completo (cabeza 8000+cola 8000).
geocodificar_via.py — PK+línea → punto SOBRE la vía (ver abajo).
revisar_localizacion.py — auditoría estricta: (a) distancia GEOMÉTRICA a las
polilíneas de los tramos ADIF (no al marcador PKTeoricos más cercano — los
marcadores son escasos: portal de túnel, tramo largo… y provocan falsos dudosos;
caso real Álora 111/2024: coord a 0 m de la línea 030 declarada pero a 501 m del
marcador de su tramo → "duda" falsa; el proxy de marcadores se conserva solo como
diagnóstico dist_via_m) Y (b) cruce PK↔línea: el veredicto lo manda la
distancia geométrica a la línea DECLARADA — si cae sobre otra vía, será grande.
(c) nodo de estación ≠ otra vía: la geometría de tramos solo tiene el EJE en
línea, no el manojo de vías del recinto. metodo_geo=estacion_ign + vía más
cercana = línea declarada + d ≤ ESTACION_RADIO (1200 m) → 'bien' (caso Manresa
70/2022: 604 m de SU línea 220, dg == dgl — el motivo 'OTRA vía' era mentira).
Señal de falsos positivos del auditor: dist_via_geo_m == dist_linea_geo_m. Solo (a) es ciego al peor bug posible — Caleyo
(Oviedo) caía sobre otra vía lejana y el auditor lo marcaba "bien" durante ciclos
enteros. Además: provincia declarada vs INE (con alias de cabecera Oviedo→Asturias,
Santander→Cantabria y denominaciones antiguas Gerona→Girona, Lerida→Lleida; y
candidatas múltiples si raíz y ubicacion declaran líneas distintas). Salida
data/revision/.
PITFALL "misma_linea" demasiado estricto (2026-09): un punto por ESTACIÓN puede ser
correcto estando sobre la red, aunque la distancia a la línea DECLARADA sea grande. Si la
línea declarada es un ramal interno o se trunca en ADIF, dgl engaña: Salou (pk 263 línea
600 — ADIF la trunca en pk 254) o Zaragoza-Delicias (ramal 060 sin geometría) se marcaban
"duda" con el punto correcto a 11 m de la vía real. Regla fijada: si metodo_geo es
estacion_* y dg (dist a la vía real MÁS CERCANA) ≤ ESTACION_RADIO → "bien", aunque
dgl sea grande. El matcher estricto de estación garantiza que el punto por estación es el
correcto por construcción; el cruce de línea declarado pasa a ser una etiqueta, no un error.
revisar_json.py — revisor IA: revalida cada json contra su .md y corrige campos mal interpretados en sitio, con log de cambios. Determinista primero (fechas vacías, formatos), LLM después.
consolidar.py — json/*→data/db/ con dedupe BIDIRECCIONAL por expediente: un solo
registro por expediente; gana quien tenga análisis (v3/hechos), empate → CIAF; el
perdedor aporta los campos que falten (lista amplia: v2, resumen, causa, tags, trenes,
entidades, geo...). El dedupe de un solo sentido ("CIAF gana si llega después") deja
duplicados cuando el orden de ficheros varía — 71 duplicados en producción.
Propagar metodo_geo al registro final. CRÍTICO — orden de escritura: cualquier
normalización de campos DEBE ejecutarse ANTES de write_text(reports/XX.json);
normalizar después de escribir no cambia nada en disco (bug real que costó varias
rondas de debugging).
CRÍTICO — index fantasma: el merge del index.json NO puede ser acumulativo
(merged = index + overlay). Tras archivar duplicados, el país re-consolidado hay
que REEMPLAZARLO entero (conservar solo claves de OTROS países) o el índice conserva
registros muertos — 619 entradas para 349 informes en producción, y el frontend los
renderiza con strings 'None'. Verificar siempre len(index) == nº de informes.
Autoridad de campos: las consecuencias del análisis v3 MANDAN sobre el JSON
base (fallecidos, heridos_graves, heridos_leves) para TODOS los registros —
la primera pasada sistemáticamente deja víctimas a 0 en informes recientes y el
usuario lo detecta en el gráfico de evolución ("seguro que no hay fallecidos desde
2014?"). Igual criterio: fecha = fecha del SUCESO, no del informe ni del
expediente ("lo importante de la fecha es cuando ocurre el siniestro").
Prioridad geo: en pk/linea/provincia, el bloque ubicacion verificado por
el geocodificador MANDA sobre los campos raíz del LLM (raíz tenía '244' erróneo y
ubicacion '422' correcto en Pedrera; al revés que las víctimas, donde v3 manda).
extraer_completo.py — extracción v3 profunda (ver references/extraccion-v3.md): análisis IA de TODO el informe → cronología minuto a minuto, infraestructura, personal, causas (directa/contribuyentes/sistémicas), lecciones, recomendaciones con destinatario, idioma_original. Antes: limpiar_md() elimina el índice del .md (líneas con puntos de relleno ..... 12) — sin esto el índice acaba colado en el campo "descripción" y el usuario lo detecta al instante.
verificar_todo.py — comprobación integral reutilizable (pdfs↔md↔json↔DB, dupes md5, magic bytes, fantasmas, 1-registro-por-expediente, coords gemelas, veredictos del auditor, cache-busting) → data/revision/XX-verificacion.md. --limpiar archiva duplicados md5 a _duplicados_descartados/; exit ≠ 0 si hay ERROR (apto como gate en cron). Su 1ª pasada encontró 3 duplicados que la limpieza manual previa dejó pasar — la comprobación debe ser script fija, no lista ad hoc por sesión. Ver references/comprobacion-integral.md.
ORDEN crítico de la fase 8-11 (ida y vuelta obligatoria): el auditor revisar_localizacion.py lee las coords de la DB generada → debe correr DESPUÉS de consolidar.py; y consolidar.py propaga geo_veredicto/geo_motivo del auditor a cada registro → tras consolidar hay que re-auditar y re-consolidar hasta que las cifras del verificador cuadren. Secuencia estable: geocodificar → consolidar → auditar → consolidar → verificar_todo.
Profundidad uniforme (feedback duro del usuario)
"Hay incidentes con mucha información y otros con muy poca o desordenada. Si no somos
capaces de hacerlo bien para España no seremos capaces para Europa." El schema mínimo
(tipo/fecha/pk/resumen) NO basta: cada registro debe llevar la extracción v3 completa.
Títulos en inglés sin normalizar también rechazados → titulo_normalizado en castellano
idioma_original. Ver references/extraccion-v3.md para el schema y los pitfalls de
la API (formato vs llaves, tokens de razonamiento, parseo tolerante).
Geolocalización sobre la vía (la clave de la calidad)
- WFS IDEADIF:
https://ideadif.adif.es/gservices/Tramificacion/wfs — capas
Tramificacion:PKTeoricos (~17.200 puntos sobre vía, con codtramo, pk,
id_provinc) y Tramificacion:TramosServicio (1.178 tramos: cod_eje,
pki/pkd, geometría). Descargar ambos a disco (~20 MB), trabajar local.
- Algoritmo: normalizar PK y línea → tramos del mismo
cod_eje cuyo rango
pki-pkd contenga el PK → interpolar sobre la geometría. Cadena: PKTeoricos
exacto → interpolación → estación IGN → Nominatim.
- CAUSA RAÍZ del bug Caleyo (estructura codtramo): el
codtramo ADIF es
eje(2)+línea(3)+seq(4) (9 dígitos) — el código de línea CIAF vive en
codtramo[2:5], NUNCA en codtramo[:3]. Indexar tramos/pk por [:3] hace que
la línea 130 (Gijón–Venta de Baños) case con el eje '061' (Teruel–Sagunt) y
accidentes asturianos aterricen en Guadalajara. Al buscar por línea, usar el
prefijo numérico de cod_linea ('130-Gijón...' → '130') Y codtramo[2:5] como
claves del índice (un tramo puede pertenecer a ambas vistas).
- id_provinc del GeoJSON ADIF = código INE (verificado: Guipúzcoa→20, Cuenca→16,
Jaén→23, Palencia→34). Cualquier mapa propio IDP→provincia hay que validarlo contra
un tramo conocido ANTES de fiarse — el que había estaba desplazado y filtraba por
provincias equivocadas en silencio. 93 tramos tienen
idp=0: resolver su provincia
vía texto provincia del tramo (con alias) o, en PKTeoricos, vía su codtramo →
tramo → provincia.
- La ruta PK-sin-línea no debe depender de la coord previa para elegir match:
una coordenada previa errónea se auto-perpetúa (el filtro de cercanía descarta el
candidato correcto). Filtrar por pk+línea+provincia primero y solo usar la coord
vieja como desempate entre varios matches válidos.
- El reintento SIN la provincia declarada debe ejecutarse de verdad: en
geocodificar_via.py el fallback «línea+PK ignorando provincia» vive en una cadena
if/elif y, si queda detrás del elif que consume el fallo con provincia, es
INALCANZABLE — el script sale 0 y la coord ni se mueve (silencioso). Después de
tocar la cadena, verificar que la coord del registro objetivo CAMBIÓ, no solo el
exit code. Caso real 64/2012: el CIAF declara «Huesca» pero Villanueva de Gállego
es ZARAGOZA; los tramos 200 de Huesca no cubren el PK 25 y solo el match sin
provincia clava el punto correcto (interpolación sobre el tramo de la 200).
La provincia de un informe CIAF puede ser simplemente errónea: ante conflicto
provincia-vs-geometría, manda la línea+PK.
- parse_linea: tolerar prefijo 'Línea ' de los CIAF ('Línea 130 ...' — sin ello
el registro cae a la ruta buggy), códigos LV de 4 dígitos ('9502' alta velocidad),
y zfill solo para ≤3 dígitos (no corromper los LV). PROBLEMA+fix (2026-09): muchos
informes escriben la línea SIN código numérico ('Valencia - San Vicente de Calders')
y
parse_linea devuelve None → el geocodificador no puede casar por línea y caía al
fallback por provincia, aterrizando en OTRA línea (caso 34/2007 → pk 244,350 cruzado
a la línea 200 en Zaragoza). Fix: si parse_linea no da código, recuperarlo del
texto del informe (erail Location name/título/resumen) con regex
\(\s*(\d{3})\s*[A-Za-z] ('(600 Valencia-... railway line)') → interpolación por
línea correcta.
- PROBLEMA id_provinc=0 en PKTeoricos (bug de clase, 2026-09): los PKTeoricos de
líneas correctas a menudo traen
id_provinc=0 (provincia sin resolver en INE). El
filtro if idps and to_int(idp) not in idps: continue los EXCLUÍa TODOS → match
vacío → se conservaba la coord previa stale/errónea (34/2007 en Zaragoza). Fix:
solo excluir provincias CONOCIDAS distintas — if idp not in (None,0) and idp not in idps: continue.
Así id_provinc=0 (válido) deja de ser descartado y el PK correcto de la línea entra.
- Directiva del usuario (2026-09): snap a estación/población nombrada — PERO con matiz de
precisión. Si el título/ubicación nombra la estación, David quiere el pin en ella ("si el
título tiene el nombre de la estación ponlo en la estación... no es tan difícil. O si aparece
la población"). Para la clase, snap a la estación cuando la ubica CLARAMENTE.
PELIGRO — no hacer auto-snap masivo por nombre desde el TÍTULO: los títulos llevan la
ruta completa del tren ("Madrid Chamartín", "Intermodal Abando Indalecio", "San Vicente",
"Sevilla-Santa Justa") → un match por substring devuelve cientos de falsos positivos
(barrido real: 248 de 276 candidatos eran espúreos). Implementación SEGURA: snap SOLO desde
el campo
estacion explícito o el patrón de ubicación oficial del eRail Location name
("in the vicinity of X" / "en la estación de X"), con match estricto por PALABRA COMPLETA
(no substring) y desempate por el patrón de ubicación, no por longitud del nombre.
Matiz de precisión: cuando el informe tiene PK+línea resoluble, la interpolación sobre
la vía GANA — David eligió "precisión sobre la vía" para el 34/2007 aun estando el punto a
~17 km de la estación nombrada; el snap a estación es el fallback/cuando la ubica claramente.
metodo_geo="poblacion" es legítimo → hay que enseñárselo al AUDITOR (2026-09). Cuando
la línea declarada NO existe en la geometría ADIF (p.ej. la CIAF escribe "510 Aljucén-Cáceres"
pero ADIF solo conserva 1 tramo de esa línea en otra zona, pk 140-142) y la estación no está
mapeada (Carmonita ni en el cache IGN de 2.000 ni en OSM), el punto correcto es la POBLACIÓN
declarada (regla de David): geocodificar el municipio vía Nominatim y escribir
metodo_geo="poblacion". OJO: el auditor revisar_localizacion.py mide distancia a la línea
y marca esos puntos como "mal" porque un centro urbano no cae sobre un raíl — hay que añadir una
regla al auditor (if metodo_geo.startswith("poblacion"): veredicto="bien", con motivo
"ubicación por población declarada; sin geometría de línea en ADIF"). Sin eso, el informe de
verificación listará la población como error indefinidamente. NO "snap a la línea ADIF más
cercana" — junto a Carmonita la ADIF más cercana es la 026 Plasencia, NO la 510: el pin dejaría
de estar en la localidad declarada y seguiría "mal" (además en la vía equivocada). Errores cross
reales fijados así esta sesión: 49/2010→Carmonita (estaba en Zafra a ~55 km) y 4/2016→Elx-Parc
(estaba en Sabadell-Parc del Nord/Barcelona por falso match de substring "Parc").
- Pitfall reproducción del geocodificador (diagnóstico):
idps_de y puntos_pk están
ANIDADAS en main() — al importar geocodificar_via no se exponen; g.idps_de(...)
devuelve None y enmascara que idps en realidad es [43] (Perdí 2 rondas de repro por
esto). Para reproducir la lógica hay que replicar idps_de inline. El observador DEFINITIVO
de un cross-line es inspeccionar los PKT (o tramos) candidatos con pk±tol y ver su
id_provinc/codtramo[2:5] — un punto de la línea correcta puede venir con id_provinc=0
y es justo el que el filtro excluía (caso 34/2007).
- Pitfall parse PK: cubrir TODAS las notaciones en una sola regex con
separadores
[+,./] — la barra / es notación CIAF (124/573 = 124,573 km)
y sin ella ~14 informes por tanda se quedan sin geocodificar. Anclar con
(?!\d) tras los decimales, no con $ (los PK suelen llevar texto detrás).
- Geocodificación por estación IGN (
geocodificar_estacion.py): dataset
RedFerrocarrilesIGN FeatureServer (services1.arcgis.com/nCKYwcSONQTkPA4K,
~3.000 estaciones, CC-BY) para informes sin PK casable pero con estación.
Pitfall matcher: NO casar por contención de substring (n_est in e["norm"])
ni elegir el primer candidato — "León" cae dentro de nombres ajenos y todo el
lote acaba en un mismo punto falso. Reglas: coincidencia exacta, o contención
de PALABRA COMPLETA (len>=4, todas las palabras presentes), desempate por
provincia INE, y ABSTENERSE si queda más de un candidato (mejor sin coordenada
que mal puesta; metodo_geo="estacion_ign" para poder deshacer por clase).
Cache del dataset: copiar las 2 páginas del FeatureServer a data/ign-estaciones*.json
y leer de ahí — si solo viven en Temp, se pierden entre sesiones y el script
muere (el arreglo de Caleyo se bloqueó una sesión entera por esto).
- Sistema de comprobación integral (pdfs↔md↔json↔DB, fantasmas, hash dupes):
ver
references/comprobacion-integral.md.
- Pitfall verificación: tras cada pasada nueva, releer la auditoría completa
— una corrección por clase puede introducir una clase de error nueva (la 1ª
pasada de estaciones metió 45 "mal" antes de detectarse).
- Los
mal del auditor se clasifican solos: coord fuera de vía real vs "pegado a
vía pero línea no cuadra" (PK ambiguo entre líneas / error de dígito del LLM en la
línea raíz). Para cada mal, mirar las líneas de los tramos a <1 km de la coord —
si una es pariente de la declarada (244 vs 422 Pedrera: transposición), es ruido del
auditor o del parser, no un re-geocodificado. No re-geocodificar a ciegas.
- Recintos sin geometría propia (cambiadores de ancho, clasificaciones): ramales
de servicio como
060 Bifurcación Cambiador Zaragoza-Delicias… a veces NO tienen
tramo en adif-tramos.geojson; el punto correcto (el cambiador, a 11 m del recodo
enlazado a la 200) sale como 'OTRA vía' porque la línea declarada está a 1.4 km.
Señal: distancia global tiny (≤50 m) y la línea cercana es la matriz de la declarada
→ ubicación BUENA; documentarlo en fuente_geo del JSON, no mover la coord.
- Ronda final de residuos = uno a uno con evidencia, no otra pasada de pipeline.
Cuando quedan pocos casos (
son pocos, puedes comprobarlo — el usuario lo exige así):
por registro (1) abrir su JSON y el PDF fuente (PyMuPDF fitz si pypdf falla) y sacar
textualmente municipio, origen-destino del tren y PK; (2) localizar el punto EXACTO en
OpenStreetMap vía Overpass (nwr["railway"~"station|halt"]["name"~"…",i](bbox) con
urllib POST, o Nominatim para localidades) — el name del nodo confirmado en la
respuesta cuenta como verificación; (3) escribir lat/lng en el JSON con
metodo_geo="estacion_adif" y fuente_geo citando el node/<id> OSM (trazable,
CC BY 4.0); (4) relanzar revisar→consolidar→verificar y contestar con
zona+origen/destino+PK+coordenada POR CASO, no con estadísticas del lote. NUNCA
escribir una coord de memoria sin haberla visto en la respuesta del servidor.
Si Overpass no devuelve el nombre exacto (clasificaciones/yards sin etiquetar),
ensanchar bbox y quitar el filtro railway, y como último recurso usar el punto del
eje ADIF interpolado por PK y declararlo en fuente_geo. Receta completa con consultas
Overpass/Nominatim y nodos ya verificados: references/verificacion-puntual-osm.md.
- Pitfall al corregir JSON en lote por nombre de fichero:
glob.glob('json/ES/*.json')
con stems que llevan corchetes literales (ID-211207-290408-CIAF[1].json) no casa —
[1] es una clase de caracteres del glob. Usar os.listdir + substring del basename,
o glob.escape.
- Resultado ES con auditor estricto sobre 349 limpios (post-dedupe): ~294 bien, 0 mal
tipo Caleyo, ~47 sin geo legítimos (informes sin PK ni estación). Las cifras exactas
cambian por pasada — lo que importa es que ningún
bien sea un cross-line.
Datos del dominio
- Excel eRAIL (~4.000 inv × 64 cols): la columna "Investigation report" está vacía
(4.033/4.067) — los PDFs viven en la web, no en el Excel.
- ES: 374 PDFs 2006-2025, 269 informes CIAF verificados (importables como base rica).
- PK de informes:
20+350, P.K. 429,825, 5/350, 11,907. Líneas: 010 Madrid Puerta de Atocha - Sevilla Santa Justa.
Diseño del visor (preferencias duras de David)
- Mapa base IGN WMTS, NUNCA tiles OSM/CARTO en proyectos públicos: "hay que usar
mapas públicos en cosas públicas" (petición literal, 2026-09). IGN base vía
https://www.ign.es/wmts/ign-base?...LAYER=IGNBase-gris...&FORMAT=image/jpeg
(FORMAT obligatorio o 400) con selector gris/topográfico/ortofoto y atribución
"© IGN — Instituto Geográfico Nacional (CC BY 4.0)". Detalle en el skill
ign-wmts-tiles (verificado: tile de prueba con curl file antes de pushear).
- Tipos de suceso = las 6 categorías oficiales (Directiva UE 2016/79 / ERA):
colisiones, descarrilamientos, accidentes en pasos a nivel, daños a personas por
material rodante en movimiento, incendios, otros. Los tipos crudos del LLM se
MAPEAN a categoría (tabla
TIPO_A_CATEGORIA) y el tipo fino queda como subtipo
mostrado "Categoría — Subtipo" en ficha; el filtro y el dashboard van por
categoría. Subdividir está permitido SOLO cumpliendo las 6.
- Título siempre
IF <código> (del expediente, sin ceros: 0041/2014 →
IF 41/2014), con el título descriptivo como subtítulo debajo. El usuario lo
pidió explícitamente: los títulos largos crudos del parser no valen.
- Chips/secciones de lista SIEMPRE con su CSS definido: si renderizas spans
dentro de un contenedor, verifica que la clase exista en el
<style> — una clase
.detail-tag sin CSS produce "accidente colisión descarrilamiento" todo pegado