Доброго времени суток дорогие читатели хабра, 12 августа 2020 года были опубликованы этапы переезда по программе реновации (ознакомиться можно здесь) и мне стало интересно, а как это будет выглядеть, если эти этапы визуализировать. Тут нужно уточнить, что я никак ни связан с правительством Москвы, но являюсь счастливым обладателем квартиры в доме под реновацию, поэтому мне было интересно посмотреть, может даже с некоторой точностью предположить, куда возможно будет двигаться волна реновации в моём случае (а может быть и в вашем, если вас дорогой читатель это заинтересует). Конечно точного прогноза не получится, но хотя-бы можно будет увидеть картину под новым углом.


UPD 28 августа 2020
Получилась полная карта реновации с отмеченными на ней волнами реновации и стартовыми площадками.


Введение


Вкратце о программе реновации

Программа реновации была запущена Правительством Москвы в 2017 году. Благодаря ей 350 тысяч московских семей, то есть более миллиона человек, переедут в новые квартиры с отделкой комфорткласса.


Какие дома войдут в программу, решали сами жители. По итогам голосования в программу было включено 5174 дома.


Участники получат равнозначное жилье в своем районе… (далее можно прочитать здесь)


На основании приказа правительства Москвы от 12 августа 2020 г. № 45/182/ПР-335/20 (прочитать можно здесь) вся программа переселения рассчитана до 2032 года и должна будет пройти в три этапа (три волны):


  • первый этап 2020 — 2024гг., в него вошло 930 домов, страницы 3-29 в приказе
  • второй этап 2025 — 2028гг., в него вошло 1636 домов, страницы 30-76 в приказе
  • третий этап 2029 — 2032гг., в него вошло 1809 домов, страницы 77-128 в приказе
  • без определённого этапа (этапы должны будут определиться до конца 1 квартала 2021г.) — 688 домов, страницы 129-148 в приказе

Реализация


Исходный код залит на github и скачать его можно здесь.


  • В первой версии кода я использовал геокодер яндекса для определения точных координат домов, входящих в программу реновации. Добавить стартовые площадки через геокодер не удалось, не все координаты определились правильно.

Первая версия кода wave1.ipynb (obsolete)

Парсинг данных


Данные я взял из этого приказа, т.к. приказ — это pdf файл с таблицами, то я использовал библиотеку tabula для парсинга pdf файлов.


import pandas as pd
import numpy as np
import requests
from tabula import read_pdf
import json
import os

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


test = read_pdf('prikaz_grafikpereseleniya.pdf', pages='3', pandas_options={'header':None})

test.head()


0 1 2 3 4 5
0 No п/п АО Район NaN Адрес дома unom
1 1 ЦАО Басманный Бакунинская ул., д.49 c.4 NaN 1316
2 2 ЦАО Басманный Бакунинская ул., д.77 c.3 NaN 1327
3 3 ЦАО Басманный Балакиревский пер., д.2/26 NaN 19328
4 4 ЦАО Басманный Госпитальный Вал ул., д.3 NaN 31354


Как видно из того, что получилось спарсить, чтобы очистить данные необходимо удалить лишние колонки и строчки, что и делает функция parse_pdf_table.


def parse_pdf_table(pages, pdf_file='prikaz_grafikpereseleniya.pdf'):
    df = read_pdf(pdf_file, pages=pages, pandas_options={'header':None})

    # удаляем не нужные строки
    df = df[~(df.iloc[:,0] == 'No п/п')]

    # оставляем только нужные колонки
    df = df.iloc[:,1:4]
    df.columns = ['AO', 'district', 'address']

    return df

Каждая волна находится в своём диапазоне страниц, парсим их и проверяем по документу, т.е. количество строк должно совпадать с тем, что есть в pdf файле. (Также сразу добавляем к данным номер волны, т.к. это пригодится в будущем)


wave_1 = parse_pdf_table('3-29') # 2020 - 2024
wave_1['wave'] = 1

wave_1.shape

(930, 4)

wave_2 = parse_pdf_table('30-76') # 2025 - 2028
wave_2['wave'] = 2

wave_2.shape

(1636, 4)

wave_3 = parse_pdf_table('77-128') # 2029 - 2032
wave_3['wave'] = 3

wave_3.shape

(1809, 4)

unknown = parse_pdf_table('129-148')
unknown['wave'] = 0

unknown.shape

