31. Topología

PostGIS soporta las especificaciones SQL/MM SQL-MM 3 Topo-Geo y Topo-Net 3 a través de una extensión llamada postgis_topology. Puedes conocer todas las funciones y tipos que proporciona esta extensión en Manual: PostGIS Topology. La extensión postgis_topology incluye otra clase de tipo espacial central, llamado topogeometry. Además del tipo espacial topogeomety, encontrará funciones para construir topologías y poblar topologías.

Antes de poder empezar a usar topologías, debes instalar la extensión postgis_topology de la siguiente forma:

CREATE EXTENSION postgis_topology;

Después de instalar la extensión, verás un nuevo esquema en tu base de datos llamado topology. El esquema topology cataloga todas las topologías en tu base de datos.

El esquema topology contiene dos tablas y todas las funciones auxiliares para topología.

  • topology - lista todas las topologías en tu base de datos y en qué esquema están almacenadas

  • layer - lista todas las columnas de tabla en tu base de datos que contienen topogeometrías

La tabla layer es muy similar a los catálogos raster_columns, geometry_columns y geography_columns que aprendimos anteriormente, pero específicamente para topogeometrías.

31.1. Creación de topologías

¿Qué es exactamente una topología y una topogeometría, y cómo se relacionan? Antes de explicar, empecemos creando una topología para almacenar nuestros datos topológicamente perfectos de NYC usando la función CreateTopology y establezcamos la tolerancia en 0.5 metros. Nota que 0.5 está en metros ya que nuestro sistema de referencia espacial es State Plane NY metros.

SELECT topology.CreateTopology('nyc_topo', 26918, 0.5);

La salida es:

1

Este es el id asignado a la nueva topología. Una vez ejecutes el comando anterior, verás un nuevo esquema en tu base de datos llamado nyc_topo. Puedes nombrar la topología como quieras. Mi convención es añadir _topo al final para distinguirlo de otros esquemas que tengo en mi base de datos.

Si exploras la tabla topology.topology,

SELECT * FROM topology.topology;

Verás:

id |   name    | srid  | precision | hasz
----+----------+-------+-----------+------
  1 | nyc_topo | 26918 |         0 | f
(1 row)

31.2. Almacenamiento de topologías y topogeometrías

Una topología se implementa como un esquema en una base de datos PostgreSQL. Si exploras el esquema nyc_topo, verás estas tablas y vistas:

  • edge - Esta es una vista construida sobre edge_data, principalmente para cumplimiento con SQL/MM.

    Tiene un subconjunto de las columnas de la tabla edge_data.

  • edge_data - Contiene todas las linestrings que conforman la topología

  • face - Contiene una lista de todas las superficies cerradas que pueden formarse a partir de edge_data.

    No contiene la geometría real, sino solo la caja envolvente (bounding box) de la geometría.

  • node - Contiene todos los puntos iniciales y finales de todos los edges así como puntos no conectados a nada (nodos aislados)

  • relation - define qué elementos en una topología conforman una topogeometría.

Entonces, ¿qué es una topogeometría? Una topogeometría es una representación de una geometría formada por los edges, faces, nodes y otras topogeometrías en una topología.

¿Dónde reside una topogeometría? Reside en otro lugar que referencia elementos de una topología mediante la tabla relation. Aunque podríamos almacenar las topogeometrías en nuestro esquema nyc_topo, la convención general es definir otras tablas en otros esquemas que tengan una columna topogeometry, junto con cualquier otro tipo de datos que queramos manejar.

31.3. ¿Por qué usar topogeometrías?

El uso de topogeometrías mantiene tus datos organizados y conectados. Son muy útiles en trabajos catastrales, donde quieres asegurarte de que dos parcelas no se superpongan aunque cambies los límites de una, o asegurarte de que las carreteras permanezcan conectadas al modificar las geometrías que las forman. Las geometrías viven en su propio “isla”, puedes duplicarlas, modificarlas, sin importar otras geometrías. Las topogeometrías, en contraste, siguen las reglas de su topología; no pueden existir sin un edge, node, face u otra topogeometría que las defina. Una topogeometría pertenece a una y solo una topología. Una topogeometría es un modelo relacional de una geometría y, como tal, a medida que cada componente (edges/faces/nodes) se mueve o se modifica, cambian no solo una topogeometría, sino todas las que comparten esos componentes.

Tenemos una topología nyc_topo vacía de datos. Vamos a llenarla con nuestros datos de NYC. Los edges, faces y nodes de la topología pueden crearse de dos formas.

  • Crearlos directamente usando funciones primitivas de topología.

  • Crearlos a partir de topogeometrías. Cuando se crea una topogeometría a partir de una geometría y faltan edges, faces o nodes que coincidan con sus coordenadas, estos nuevos elementos se crean como parte del proceso.

31.4. Definición de columnas de topogeometría y creación de topogeometrías

La forma más común de poblar topologías es creando topogeometrías. Empecemos creando una tabla para almacenar barrios y luego añadamos una columna de tipo topogeometry usando la función AddTopoGeometryColumn.

