Pull to refresh

Жилье в 500м от сетевых продуктовых магазинов в Москве. Или как публикация на Лента.ру избегает Хабр и дезинформирует

Level of difficultyMedium
Reading time8 min
Views6.9K

Приятно когда живешь там где у тебя вход в метро в 15 минутах ходьбы и с комфортом жизни неплохо. Но кушать хочется всегда, а на рабочей неделе уж совсем нет времени и желания ехать в супермаркет чтобы сварить гречневую кашу и взять что-нибудь на завтрак. Магазины шаговой доступности есть везде, но мне хотелось бы чтобы это было что-нибудь более привычное, типа Пятёрочки, Дикси, Магнита, Перекрёстка, Магнолии, Атак или Ленты.

Жизнь по этому фактору в Серебряном бору( это один из победителей в категории удаленности от неблагоприятных факторов) отпадает сразу - там и до метро не близко! Найдем более удобные места для ежедневной жизни и похода в продуктовый магазин.

Это как в дилеме что первее: курица или яйцо, так же сетевой продуктовый/жилой район. Компании владельцы торговых сетей тратят деньги на геоанализ где им лучше расположить продуктовый магазин. Так что в каком-то виде мы переиспользуем их аналитику и найдем где живут мои соседи. Если же нужна другая группа соседей, то нужно поискать окрестности Азбуки Вкуса и бывшего Елисеевского.

Про то как выбрать правильное место для открытия магазина и что нужно для этого учесть написал в этой публикации.

Для анализа буду как обычно использовать геоданные OSM, и вот для выбора сетевых магазинов без "черной магии" мне не удастся обойтись, потому что в исходных данных полно опечаток, разных написаний и разношёрстности в обозначениях. После получаса экспериментов мне удалось подобрать нужное заклинание:

create temporary table supermarket_entrance as 
  select id, centre, tags 
    from geometry_global_view 
    where (tags@>'shop=>supermarket' or tags@>'shop=>convenience') and 
    		(tags->'brand' in ('Пятёрочка','ВкусВилл','Дикси','Магнит',
                               'Перекрёсток','Магнолия','Перекресток',
                               'Лента','Eurospar','Ашан','Супер Лента',
                               'Атак','СуперЛента','О’КЕЙ','Spar',
                               'Перекресток Экспресс','SPAR',
                               'METRO Cash & Carry','EuroSPAR','АТАК',
                               'Ашан Сити','Мини Лента','EUROSPAR',
                               'МиниЛента','METRO','Auchan','супер Лента') 
    		or tags@>'brand:wikipedia=>ru:Auchan');

Подробнее про подготовку данных к анализу можете почитать в прошлой статье. Чтобы исключить из геоанализа мавзолей на Красной площади и Кремлевские башни добавлю фильтр:not(b.tags?'tomb') and not(b.tags?'man_made') и найду жилые здания у интересующих магазинов:

explain create table living_building_near as
select m.id supermarket_entrance_id,st_x(m.centre) supermarket_entrance_x,st_y(m.centre) supermarket_entrance_y,
       array_agg((b.id,b.type,st_x(b.centre),st_y(b.centre))::building_info) buildings
  from supermarket_entrance m inner join geometry_global_view b on
    b.tags?'building' and
    not(b.tags?'amenity') and
    not(b.tags?'shop') and
    not(b.tags?'tomb') and
    not(b.tags?'man_made') and
    b.tags->'building' not in --в перечисленных ниже зданиях не живут на постоянной основе
        ('service','garages','industrial','retail','office','roof','commercial','garage','kiosk','warehouse','church',
        'parking','public','shed','hangar','train_station','guardhouse','transportation','terrace','greenhouse','bridge',
        'government','chapel','gazebo','civic','ruins','supermarket','sports_centre','semidetached_house','toilets',
        'sports_hall','clinic','farm_auxiliary','stable','grandstand','bunker','gatehouse','store','temple','ventilation_kiosk',
        'carport','cowshed','barracks','shop','cabin','barn','cathedral','wall','townhouse','manufacture','shelter',
        'fire_station','stadium','stands','sport_hall','theatre','storage_tank','checkpoint','houseboat','abandoned','dovecote',
        'mosque','museum','military','container','observatory','lift','tent','factory','sport','mall','riding_hall','depot',
        'prison','gate','triumphal_arch','water_works','public_building','pavilion','bank','institute','works','collapsed',
        'car_repair','crossing_box','fuel','tree_house','presbytery','yesq','farm','outbuilding','police','porch','sauna',
        'monastery','cinema','tower','boathouse','library','transformer_tower','heat_exchange_station','ice_rink','entrance','construction','transformer'
        ) and 
    ST_DWithin(b.geom::geography,m.centre::geography,500) 
  group by 1,2,3; -- create table living_building_near as select m.id supermarket_entrance_id,st_x(m.centre) supermarket_entrance_x,st_y(m.centre) supermarket_entrance_y,array_agg((b.id,b.type,st_x(b.centre),st_y(b.centre))::building_info) buildings from supermarket_entrance m inner join geometry_global_view b on b.tags?'_lb' and ST_DWithin(b.geom::geography,m.centre::geography,500) group by 1,2,3;