(688, 4)

Обработка данных


Обрабатывать данные будем на пандасе (pandas), для этого соберём все волны в один датафрейм df.


df = pd.concat([wave_1, wave_2, wave_3, unknown], ignore_index=True)

Выделим своим цветом метки каждой волны.


df['marker-color'] = df['wave'].map({1:'#0ACF00',  # зеленый
                                     2:'#1142AA',  # синий
                                     3:'#FFFD00',  # жёлтый
                                     0:'#FD0006'}) # красный

Также подпишем каждую метку в зависимости от волны.


df['iconContent'] = df['wave'].map({1:'1',
                                    2:'2',
                                    3:'3',
                                    0:''})

В описание метки добавим адрес.


df['description'] = df['address']

Если не уточнить город — Москва, то по данным, полученным из геокодера получится, что реновация началась по всей стране, да что там, во всём мире. (Даёшь реновацию во всём мире! :)



def add_city(x):
    if x['AO'] == 'ЗелАО':
        return 'Зеленоград, ' + x['address']

    return 'Москва, ' + x['address']

df['address'] = df[['AO', 'address']].apply(add_city, axis=1)

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


def geocoder(addr, key='введи свой ключ'):   
    url = 'https://geocode-maps.yandex.ru/1.x'
    params = {'format':'json', 'apikey': key, 'geocode': addr}
    response = requests.get(url, params=params)

    try:
        coordinates = response.json()["response"]["GeoObjectCollection"]["featureMember"][0]["GeoObject"]["Point"]["pos"]
        lon, lat = coordinates.split(' ')
    except:
        lon, lat = 0, 0

    return lon, lat

%%time
df['longitude'], df['latitude'] = zip(*df['address'].apply(geocoder))

CPU times: user 2min 11s, sys: 4.31 s, total: 2min 15s
Wall time: 15min 14s

Все координаты определились удачно (именно удачно, т.к. нет гарантий, что геокодер спарсил адрес так как нам нужно), другими словами он хотя-бы что-то вернул.


len(df[df['longitude'] == 0])

0

Сохраним полученные данные.


df.to_csv('waves.csv')

#df = pd.read_csv('waves.csv')

Формирование карты волн реновации


Для отображения полученных данных на карте я использовал формат GeoJSON.


def df_to_geojson(df, properties, lat='latitude', lon='longitude'):
    geojson = {'type':'FeatureCollection', 'features':[]}
    for _, row in df.iterrows():
        feature = {'type':'Feature',
                   'properties':{},
                   'geometry':{'type':'Point',
                               'coordinates':[]}}
        feature['geometry']['coordinates'] = [row[lon],row[lat]]
        for prop in properties:
            feature['properties'][prop] = row[prop]
        geojson['features'].append(feature)
    return geojson

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


properties = ['marker-color', 'iconContent', 'description']

if not os.path.exists('data'):
    os.makedirs('data')

for ao, data in df.groupby('AO'):
    geojson = df_to_geojson(data, properties)

    with open('data/' + ao + '.geojson', 'w') as f:
        json.dump(geojson, f, indent=2) 

Полученные данные в формате .geojson я сохранил в папку data. В файле ВСЕ_ОКРУГА.geojson записаны данные по всем округам вместе.


geojson = df_to_geojson(df, properties)

with open('data/ВСЕ_ОКРУГА.geojson', 'w') as f:
    json.dump(geojson, f, indent=2) 


ссылка на полную карту (может работать медленно) здесь.



В целом получилось не плохо, все метки внутри границ Москвы, однако, есть и несколько ошибок, как например недалеко от Сергиева Посада — Пролетарий СНТ территория (п.Вороновское), д.1 или в окрестностях Орехово-Зуево — Гаражный пер. (пос.ДСК Мичуринец, п.Внуковское), д.8/КБ/Н. (Честно говоря я бы и сам не сразу понял, где это находится)