CREATE TABLE nyc_neighborhoods_t(boroname varchar(43), name varchar(67),
  CONSTRAINT pk_nyc_neighborhoods_t PRIMARY KEY(boroname,name) );
SELECT topology.AddTopoGeometryColumn('nyc_topo', 'public', 'nyc_neighborhoods_t',
  'topo', 'POLYGON') As  layer_id;

La salida es:

layer_id
--------
1

Ahora estamos listos para poblar nuestra tabla. Es mejor asegurarse de que las geometrías sean válidas antes de añadirlas, de lo contrario aparecerán errores como “SQL/MM geometry is not simple”.

Así que empecemos añadiendo geometrías válidas. El 1 usado aquí se refiere al layer_id generado en la consulta anterior. Si no conoces el layer id, lo buscarías usando la función FindLayer <https://postgis.net/docs/FindLayer.html>, que usaremos en ejemplos posteriores.

Para estos ejemplos usarás la función toTopoGeom <https://postgis.net/docs/toTopoGeom.html> que convierte una geometría en su equivalente topogeometry. La función toTopoGeom gestiona gran parte del trabajo por ti.

La función toTopoGeom inspecciona la geometría pasada e inyecta nodes, edges y faces según sea necesario en tu topología para formar la geometría. Luego añadirá relaciones a la tabla relation que definen cómo esta nueva topogeometría se relaciona con los elementos nuevos y existentes de la topología. En algunos casos, piezas de la geometría ya pueden existir o necesitan dividirse para formar la nueva geometría.

INSERT INTO nyc_neighborhoods_t(boroname,name, topo)
SELECT boroname, name,  topology.toTopoGeom(geom, 'nyc_topo', 1)
  FROM nyc_neighborhoods
  WHERE ST_ISvalid(geom);

Este paso debería tardar 3-4 segundos. Ahora añadamos las no válidas:

INSERT INTO nyc_neighborhoods_t(boroname,name, topo)
SELECT boroname, name,  topology.toTopoGeom(
  ST_UnaryUnion(
    ST_CollectionExtract(
      ST_MakeValid(geom), 3)
      ), 'nyc_topo', 1)
  FROM nyc_neighborhoods
  WHERE NOT ST_ISvalid(geom);

Esto debería tardar unos 300-400 ms.

Ahora tenemos datos en nuestra topología. Una verificación rápida mostrará que nyc_topo.edge, nyc_topo.node y nyc_topo.face tienen datos:

SELECT 'edge' AS name, count(*)
  FROM nyc_topo.edge
UNION ALL
SELECT 'node' AS name, count(*)
  FROM nyc_topo.node
UNION ALL
SELECT 'face' AS name, count(*)
  FROM nyc_topo.face;

La salida es:

name | count
------+-------
edge |   580
node |   396
face |   218
(3 rows)

Ahora podemos expresar declarativamente que los boroughs se forman a partir de una colección de barrios definiendo una columna llamada topo en la tabla nyc_boros_t que es de tipo POLYGON y es una colección de otras topogeometrías de la columna nyc_neighborhoods_t.topo.

CREATE TABLE nyc_boros_t(boroname varchar(43),
  CONSTRAINT pk_nyc_boros_t PRIMARY KEY(boroname) );
SELECT topology.AddTopoGeometryColumn('nyc_topo', 'public', 'nyc_boros_t',
  'topo', 'POLYGON',
    (topology.FindLayer('public', 'nyc_neighborhoods_t', 'topo')).layer_id
        ) AS  layer_id;

La salida es:

Para poblar esta nueva tabla, usaremos la función CreateTopoGeom <https://postgis.net/docs/CreateTopoGeom.html>. En lugar de partir de geometrías para formar una nueva topogeometría, CreateTopoGeom parte de elementos de topología existentes que pueden ser primitivos u otras topogeometrías para definir una nueva topogeometría.

INSERT INTO nyc_boros_t(boroname, topo)
SELECT n.boroname,
  topology.CreateTopoGeom('nyc_topo',
  3,  (topology.FindLayer('public', 'nyc_boros_t', 'topo')).layer_id ,
    topology.TopoElementArray_Agg( ARRAY[ (n.topo).id, (n.topo).layer_id ]::topoelement ) )
  FROM nyc_neighborhoods_t AS n
GROUP BY n.boroname;

Esto insertará 5 registros correspondientes a los boroughs de Nueva York.

Nota

Si usas PostGIS 3.4 o superior, puedes usar el nuevo cast para convertir una topogeometría en un topoelement y reemplazar topology.TopoElementArray_Agg( ARRAY[ (n.topo).id, (n.topo).layer_id ]::topoelement ) ) en el ejemplo anterior por la forma más corta topology.TopoElementArray_Agg( n.topo::topoelement )

Para verlos en pgAdmin, puedes convertir la topogeometría a geometría de la siguiente manera:

SELECT boroname, topo::geometry AS geom
 FROM nyc_boros_t;

La salida se verá como:

_images/boros_topogeom.png

