Работа с географическими координатами на примере расширения PostGIS в PostgeSQL

блог о bi, №1 в рунете
В наше время данные с географической привязкой играют важную роль в бизнесе и различных сервисах. На первый взгляд может показаться, что у работы с координатами достаточно узкая область применения – карты и все что с ними связано. На самом деле это не так. Способность работать с пространственной информацией становится большим преимуществом практически в любой сфере деятельности такие как: логистика, транспорт, маркетинг, ритейл, сельское хозяйство, строительство и многие другие. В статье будут рассмотрены различные примеры использования географических данных, которые могли бы использоваться, например в логистике или продажах.
Существует множество систем управления базами данных, поддерживающих работу с географическими координатами: некоторые из них платные, другие — бесплатные; при этом одни отличаются большей функциональностью и производительностью, а другие уступают в этих аспектах. Одной из таких СУБД является PostgreSQL — бесплатная и open-source система управления базами данных, которая с помощью расширения PostGIS превращается в мощную геоинформационную платформу, способную на равных конкурировать с дорогостоящими специализированными решениями. Именно про данное расширение мы и поговорим в нашей статье.
Рассмотрим примеры использования координат на примере задач.

Задача №1

Представим ситуацию, что мы хотим открыть пункт выдачи заказов (далее ПВЗ). Главным критерием для нас будет найти место, где будет много потенциальных клиентов и мало (идеально вообще нет) аналогичных ПВЗ конкурентов. Попробуем решить данную задачу с помощью PostGIS.
Для начала подготовим данные для анализа. Данные о домах, объектах и другой полезной информации можно найти как на платных сайтах, предоставляющих уже готовую информацию за деньги, так и на общедоступных сайтах. Мы воспользуемся третьим вариантом – часть данных взята с общедоступных сайтов https://yandex.com.ru/maps и https://наш.дом.рф, а часть придумана автором статьи для большей вариативности.
Создадим таблицу edu.objects, в которой будет хранится информация обо всех объектах, их координатах, а для жилых домов еще и количестве жилых квартир. Именно этот параметр мы будем брать за основу при принятии решения, где располагать ПВЗ.
CREATE TABLE IF NOT EXISTS edu.objects (
    id SERIAL PRIMARY KEY,
    name TEXT,
    type TEXT, -- дом, аптека, магазин и т.д.
    geom GEOMETRY(Point, 4326), -- координаты (широта/долгота)
    residents int – кол-во жилых квартир
);
Вставим в таблицу данные об объектах. В приложении представлен скрипт для вставки.
Для визуализации полученных результатов будем использовать утилиту QGIS — это свободная и бесплатная географическая информационная система (ГИС), с открытым исходным кодом, которая хорошо интегрируется с PostgreSQL/PostGIS. В ней можно добавить карту и на ней смотреть наши объекты.
Первым делом добавим слой – карту мира.
Для этого нажимаем вкладку «Слой» -> «Добавить слой» -> «Добавить таловый слой XYZ». QGIS поддерживает большинство сервисов карт, таких как google.maps, яндекс.карты и другие. Для этого нужно создать слой, добавив url на соответствующую карту и дать имя слою. Кроме того, можно выбрать одну из двух карт по умолчанию. Мы выберем карту по умолчанию – OpenStreetMap.
Далее добавим на карту наши объекты.
Нажимаем вкладку«Слой» -> «Добавить слой» -> «Add PostgeSQL Layer». Будет открыто подключение к БД PostgreSQL, выберем тут нашу таблицу edu.objects. Далее щелкаем правой кнопкой мыши на появившийся слой objects и заходим в свойства, выбираем «Стиль» и в самом верху выбираем «Символизация по уникальным значениям». Выбираем в поле «Значение» - атрибут type, нажимаем кнопку «Классифицировать». Будет сгенерирован цвет и обозначение (в нашем случае кружки) для каждого типа объектов. Настраиваем тип и цвет как удобнее и таким образом на карте мы увидим объекты, у которых будет соответствующая подсветка по типу объекта. Добавим еще подпись, в которой будет отображаться число жилых квартир. Там же в свойствах выбираем «Подписи» и в «Значении» выбираем атрибут residents. Таким образом для жилых домов будут отображено число жилых квартир.
Далее, для наглядности добавим области парков и границ ЖК, который будем рассматривать. Для этого создадим таблицу edu.area:
CREATE TABLE IF NOT EXISTS edu.area (
    id SERIAL PRIMARY KEY,
    name TEXT,
    geom geometry(Polygon, 4326)
);
Добавим в новую таблицу области парков, границу ЖК и область жилых домов.
Далее, аналогично таблице edu.objects, добавляем таблицу edu.area новым слоем на карту. После всего этого у нас получится вот такая картинка:
Теперь напишем запрос, который позволит найти лучшее расположение ПВЗ. Можно придумать много различных условий по котором мы выбираем лучшее место для ПВЗ, поэтому если предложенный подход кажется вам слишком простым, можно усложнить его дополнительными ограничениями или условиями. Мы же попробуем найти лучшее место для ПВЗ двумя вариантами:
1) ПВЗ располагается в жилом доме
2) ПВЗ располагается в любом месте внутри области ЖК
Основным критерием «лучшего места» будет определяться наибольшее количество жилых квартир в выбранном радиусе. Радиус выбора будет 300 метров. Так же смотрим чтобы в данном радиусе не было ПВЗ.
Итак, напишем запрос для оптимального места с учетом п.1 - ПВЗ располагается в жилом доме и окрестностях нет другого ПВЗ.
WITH params AS (
    SELECT 300::double precision AS radius -- радиус охвата, м
)
SELECT
    b.id AS candidate_building_id,
    b.name AS candidate_building,
    b.geom,
    SUM(nb.residents) AS total_residents,-- кол-во жилых квартир
    COUNT(nb.id) AS buildings_covered    -- кол-во жилых домов