Что хотелось сделать, но не получилось :(


Официальный список стартовых площадок находится здесь.


Также на карту волн реновации я хотел добавить стартовые площадки, однако это не получилось сделать. Проблема даже не в том, что нормально спарсить список не удалось, это можно было бы решить, проблема в том, что геокодер не может точно определить координаты по владению, например, Шмитовский проезд, вл. 39, Мукомольный проезд, вл. 6, или где находится этот адрес — район Южное Медведково, мкр. 1, 2, 3, корп. 38.


Таким образом единственный источник данных это официальная карта реновации (находится здесь), а как получить из неё координаты я не знаю, если кто знает, как получить координаты стартовых площадок, напишите пожалуйста в комментах.


Однако не всё так плохо и выход всё же есть — можно добавить эти метки вручную!



Видео-инструкция о том, как это сделать есть в исходном коде проекта, а также её можно посмотреть/скачать здесь.


  • Вторая версия карты получилась полной и точной, на неё удалось добавить как дома по реновации, так и стартовые площадки. Вместо геокодера я использовал данные, которые удалось спарсить пользователю PbIXTOP, за что ему большое спасибо.

Вторая версия кода wave2.ipynb (Волны московской реновации 2.0)

Волны московской реновации 2.0


import pandas as pd
import numpy as np
import json
from tabula import read_pdf
from tqdm.notebook import tqdm
import os

Адреса и локации домов по реновации


with open('renovation_address.txt') as f:
    bounded_addresses = json.load(f)

def parse_pdf_table(pages, pdf_file='prikaz_grafikpereseleniya.pdf'):
    df = read_pdf(pdf_file, pages=pages, pandas_options={'header':None})

    # удаляем не нужные строки
    df = df[~(df.iloc[:,0] == 'No п/п')]

    df['unom'] = df.iloc[:,-1].combine_first(df.iloc[:,-2])

    # оставляем только нужные колонки
    df = df.iloc[:,[1, 2, 3, -1]]
    df.columns = ['AO', 'district', 'description', 'unom']

    return df

wave_1 = parse_pdf_table('3-29') # 2020 - 2024
wave_1['wave'] = 1

wave_2 = parse_pdf_table('30-76') # 2025 - 2028
wave_2['wave'] = 2

wave_3 = parse_pdf_table('77-128') # 2029 - 2032
wave_3['wave'] = 3

unknown = parse_pdf_table('129-148')
unknown['wave'] = 0

df = pd.concat([wave_1, wave_2, wave_3, unknown], ignore_index=True)

df['marker-color'] = df['wave'].map({1:'#0ACF00',  # зеленый
                                     2:'#1142AA',  # синий
                                     3:'#FFFD00',  # жёлтый
                                     0:'#FD0006'}) # красный

df['iconContent'] = df['wave'].map({1:'1',
                                    2:'2',
                                    3:'3',
                                    0:''})

df['longitude'] = 0
df['latitude'] = 0

for i in tqdm(bounded_addresses):
    unom = i['unom']
    coordinates = i['center']['coordinates']

    df.loc[df['unom']==unom, 'longitude'] = coordinates[1]
    df.loc[df['unom']==unom, 'latitude'] = coordinates[0]

HBox(children=(FloatProgress(value=0.0, max=5152.0), HTML(value='')))

# Объеденим ТАО и НАО в ТиНАО, т.к. в стартовых площадках есть только ТиНАО
df.loc[(df['AO'] == 'ТАО') | (df['AO'] == 'НАО'), 'AO'] = 'ТиНАО'

df[df['longitude'] == 0]


AO district description unom wave marker-color iconContent longitude latitude
917 ТиНАО поселение Михайлово-Ярцевское Армейский пос. (п.Михайлово-Ярцевское), д.11 15000016 1 #0ACF00 1 0.0 0.0
918 ТиНАО поселение Михайлово-Ярцевское Армейский пос. (п.Михайлово-Ярцевское), д.13 15000015 1 #0ACF00 1 0.0 0.0
919 ТиНАО поселение Михайлово-Ярцевское Армейский пос. (п.Михайлово-Ярцевское), д.3 15000013 1 #0ACF00 1 0.0 0.0
925 ТиНАО поселение Михайлово-Ярцевское Армейский пос. (п.Михайлово-Ярцевское), д.4 15000012 1 #0ACF00 1 0.0 0.0
926 ТиНАО поселение Михайлово-Ярцевское Армейский пос. (п.Михайлово-Ярцевское), д.6 15000014 1 #0ACF00 1 0.0 0.0
4883 ТиНАО поселение Внуковское Гаражный пер. (пос.ДСК Мичуринец, п.Внуковское)... 4405823 0 #FD0006 0.0 0.0
4945 ТиНАО поселение Мосрентген Теплый Стан ул. (п.Мосрентген, в/г), д.51 20000002 0 #FD0006 0.0 0.0
4946 ТиНАО поселение Мосрентген Теплый Стан ул. (п.Мосрентген, в/г), д.52 20000003 0 #FD0006 0.0 0.0
4947 ТиНАО поселение Мосрентген Теплый Стан ул. (п.Мосрентген, в/г), д.53 20000001 0 #FD0006 0.0 0.0
4948 ТиНАО поселение Мосрентген Теплый Стан ул. (п.Мосрентген, в/г), д.85 20000000 0 #FD0006 0.0 0.0
4995 ТиНАО поселение Вороновское Пролетарий СНТ территория (п.Вороновское), д.1 20000004 0 #FD0006 0.0 0.0


Добавляем вручную дома, которые не удалось спарсить


df.loc[917, ['longitude', 'latitude']] = 37.204805, 55.385382 
df.loc[918, ['longitude', 'latitude']] = 37.205255, 55.385367 
df.loc[919, ['longitude', 'latitude']] = 37.201518, 55.385265 
df.loc[925, ['longitude', 'latitude']] = 37.201545, 55.384927 
df.loc[926, ['longitude', 'latitude']] = 37.204151, 55.384576
df.loc[4883, ['longitude', 'latitude']] = 37.321218, 55.661308 
df.loc[4945, ['longitude', 'latitude']] = 37.476896, 55.604153 
df.loc[4946, ['longitude', 'latitude']] = 37.477406, 55.603895 
df.loc[4947, ['longitude', 'latitude']] = 37.476546, 55.602729 
df.loc[4948, ['longitude', 'latitude']] = 37.477568, 55.604659
df.loc[4995, ['longitude', 'latitude']] = 37.176806, 55.341541

Стартовые площадки


with open('start_area.txt') as f:
    end = json.load(f)

data = {
    'AO':[],
    'district':[],
    'longitude':[],
    'latitude':[],
    'description':[]
}

for i in end['response']:

    data['AO'].append(i['OKRUG'])

    data['district'] = i['AREA']

    coordinates = i['geoData']['coordinates']

    data['longitude'].append(coordinates[1])
    data['latitude'].append(coordinates[0])

    description = i['Address']

    if 'StartOfRelocation' in i:
        if i['StartOfRelocation'] is not None:
            description += '\n' + i['StartOfRelocation']

    data['description'].append(description)

df_start_area = pd.DataFrame(data)
df_start_area['marker-color'] = '#7D3E00' # коричневый цвет
df_start_area['iconContent'] = '0'
df_start_area['unom'] = None
df_start_area['wave'] = -1

Объеденяем метки домов по реновации и стартовых площадок


df = pd.concat([df, df_start_area], ignore_index=True)

Формирование карты реновации


def df_to_geojson(df, properties, lat='latitude', lon='longitude'):
    geojson = {'type':'FeatureCollection', 'features':[]}
    for _, row in df.iterrows():
        feature = {'type':'Feature',
                   'properties':{},
                   'geometry':{'type':'Point',
                               'coordinates':[]}}
        feature['geometry']['coordinates'] = [row[lon],row[lat]]
        for prop in properties:
            feature['properties'][prop] = row[prop]
        geojson['features'].append(feature)
    return geojson

properties = ['marker-color', 'iconContent', 'description']

Разделяем данные по округам.


if not os.path.exists('data'):
    os.makedirs('data')

for ao, data in df.groupby('AO'):
    geojson = df_to_geojson(data, properties)

    with open('data/' + ao + '.geojson', 'w') as f:
        json.dump(geojson, f, indent=2) 

Полная карта (может работать медленно)


geojson = df_to_geojson(df, properties)

with open('data/ВСЕ_ОКРУГА.geojson', 'w') as f:
    json.dump(geojson, f, indent=2) 

Выводы


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


UPD 28 августа 2020


Полная карта реновации с отмеченными на ней волнами реновации и стартовыми площадками.


Спасибо пользователю PbIXTOP за данные, спарсенные с официальной карты.


ВСЕ ОКРУГА (Может работать медленно)


ВАО
ЗАО
ЗелАО
САО
СВАО
СЗАО
ТиНАО
ЦАО
ЮАО
ЮВАО
ЮЗАО


UPD 1 сентября 2020


Добавил актуальный код для формирования карты, скрыл реализацию, т.к. большинство читателей статьи интересуется только картой.


Спасибо за внимание.