Saltar al contenido
geoespacial / · 5 min

Deduplicar por cercanía: ST_DWithin y por qué geography y no geometry

Tres vecinos reportan el mismo bache desde tres esquinas. Son un bache con tres reportes, no tres baches. Cómo se resuelve con diez metros de radio y por qué el tipo de la columna decide si escribes metros o grados.

Administrador · Desarrollador Laravel
Deduplicar por cercanía: ST_DWithin y por qué geography y no geometry

Tres vecinos de la misma cuadra reportan el mismo bache. Cada uno manda su ubicación desde donde está parado, así que llegan tres puntos separados por cuatro, siete y once metros.

Eso es un bache con tres reportes, no tres baches. Si el sistema no lo distingue, el ayuntamiento manda tres cuadrillas al mismo hoyo y los reportes ciudadanos dejan de servir para priorizar.

La deduplicación por cercanía se resuelve con una función de PostGIS y una decisión de tipo de columna que lo cambia todo.

La decisión: geography, no geometry

PostGIS tiene dos tipos espaciales y elegir mal se paga en cada consulta.

geometry trabaja sobre un plano cartesiano. Es rápido y es lo correcto cuando tus datos están proyectados —en UTM, por ejemplo, donde las unidades son metros sobre una proyección plana—.

geography trabaja sobre el elipsoide terrestre. Es más lento y sus distancias salen en metros, calculadas sobre la superficie de la Tierra.

Aquí está el problema real: si guardas puntos en geometry con SRID 4326 —o sea latitud y longitud— y preguntas por una distancia, la respuesta viene en grados. Y un grado no mide lo mismo en Hidalgo que en Alaska, ni mide lo mismo en latitud que en longitud. Terminas escribiendo constantes mágicas como 0.00009 y esperando que a nadie se le ocurra desplegar el sistema en otra latitud.

Por eso la columna es:

geom geography(Point,4326)

Y por eso el radio se escribe en metros, como se piensa:

ST_DWithin(a, b, 10)   -- diez metros. Literalmente.

Para un país entero, la diferencia de rendimiento entre los dos tipos es irrelevante frente a la de tener que razonar en grados.

La función

CREATE OR REPLACE FUNCTION bache_cercano(
  p_lat     double precision,
  p_lon     double precision,
  p_radio_m double precision DEFAULT 10
)
RETURNS TABLE (id uuid, folio text, num_reportes int, distancia_m double precision)
LANGUAGE sql
STABLE
AS $$
  SELECT
    b.id,
    b.folio,
    b.num_reportes,
    ST_Distance(b.geom, ST_SetSRID(ST_MakePoint(p_lon, p_lat), 4326)::geography) AS distancia_m
  FROM baches b
  WHERE ST_DWithin(b.geom, ST_SetSRID(ST_MakePoint(p_lon, p_lat), 4326)::geography, p_radio_m)
  ORDER BY distancia_m
  LIMIT 1;
$$;

Devuelve el registro más cercano dentro del radio, o nada. Con eso, el flujo de ingesta es:

llega un reporte
  └─ ¿hay un bache dentro de 10 m?
       ├─ sí  → num_reportes + 1, se guarda el reporte apuntando a ese bache
       └─ no  → se crea un bache nuevo con su folio

Y el created que devuelve ese paso es lo que después decide si se manda la notificación de «bache nuevo» o la de «un reporte más refuerza este bache».

ST_DWithin y no ST_Distance < 10

Parecen lo mismo y no lo son.

-- mal
WHERE ST_Distance(b.geom, punto) < 10

-- bien
WHERE ST_DWithin(b.geom, punto, 10)

ST_Distance calcula la distancia real de cada fila de la tabla contra tu punto, y después filtra. Es un recorrido completo, siempre.

ST_DWithin usa el índice espacial: primero descarta con las cajas envolventes todo lo que ni de lejos está cerca, y sólo calcula la distancia exacta de los pocos candidatos que sobreviven. En una tabla de cien mil puntos es la diferencia entre milisegundos y segundos.

Fíjate en que ST_Distance sí aparece en el SELECT —ahí lo quieres, para saber a cuántos metros quedó—, pero nunca en el WHERE.

El índice no es opcional

CREATE INDEX IF NOT EXISTS idx_baches_geom ON baches USING GIST (geom);

GIST, no BTREE. Un índice BTREE ordena valores en una línea; los polígonos y los puntos en un plano no tienen ese orden. GIST indexa por cajas envolventes anidadas, que es lo que ST_DWithin sabe aprovechar.

Sin él, ST_DWithin funciona pero se comporta igual de mal que ST_Distance.

Elegir el radio

Diez metros salió de mirar el problema, no de una fórmula.

Es más que el error típico del GPS de un teléfono en calle abierta —entre tres y ocho metros— y menos que la distancia entre dos baches que de verdad son distintos. Con veinte metros empiezas a fusionar hoyos separados que están en la misma cuadra; con cinco, el mismo bache reportado desde las dos aceras se duplica.

Es un parámetro con valor por omisión, no una constante incrustada, precisamente porque el número correcto depende del terreno. En una carretera podría ser cincuenta; en un mercado, tres.

Lo mismo sirve para otras cosas

El patrón —buscar el vecino más cercano dentro de un radio— resuelve varios problemas que no parecen el mismo:

  • Deduplicar reportes ciudadanos, que es este caso.
  • Validar que una captura de campo se hizo donde dice, comparando el punto contra el domicilio registrado.
  • Asignar el taxi más cercano a un servicio.
  • Detectar capturas sospechosas: cincuenta registros en el mismo punto con cero metros de diferencia es alguien capturando desde su casa en vez de en campo.

Ese último uso lo descubrí por accidente revisando datos, y resultó ser la herramienta de control de calidad más útil de toda la plataforma.

Un apunte sobre folios

El bache nuevo necesita un folio consecutivo, y ahí hay una trampa clásica de concurrencia. La forma correcta, sin bloquear ni arriesgar duplicados:

INSERT INTO folio_contador (estado, anio, consecutivo)
VALUES (p_estado, extract(year from now()), 1)
ON CONFLICT (estado, anio)
DO UPDATE SET consecutivo = folio_contador.consecutivo + 1
RETURNING consecutivo;

Un INSERT ... ON CONFLICT DO UPDATE ... RETURNING es atómico: dos peticiones simultáneas obtienen números distintos sin que tengas que tomar un bloqueo ni leer-y-después-escribir. Es la manera correcta de un contador en PostgreSQL, y mucho mejor que un SELECT MAX(...) + 1 que en concurrencia entrega el mismo folio dos veces.

#postgresql #rendimiento #postgis #sql #mapas
Comentarios 0

Nadie ha comentado todavía. Estrena la sección.

Deja tu comentario
Tu correo no se publica. Reviso los comentarios antes de publicarlos.

Seguir leyendo