FROM edu.objects b
CROSS JOIN params p
JOIN edu.objects nb
    ON ST_DWithin(b.geom::geography, nb.geom::geography, p.radius) and nb.type = 'жилой дом'
WHERE b.type = 'жилой дом' and
NOT EXISTS (
    SELECT 1
    FROM edu.objects o
    WHERE o.type = 'пвз'
      AND ST_DWithin(o.geom::geography, b.geom::geography, p.radius) -- фильтр: слишком близко к уже существующему ПВЗ
)
GROUP BY b.id, b.name, b.geom
ORDER BY total_residents DESC
LIMIT 5;
Обернем этот запрос в view, назовем edu.top_5_buildings_for_pvz. Далее вытащим эту view как новый слой и сможем увидеть следующие результаты:
И на карте:
Можно изменить условие. Например, мы теперь рассматриваем варианты домов, где уже есть ПВЗ рядом, но будет делить число потенциальных жилых квартир на число ПВЗ в округе, включая потенциальное наше новое (т.е. count(пвз)+1). Тогда запрос будет выглядеть так:
WITH params AS (
  SELECT 300::double precision AS radius  -- радиус охвата, м
)
SELECT
  b.id                     AS candidate_building_id,
  b.name                   AS candidate_building,
  b.geom,
  COALESCE(nb_agg.total_residents, 0) AS total_residents_full,
 (COALESCE(nb_agg.total_residents, 0)::double precision) /
    (COALESCE(pvz_agg.cnt_pvz, 0) + 1) AS total_residents, -- score: жителей вокруг / (кол-во ПВЗ в радиусе + 1)
  COALESCE(nb_agg.buildings_covered, 0) AS buildings_covered, -- число зданий в заданном радиусе
  COALESCE(pvz_agg.cnt_pvz, 0) AS cnt_pvz – кол-во ПВЗ в радиусе охвата
