Приятно когда живешь там где у тебя вход в метро в 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;
}
}
}
Выгружаю результат анализа в 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.
Можно было бы увеличить точность вычисления пешеходной дистанции к магазину, но сначала нужно разметить подъезды в исходных данных. И уже внес свою посильную помощь в геоданные Москвы по подъездам в жилых домах. Пообщался c местным сообществом мэпперов на эту тему и умные люди подсказали сайт, где желающие могут найти дома в которых не указаны подъезды и разметить их в OpenStreetMap.
В Москве для зданий иногда не хватает информации о его типе. Как заметили в коментариях к карте само здание магазина может быть учтено как жилое. В этом случае правильным решением будет изменить тип здания на building=retail и пересчитать карту. Я могу найти все такие здания и сформировать список, чтобы люди живущие в этом районе перепроверили и если здание действительно только с коммерческими объектами/магазином, обновили данные в OSM.
Исходные данные OpenStreetMap доступны каждому и по лицензии являются свободными. Не надо быть data science специалистом чтобы посчитать расстояния от магазинов до домов.
Результат геоаналитики
Готовую карту жилых домов Москвы в 500м от сетевых продуктовых магазинов вы можете посмотреть по ссылке на GitHub Gist. Где точки на карте обозначают центры жилых зданий в шаговой доступности от магазинов.
Используйте на здоровье, если вам при выборе места жительства хочется учесть и тоже нужно зайти вечером в ближайший сетевой продуктовый магазин у дома.
Случайная популярность или как работает пресса
А теперь пару слов как работают журналисты или кто-то заказал очернение этой публикации про работу с открытыми гео данными или это резюме от ChatGPT.
Помните мем: Ученые научились лечить рак?
В этом случае все произошло так же. Лента ру опубликовала статью про то что удобное не удобно. Интересно случайно или в этом есть цель обесценить мой труд? Оставлю скриншот:
И самое интересное ссылка ведет сразу на Github, избегая описания на Хабре... Сравните оригинал и новость!)