Посчитаю пешеходные расстояния от магазина до домов в радиусе 500м. Чтобы учесть случаи когда дом расположен через реку или автостраду без подземного перехода, не включать его в расчет расстояния.

GraphHopper 8 как движок расчета расстояний и Java код для него.
create table supermarket_distance(
                entrance_id bigint,
                building_id bigint,
                building_type table_reference,
                distance smallint,
                primary key(entrance_id,building_id,building_type)
);

Основная проблема с расчетом расстояний - это время вычислений, в случае если сервис взаимодействует с программой как REST через TCP добавляется round trip пакетов к времени обработки каждого запроса, поэтому буду использовать Graphhopper как библиотеку внутри процесса без вызовов по HTTP:

package com.github.isuhorukov.routing;

import com.graphhopper.GHRequest;
import com.graphhopper.GHResponse;
import com.graphhopper.GraphHopper;
import com.graphhopper.ResponsePath;
import com.graphhopper.config.CHProfile;
import com.graphhopper.config.Profile;
import org.postgresql.jdbc.PgArray;
import org.postgresql.util.PGobject;

import java.sql.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class Routing {

    public static final int MAX_DISTANCE = 500;

    public static void main(String[] args) throws Exception {
        GraphHopper hopper = getGraphHopper();
        try (Connection connection = DriverManager.getConnection(
                System.getenv("jdbc_url"), System.getenv("user"), System.getenv("password"))) {
            connection.setAutoCommit(false);
            try {
                var distances = calculateDistances(connection, hopper);
                saveDistance(connection, distances);
            } catch (SQLException e) {
                connection.rollback();
                throw e;
            }
        }
    }

    private static GraphHopper getGraphHopper() {
        var hopper = new GraphHopper();
        hopper.setOSMFile(System.getenv("osm_file"));
        hopper.setGraphHopperLocation("route");
        hopper.getCHPreparationHandler().setCHProfiles(new CHProfile("foot"));
        hopper.setProfiles(new Profile("foot").setVehicle("foot"));
        hopper.importOrLoad();
        return hopper;
    }

    private static List<Distance> calculateDistances(Connection connection, GraphHopper hopper) throws SQLException {
        List<Distance> distances = new ArrayList<>();
        try (Statement supermarketQuery = connection.createStatement()){
            int step=0;
            try (ResultSet livingBuilding = supermarketQuery.executeQuery("select * from living_building_near")){
                while (livingBuilding.next()){
                    long supermarketEntranceId = livingBuilding.getLong("supermarket_entrance_id");
                    double supermarketEntranceX = livingBuilding.getDouble("supermarket_entrance_x");
                    double supermarketEntranceY = livingBuilding.getDouble("supermarket_entrance_y");
                    PgArray buildingsArray = (PgArray) livingBuilding.getArray("buildings");
                    List<Building> buildings = mapBuildings(buildingsArray);
                    System.out.println(step++);
                    distances.addAll(buildings.stream().map(building -> {
                        short distance = calculateDistances(hopper, building, supermarketEntranceY, supermarketEntranceX);
                        return new Distance(supermarketEntranceId,building.buildingId, building.buildingType, distance);
                    }).collect(Collectors.toList()));
                }
            }
        }
        return distances;
    }

    private static void saveDistance(Connection connection, List<Distance> distances) throws SQLException {
        try (PreparedStatement insert = connection.prepareStatement(
                "insert into supermarket_distance(entrance_id,building_id,building_type,distance) " +
                        "values (?,?,?::table_reference,?)")){
            distances.forEach(distance -> {
                if(distance.distance> MAX_DISTANCE) return;
                try {
                    insert.setLong(1, distance.supermarketEntranceId);
                    insert.setLong(2, distance.buildingId);
                    insert.setString(3, distance.buildingType);
                    insert.setShort(4, distance.distance);
                    insert.addBatch();
                } catch (SQLException e) {
                    throw new RuntimeException(e);
                }
            });
            insert.executeBatch();
            connection.commit();
        }
    }

    private static List<Building> mapBuildings(PgArray buildingsArray) throws SQLException {
        return getPgObjects(buildingsArray).stream().map(pGobject -> {
            String value = pGobject.getValue();
            if(value==null || value.length()<=2){
                throw new IllegalArgumentException();
            }
            String[] parts = value.substring(1, value.length() - 1).split(",");
            long buildingId = Long.parseLong(parts[0]);
            String buildingType = parts[1];
            double buildingX = Double.parseDouble(parts[2]);
            double buildingY = Double.parseDouble(parts[3]);
            return new Building(buildingId, buildingType, buildingX, buildingY);
        }).collect(Collectors.toList());
    }

    private static List<PGobject> getPgObjects(PgArray buildingsArray) throws SQLException {
        Object[] array = (Object[]) buildingsArray.getArray();
        buildingsArray.free();
        return Arrays.stream(array).map(object -> (PGobject) object).collect(Collectors.toList());
    }

    private static short calculateDistances(GraphHopper hopper,
                                      Building building, double supermarketEntranceY, double supermarketEntranceX) {
        GHRequest request = new GHRequest(supermarketEntranceY, supermarketEntranceX, building.buildingY, building.buildingX);
        request.setProfile("foot");
        GHResponse route = hopper.route(request);
        if(route.hasErrors()){
            return Short.MAX_VALUE;
        }
        ResponsePath bestRoute = route.getBest();
        return (short) Math.round(bestRoute.getDistance());
    }

    private static class Building{
        long buildingId;
        String buildingType;
        double buildingX;
        double buildingY;
        public Building(long buildingId, String buildingType, double buildingX, double buildingY) {
            this.buildingId = buildingId;
            this.buildingType = buildingType;
            this.buildingX = buildingX;
            this.buildingY = buildingY;
        }
    }

    private static class Distance{
        long supermarketEntranceId;
        long buildingId;
        String buildingType;
        short distance;
        public Distance(long supermarketEntranceId, long buildingId, String buildingType, short distance) {
            this.supermarketEntranceId = supermarketEntranceId;
            this.buildingId = buildingId;
            this.buildingType = buildingType;
            this.distance = distance;
        }
    }
}
Карта домов в 500м у сетевых продуктовых магазинов шаговой доступности в Москве
Карта домов в 500м у сетевых продуктовых магазинов шаговой доступности в Москве