FROM edu.objects b
CROSS JOIN params p
-- агрегат по соседним жилым домам (без дублирования)
LEFT JOIN LATERAL (
  SELECT
    SUM(nb.residents) AS total_residents,
    COUNT(*)          AS buildings_covered
  FROM edu.objects nb
  WHERE nb.type = 'жилой дом'
    AND nb.id <> b.id  -- исключаем сам дом
    AND ST_DWithin(b.geom::geography, nb.geom::geography, p.radius)
) nb_agg ON true
-- агрегат по ПВЗ в радиусе
LEFT JOIN LATERAL (
  SELECT COUNT(*) AS cnt_pvz
  FROM edu.objects pvz
  WHERE pvz.type = 'пвз'
    AND ST_DWithin(b.geom::geography, pvz.geom::geography, p.radius)
) pvz_agg ON true
WHERE b.type = 'жилой дом'
ORDER BY total_residents DESC
LIMIT 5;
Пересоздадим нашу view edu.top_5_buildings_for_pvz с новым запросом. Получим следующий результат:
И он же на карте:
Теперь представим, что можем расположить ПВЗ в любой точке внутри области ЖК. Напишем запрос, который покажет, где оптимальное место для этого ПВЗ. Шаг сетки, на котором будем искать оптимальную точку, возьмем 25 м. Радиус поиска оставим тот же 300 м.
WITH params AS (
    SELECT 300::double precision AS radius  -- радиус охвата, м
),
extent AS (
    -- рамка по всем жилым домам
    SELECT ST_Envelope(ST_Collect(geom)) AS geom
    FROM edu.objects 
    WHERE type = 'жилой дом'
),
grid AS (
    -- гексагональная сетка
    SELECT
        row_number() OVER () AS id,
        ST_Centroid(cell) AS geom
    FROM extent e,
         LATERAL ST_HexagonGrid(
             0.00025, -- шаг сетки в градусах (~25 м)
             e.geom
         ) g(cell)
),
scores AS (
    SELECT
        g.id,
        g.geom,
        round((COALESCE(nb_agg.total_residents, 0)::double precision) /
              (COALESCE(pvz_agg.cnt_pvz, 0) + 1))::int AS total_residents,
        COALESCE(nb_agg.buildings_covered, 0) AS buildings_covered,
        COALESCE(nb_agg.total_residents, 0)   AS residents,
        COALESCE(pvz_agg.cnt_pvz, 0)          AS cnt_pvz
    FROM grid g
    CROSS JOIN params p
    -- агрегат по соседним жилым домам
    LEFT JOIN LATERAL (
        SELECT
            SUM(nb.residents) AS total_residents,
            COUNT(*)          AS buildings_covered
        FROM edu.objects nb
        WHERE nb.type = 'жилой дом'
          AND ST_DWithin(g.geom::geography, nb.geom::geography, p.radius)
    ) nb_agg ON TRUE
    -- агрегат по ПВЗ в радиусе
    LEFT JOIN LATERAL (
        SELECT COUNT(*) AS cnt_pvz
        FROM edu.objects pvz
        WHERE pvz.type = 'пвз'
          AND ST_DWithin(g.geom::geography, pvz.geom::geography, p.radius)
    ) pvz_agg ON TRUE
    -- фильтруем по полигону ЖК
    WHERE ST_Within(g.geom, (SELECT geom FROM edu.area WHERE name = 'ЖК'))
)
SELECT s.id,
       s.geom,
       s.total_residents,
       s.buildings_covered,
       s.residents,
       s.cnt_pvz
FROM scores s
ORDER BY s.total_residents DESC
LIMIT 5;
Результат запроса:
Обернем данный запрос в view edu.top_5_place_for_pvz и добавим эту view новым слом на карту.
Как мы видим по карте топ 3 лучших места находятся в одном из парков, что логично, ведь если не ограничивать поиск места для ПВЗ только в жилых домах, то можно найти места с большим охватом жилых квартир. Но в реальной жизни, надо учитывать еще другие факторы такие как аренда, налоги и т.д.

Задача №2