Si piensas, “qué desastre total”, sí, es un desastre total. Esto es lo que ocurre después de numerosos ciclos de simplificación y otros procesos de geometría donde cada geometría se trata como una unidad separada. Obtienes huecos, islas colgantes y barrios invadiendo el territorio de otros.

Afortunadamente, podemos usar topología para limpiar este desastre y ayudarnos a mantener datos limpios y conectados.

Pongámonos el sombrero de catastral y hagámonos la pregunta: si estamos dividiendo nuestras parcelas en distritos (boroughs o barrios) de modo que cada distrito pueda bordear otros distritos pero no compartir área en común, ¿tiene sentido que compartan áreas? No, no tiene sentido. Y aquí estamos con nuestros datos mostrando que algunas áreas pertenecen a más de un barrio o más de un borough.

Veamos primero los boroughs y busquemos barrios que compartan elementos en común:

SELECT te, array_agg(DISTINCT b.boroname)
 FROM nyc_boros_t AS b, topology.GetTopoGeomelements(topo) AS te
 GROUP BY te
 HAVING count(DISTINCT b.boroname) > 1;

La salida es:

  te    |     array_agg
--------+-------------------
{44,3}  | {Brooklyn,Queens}
{51,3}  | {Brooklyn,Queens}
{76,3}  | {Brooklyn,Queens}
{114,3} | {Brooklyn,Queens}
{117,3} | {Brooklyn,Queens}
(5 rows)

Lo que nos dice que Queens y Brooklyn están en medio de guerras fronterizas. En esta consulta usamos la función GetTopoGeomElements <https://postgis.net/docs/GetTopoGeomElements.html> para inspeccionar declarativamente qué componentes se comparten entre boroughs.

Lo que se devuelve es un conjunto de topoelementos. Un topoelemento se representa como un arreglo de 2 enteros, donde el primer número es el id del elemento y el segundo es la capa (o tipo primitivo) del elemento. PostGIS GetTopoElements devuelve los primitivos de una topogeometría con los tipos numerados del 1 al 3, correspondientes a (1: nodos, 2: aristas, y 3: caras). Todos los topoelementos de barrios y boroughs son de tipo 3, lo que corresponde a una cara topológica. Podemos usar la función ST_GetFaceGeometry para obtener una representación visual de estas caras compartidas de la siguiente manera:

SELECT te, t.geom, ST_Area(t.geom) AS area, array_agg(DISTINCT d.boroname) AS shared_boros
FROM nyc_boros_t AS d, topology.GetTopoGeomelements(topo) AS te
  , topology.ST_GetFaceGeometry('nyc_topo',te[1]) AS t(geom)
GROUP BY te, t.geom
HAVING count(DISTINCT d.boroname) > 1
ORDER BY area;

El resultado serán 5 filas correspondientes a disputas fronterizas entre Queens y Brooklyn.

Si miramos nuestros barrios, veremos una historia similar pero con 44 disputas fronterizas:

SELECT te, t.geom, ST_Area(t.geom) AS area, array_agg(DISTINCT d.name) AS shared_d
FROM nyc_neighborhoods_t AS d, topology.GetTopoGeomelements(d.topo) AS te
  , topology.ST_GetFaceGeometry('nyc_topo',te[1]) AS t(geom)
GROUP BY te, t.geom
HAVING count(DISTINCT d.name) > 1
ORDER BY area;

Dado que los boroughs son una agregación de barrios, podemos corregir el problema de los boroughs corrigiendo las disputas fronterizas de los barrios.

Hay varias formas en que podríamos arreglar esto. Podríamos salir a encuestar a la gente preguntándoles en qué barrio creen que están parados. Alternativamente, podríamos simplemente asignar pequeñas franjas de terreno al barrio con la menor cantidad de área o al mejor postor.

La eliminación de elementos de las topogeometrías se maneja usando la función TopoGeom_remElement. Así que procedamos a eliminar elementos duplicados de los barrios con la mayor cantidad de área de la siguiente manera:

WITH to_remove AS (SELECT te, MAX( ST_Area(d.topo::geometry) ) AS max_area, array_agg(DISTINCT d.name) AS shared_d
  FROM nyc_neighborhoods_t AS d, topology.GetTopoGeomelements(d.topo) AS te
    , topology.ST_GetFaceGeometry('nyc_topo',te[1]) AS t(geom)
  GROUP BY te
  HAVING count(DISTINCT d.name) > 1)
  UPDATE nyc_neighborhoods_t AS d SET topo = TopoGeom_remElement(topo, te)
  FROM to_remove
  WHERE d.name = ANY(to_remove.shared_d)
    AND ST_Area(d.topo::geometry) = to_remove.max_area;

El resultado de lo anterior es que se actualizaron 29 barrios. Si vuelves a ejecutar las consultas de disputas fronterizas para barrios y boroughs, verás que ya no tienes disputas fronterizas.

Todavía tenemos huecos de espacio vacío entre barrios causados por simplificación intensiva. Estos problemas pueden corregirse editando directamente la topología usando la familia de funciones Topology Editor y/o rellenando los huecos y asignándolos a los barrios.