Выгружаю результат анализа в GeoJSON файл и отправляю его на GitHub:

\copy (select json_build_object('type', 'FeatureCollection','features', json_agg(json_build_object('type', 'Feature','geometry', st_AsGeoJSON(centre)::json))) from (select distinct centre from supermarket_distance s inner join geometry_global_view b on b.id=s.building_id and b.type=s.building_type) buildings ) to '~/moscow_supermarket_footpath.json';

Интересные находки

Подъезды в жилых домах в Москве часто не размечены на OpenStreetMap.

Зеленые дома - подъезд указан, "цвет-румянец" (#DE5D83) подъезды не указаны. И это еще неплохой процент разметки подъездов для столицы
Зеленые дома - подъезд указан, "цвет-румянец" (#DE5D83) подъезды не указаны. И это еще неплохой процент разметки подъездов для столицы

Можно было бы увеличить точность вычисления пешеходной дистанции к магазину, но сначала нужно разметить подъезды в исходных данных. И уже внес свою посильную помощь в геоданные Москвы по подъездам в жилых домах. Пообщался c местным сообществом мэпперов на эту тему и умные люди подсказали сайт, где желающие могут найти дома в которых не указаны подъезды и разметить их в OpenStreetMap.

В Москве для зданий иногда не хватает информации о его типе. Как заметили в коментариях к карте само здание магазина может быть учтено как жилое. В этом случае правильным решением будет изменить тип здания на building=retail и пересчитать карту. Я могу найти все такие здания и сформировать список, чтобы люди живущие в этом районе перепроверили и если здание действительно только с коммерческими объектами/магазином, обновили данные в OSM.

Исходные данные OpenStreetMap доступны каждому и по лицензии являются свободными. Не надо быть data science специалистом чтобы посчитать расстояния от магазинов до домов.

Результат геоаналитики

Готовую карту жилых домов Москвы в 500м от сетевых продуктовых магазинов вы можете посмотреть по ссылке на GitHub Gist. Где точки на карте обозначают центры жилых зданий в шаговой доступности от магазинов.

Используйте на здоровье, если вам при выборе места жительства хочется учесть и тоже нужно зайти вечером в ближайший сетевой продуктовый магазин у дома.

Случайная популярность или как работает пресса

А теперь пару слов как работают журналисты или кто-то заказал очернение этой публикации про работу с открытыми гео данными или это резюме от ChatGPT.

Помните мем: Ученые научились лечить рак?

В этом случае все произошло так же. Лента ру опубликовала статью про то что удобное не удобно. Интересно случайно или в этом есть цель обесценить мой труд? Оставлю скриншот:

И самое интересное ссылка ведет сразу на Github, избегая описания на Хабре... Сравните оригинал и новость!)

Tags:
Hubs:
Total votes 13: ↑13 and ↓0+13
Comments32

Articles