Теперь представим, что мы компания застройщик и нам нужна информация (например, для сайта или буклета продаж) по всем домам с количеством объектов по соседству в интервале 300м:
SELECT 
    d.name AS dom_name,
    -- считаем разные типы объектов в радиусе 300 м
    COALESCE(a.cnt, 0) AS "Аптеки",
    COALESCE(c.cnt, 0) AS "Кафе",
    COALESCE(m.cnt, 0) AS "Магазины",
    COALESCE(p.cnt, 0) AS "Парки",
    COALESCE(s.cnt, 0) AS "Школы",
    COALESCE(o.cnt, 0) AS "Офисы",
    COALESCE(tc.cnt, 0) AS "ТЦ",
    COALESCE(b.cnt, 0) AS "Больницы",
    COALESCE(pvz.cnt, 0) AS "ПВЗ",
     COALESCE(a.cnt, 0) + COALESCE(c.cnt, 0) +
     COALESCE(m.cnt, 0) + COALESCE(p.cnt, 0) + 
     COALESCE(s.cnt, 0) + COALESCE(o.cnt, 0) + 
     COALESCE(tc.cnt, 0) + COALESCE(b.cnt, 0) + COALESCE(pvz.cnt, 0) as sum_object
FROM edu.objects d
-- ограничиваем только жилыми домами
-- аптеки
LEFT JOIN LATERAL (
    SELECT COUNT(*) AS cnt
    FROM edu.objects o
    WHERE o.type = 'аптека'
      AND ST_DWithin(d.geom::geography, o.geom::geography, 300)
) a ON TRUE


-- кафе
LEFT JOIN LATERAL (
    SELECT COUNT(*) AS cnt
    FROM edu.objects o
    WHERE o.type = 'кафе'
      AND ST_DWithin(d.geom::geography, o.geom::geography, 300)
) c ON TRUE


-- магазины
LEFT JOIN LATERAL (
    SELECT COUNT(*) AS cnt
    FROM edu.objects o
    WHERE o.type = 'магазин'
      AND ST_DWithin(d.geom::geography, o.geom::geography, 300)
) m ON TRUE


-- парки
LEFT JOIN LATERAL (
    SELECT COUNT(*) AS cnt
    FROM edu.objects o
    WHERE o.type = 'парк'
      AND ST_DWithin(d.geom::geography, o.geom::geography, 300)
) p ON TRUE


-- школы
LEFT JOIN LATERAL (
    SELECT COUNT(*) AS cnt
    FROM edu.objects o
    WHERE o.type = 'школа'
      AND ST_DWithin(d.geom::geography, o.geom::geography, 300)
) s ON TRUE


-- офисы
LEFT JOIN LATERAL (
    SELECT COUNT(*) AS cnt
    FROM edu.objects o
    WHERE o.type = 'офис'
      AND ST_DWithin(d.geom::geography, o.geom::geography, 300)
) o ON TRUE


-- торговые центры
LEFT JOIN LATERAL (
    SELECT COUNT(*) AS cnt
    FROM edu.objects o
    WHERE o.type = 'торговый центр'
      AND ST_DWithin(d.geom::geography, o.geom::geography, 300)
) tc ON TRUE


-- больницы
LEFT JOIN LATERAL (
    SELECT COUNT(*) AS cnt
    FROM edu.objects o
    WHERE o.type = 'больница'
      AND ST_DWithin(d.geom::geography, o.geom::geography, 300)
) b ON TRUE


-- ПВЗ
LEFT JOIN LATERAL (
    SELECT COUNT(*) AS cnt
    FROM edu.objects o
    WHERE o.type = 'пвз'
      AND ST_DWithin(d.geom::geography, o.geom::geography, 300)
) pvz ON true
WHERE d.type = 'жилой дом'
order by d.name;
Результат получится такой:
Таким образом, мы получим информацию по каждому дому и по количеству объектов, которые расположены в окрестностях 300 м.
В данной статье мы рассмотрели лишь небольшую часть задач в работе с координатами, которые можно решить с помощью PostGIS. Может возникнуть вопрос – если нужна работа с координатами всегда надо выбирать СУБД PostgreSQL? На самом деле нет, существуют много различных аналогов. Ниже представлена сравнительная таблица, в которой отражены решения, стоимость этих решений, возможности и производительность в сравнении.
Решение
Стоимость
Возможности

Скорость/Производительность

PostGIS (PostgreSQL)

Бесплатно / Open Source — расширение PostGIS + сам PostgreSQL лицензированы по свободным лицензиям.
Очень богатый набор функций: 2D/3D/4D геометрия, преобразования координат (проекции), геодезическая география, топология, агрегаты, K-NN поиск, буферы, пересечения и др. Практически полный стандарт OGC / SQL/MM Spatial
Обычно высокая производительность на сложных и крупных данных, особенно с правильно настроенными пространственными индексами. В тестах PostGIS часто быстрее, чем Oracle Spatial в ряде операций, особенно при «горячем» кэше (warm cache).

Oracle Spatial / Graph

Ранее было платным дополнением, но начиная с Oracle DB версии 11.2+: Spatial & Graph включены во все версии Oracle без дополнительной платы. Отметим, что сама СУБД коммерческая.
Очень мощное решение, с поддержкой растровых данных, топологии, сетей и др. Много утилит, зрелый набор возможностей. Однако часть расширенных возможностей может быть сложнее в настройке/использовании.
В некоторых тестах Oracle Spatial уступает PostGIS в задачах, связанных с манипуляцией геометрией или конвертациями (например, преобразование в WKT большого числа точек). При больших объёмах данных и сложных операциях может быть медленнее, если не оптимизировано.

Microsoft SQL Server (geometry / geography types)

Платформа сама лицензируется, но пространственные возможности включены в штатные версии.
Поддерживает пространственные типы geometry (декартова плоскость) и geography (геодезическая поверхность), базовые пространственные функции: пересечения, расстояния, буфер и др. Однако меньше продвинутых функций по сравнению с PostGIS / Oracle для топологии, растров и др.
Хорошая скорость для стандартных операций. В задачах с большим объёмом, сложными геометриями, агрегациями или топологией может уступать PostGIS / Oracle. Если запросы правильно индексированы, может быть вполне конкурентной.
MySQL / MariaDB Spatial Extensions
Бесплатно, встроено в продукт.
Поддержка базовых типов геометрии, пространственных индексов (ограничения на движок, например, MyISAM / InnoDB), базовых пространственных запросов (ST_Intersects, ST_Distance и др.). Однако ограничены функции: часто работают через минимальные ограничивающие прямоугольники (MBR), мало поддержки сложных геометрий / топологии / многомерности.
Для простых запросов — довольно неплохо. Но при сложных геометриях, большом объёме, или когда нужны точные пересечения / буферы / трансформации — заметно медленнее, чем PostGIS или Oracle. Иногда индекс либо геометрия не используются, что сильно влияет на результат.

SpatiaLite (SQLite + Spatial Extensions)

Бесплатно, Open Source.
Поддерживает многие функции пространственных данных: геометрические типы, индексы (R-деревья), базовые операции, соответствие OGC/SFS в значительной степени. Однако не клиент-сервер, нет параллелизма, нет встроенной поддержки всего спектра возможностей у крупных СУБД.
Для малых-средних наборов данных и локального использования — очень быстрый и удобный. При масштабировании (миллионы геометрий, сложные операции) производительность падает, ограничение ресурсов и параллелизма.

Вывод

Как видно из сравнительной таблицы, решений предостаточно на любой вкус, цвет и карман. Главное, что с каким бы СУБД вы не работали, важно эффективно и правильно использовать данные географических координат, что поможет закрыть множество задач на вашем проекте и вашей компании, а так же даст бизнесу конкурентное преимущество - позволит принимать решения, учитывающие пространственный фактор, оптимизировать процессы и находить новые точки роста за счет анализа реальной географии клиентов, ресурсов и активов.