Files
notifications-admin/app/broadcast_areas/create-broadcast-areas-db.py

453 lines
15 KiB
Python
Raw Normal View History

#!/usr/bin/env python
import csv
import pickle
import sys
from math import isclose
from pathlib import Path
import geojson
from notifications_utils.formatters import formatted_list
from notifications_utils.polygons import Polygons
from populations import (
2020-09-09 13:29:45 +01:00
BRYHER,
CITY_OF_LONDON,
MEDIAN_AGE_RANGE_UK,
MEDIAN_AGE_UK,
SMARTPHONE_OWNERSHIP_BY_AGE_RANGE,
2020-09-16 11:33:57 +01:00
estimate_number_of_smartphones_for_population,
2020-09-09 13:29:45 +01:00
)
from repo import BroadcastAreasRepository, rtree_index_path
2021-04-13 16:31:06 +03:00
from rtreelib import Rect, RTree
from shapely import wkt
from shapely.geometry import MultiPolygon, Polygon
source_files_path = Path(__file__).resolve().parent / 'source_files'
point_counts = []
invalid_polygons = []
rtree_index = RTree()
# The hard limit in the CBCs is 6,000 points per polygon. But we also
# care about optimising how quickjly we can process and display polygons
# so we aim for something lower, i.e. enough to give us a good amount of
# precision relative to the accuracy of a cell broadcast
MAX_NUMBER_OF_POINTS_PER_POLYGON = 250
def simplify_geometry(feature):
if feature["type"] == "Polygon":
return [feature["coordinates"][0]]
elif feature["type"] == "MultiPolygon":
return [polygon for polygon, *_holes in feature["coordinates"]]
else:
raise Exception("Unknown type: {}".format(feature["type"]))
def clean_up_invalid_polygons(polygons, indent=" "):
"""
This function expects a list of lists of coordinates defined in degrees
"""
for index, polygon in enumerate(polygons):
shapely_polygon = Polygon(polygon)
# Some of our data has points which are incredibly close
# together. In some cases they are close enough to be duplicates
# at a given precision, which makes an invalid topology. In
# other cases they are close enough that, when converting from
# one coordinate system to another, they shift about enough to
# create self-intersection. The fix in both cases is to reduce
# the precision of the coordinates and then apply simplification
# with a tolerance of 0.
simplified_polygon = wkt.loads(wkt.dumps(
shapely_polygon,
rounding_precision=Polygons.output_precision_in_decimal_places - 1
)).simplify(0)
if simplified_polygon.is_valid:
print( # noqa: T201
f"{indent}Polygon {index + 1}/{len(polygons)} is valid"
)
yield simplified_polygon
else:
invalid_polygons.append(shapely_polygon)
# Weve found polygons where all the points line up, so they
# dont have an area. They wouldnt contribute to a broadcast
# so we can ignore them.
if simplified_polygon.area == 0:
print( # noqa: T201
f"{indent}Polygon {index + 1}/{len(polygons)} has 0 area, skipping"
)
continue
print( # noqa: T201
f"{indent}Polygon {index + 1}/{len(polygons)} needs fixing..."
)
# Buffering with a size of 0 is a trick to make valid
# geometries from polygons that self intersect
buffered = shapely_polygon.buffer(0)
# If the buffering has caused our polygon to split into
# multiple polygons, we need to recursively check them
# instead
if isinstance(buffered, MultiPolygon):
for sub_polygon in clean_up_invalid_polygons(buffered, indent=" "):
yield sub_polygon
continue
# We only care about the exterior of the polygon, not an
# holes in it that may have been created by fixing self
# intersection
fixed_polygon = Polygon(buffered.exterior)
# Make sure the polygon is now valid, and that we havent
# drastically transformed the polygon by fixing it
assert fixed_polygon.is_valid
assert isclose(fixed_polygon.area, shapely_polygon.area, rel_tol=0.001)
print( # noqa: T201
f"{indent}Polygon {index + 1}/{len(polygons)} fixed!"
)
yield fixed_polygon
def polygons_and_simplified_polygons(feature):
if keep_old_polygons:
# cheat and shortcut out
return [], []
raw_polygons = simplify_geometry(feature)
clean_raw_polygons = [
[[x, y] for x, y in polygon.exterior.coords]
for polygon in clean_up_invalid_polygons(raw_polygons)
]
polygons = Polygons(clean_raw_polygons)
full_resolution = polygons.remove_too_small
smoothed = full_resolution.smooth
simplified = smoothed.simplify
if not (len(full_resolution) or len(simplified)):
raise RuntimeError(
'Polygon of 0 size found'
)
print( # noqa: T201
f' Original:{full_resolution.point_count: >5} points'
f' Smoothed:{smoothed.point_count: >5} points'
f' Simplified:{simplified.point_count: >4} points'
)
point_counts.append(simplified.point_count)
if simplified.point_count >= MAX_NUMBER_OF_POINTS_PER_POLYGON:
raise RuntimeError(
'Too many points '
'(adjust Polygons.perimeter_to_simplification_ratio or '
'Polygons.perimeter_to_buffer_ratio)'
)
output = [
full_resolution.as_coordinate_pairs_long_lat,
simplified.as_coordinate_pairs_long_lat,
]
# Check that the simplification process hasnt introduced bad data
for dataset in output:
for polygon in dataset:
assert Polygon(polygon).is_valid
return output + [simplified.utm_crs]
2020-09-09 13:29:45 +01:00
def estimate_number_of_smartphones_in_area(country_or_ward_code):
if country_or_ward_code in CITY_OF_LONDON.WARDS:
# We dont have population figures for wards of the City of
# London. Well leave it empty here and estimate on the fly
# later based on physical area.
print(' Population: N/A') # noqa: T201
2020-09-09 13:29:45 +01:00
return None
# For some reason Bryher is the only ward missing population data, so we
# need to hard code it. For simplicity, lets assume all 84 people who
# live on Bryher are 40 years old
if country_or_ward_code == BRYHER.WD20_CODE:
return BRYHER.POPULATION * SMARTPHONE_OWNERSHIP_BY_AGE_RANGE[MEDIAN_AGE_RANGE_UK]
if country_or_ward_code not in area_to_population_mapping:
raise ValueError(f'No population data for {country_or_ward_code}')
2020-09-16 11:33:57 +01:00
return estimate_number_of_smartphones_for_population(
area_to_population_mapping[country_or_ward_code]
2020-09-09 13:29:45 +01:00
)
test_filepath = source_files_path / "Test.geojson"
ctry19_filepath = source_files_path / "Countries.geojson"
# https://geoportal.statistics.gov.uk/datasets/wards-may-2020-boundaries-uk-bgc
# Converted to geojson manually from SHP because of GeoJSON download limits
2020-08-24 18:50:17 +01:00
wd20_filepath = source_files_path / "Electoral Wards May 2020.geojson"
2020-08-24 18:50:17 +01:00
# http://geoportal.statistics.gov.uk/datasets/local-authority-districts-may-2020-boundaries-uk-bgc
lad20_filepath = source_files_path / "Local Authorities May 2020.geojson"
# https://geoportal.statistics.gov.uk/datasets/counties-and-unitary-authorities-december-2019-boundaries-uk-bgc
2020-08-24 18:50:17 +01:00
ctyua19_filepath = source_files_path / "Counties_and_Unitary_Authorities__December_2019__Boundaries_UK_BGC.geojson"
2020-09-09 13:29:45 +01:00
2020-08-24 18:50:17 +01:00
# http://geoportal.statistics.gov.uk/datasets/ward-to-westminster-parliamentary-constituency-to-local-authority-district-december-2019-lookup-in-the-united-kingdom/data
wd_lad_map_filepath = source_files_path / "Electoral Wards and Local Authorities 2020.geojson"
# https://geoportal.statistics.gov.uk/datasets/lower-tier-local-authority-to-upper-tier-local-authority-december-2019-lookup-in-england-and-wales?where=LTLA19CD%20%3D%20%27E06000045%27
ltla_utla_map_filepath = source_files_path / "Lower_Tier_Local_Authority_to_Upper_Tier_Local_Authority__December_2019__Lookup_in_England_and_Wales.csv" # noqa: E501
2020-09-09 13:29:45 +01:00
# https://www.ons.gov.uk/peoplepopulationandcommunity/populationandmigration/populationestimates/datasets/wardlevelmidyearpopulationestimatesexperimental
population_filepath_england_wales = source_files_path / "Mid-2019_Persons_England_Wales.csv"
# https://www.nrscotland.gov.uk/statistics-and-data/statistics/statistics-by-theme/population/population-estimates/2011-based-special-area-population-estimates/electoral-ward-population-estimates
population_filepath_scotland = source_files_path / "Mid-2019_Persons_Scotland.csv"
population_filepath_northern_ireland = source_files_path / "Ward-2014_Northern_Ireland.csv"
population_filepath_uk = source_files_path / "MYE1-2019.csv"
ward_code_to_la_mapping = {
f["properties"]["WD19CD"]: f["properties"]["LAD19NM"]
2020-08-24 18:50:17 +01:00
for f in geojson.loads(wd_lad_map_filepath.read_text())["features"]
}
ward_code_to_la_id_mapping = {
f["properties"]["WD19CD"]: f["properties"]["LAD19CD"]
2020-08-24 18:50:17 +01:00
for f in geojson.loads(wd_lad_map_filepath.read_text())["features"]
}
# the mapping dict is empty for lower tier local authorities that are also upper tier (unitary authorities, etc)
ltla_utla_mapping_csv = csv.DictReader(ltla_utla_map_filepath.open())
la_code_to_cty_id_mapping = {
row['LTLA19CD']: row['UTLA19CD'] for row in ltla_utla_mapping_csv if row['LTLA19CD'] != row['UTLA19CD']
}
2020-09-09 13:29:45 +01:00
area_to_population_mapping = {}
for population_filepath in (
population_filepath_uk,
population_filepath_england_wales,
population_filepath_northern_ireland,
population_filepath_scotland,
):
area_to_population_csv = csv.DictReader(population_filepath.open())
for row in area_to_population_csv:
area_to_population_mapping[row['ward']] = [
(
int(k) if k.isnumeric() else MEDIAN_AGE_UK,
int(float(v.replace(',', '') or '0'))
)
for k, v in row.items() if k != 'ward'
]
def add_test_areas():
dataset_id = 'test'
dataset_geojson = geojson.loads(test_filepath.read_text())
repo.insert_broadcast_area_library(
dataset_id,
name='Test areas',
name_singular='test area',
is_group=False,
)
areas_to_add = []
for feature in dataset_geojson["features"]:
f_id = feature["properties"]['id']
f_name = feature["properties"]['name']
print() # noqa: T201
print(f_name) # noqa: T201
feature, _, utm_crs = polygons_and_simplified_polygons(
feature["geometry"]
)
areas_to_add.append([
f'{dataset_id}-{f_id}', f_name,
dataset_id, None,
feature, feature,
utm_crs,
0,
])
repo.insert_broadcast_areas(areas_to_add, keep_old_polygons)
2020-09-04 17:26:37 +01:00
def add_countries():
dataset_id = 'ctry19'
dataset_geojson = geojson.loads(ctry19_filepath.read_text())
repo.insert_broadcast_area_library(
'ctry19',
name='Countries',
name_singular='country',
is_group=False,
)
areas_to_add = []
for feature in dataset_geojson["features"]:
2020-09-09 13:29:45 +01:00
f_id = feature["properties"]['ctry19cd']
f_name = feature["properties"]['ctry19nm']
print() # noqa: T201
print(f_name) # noqa: T201
feature, simple_feature, utm_crs = (
polygons_and_simplified_polygons(feature["geometry"])
)
areas_to_add.append([
2020-09-09 13:29:45 +01:00
f'ctry19-{f_id}', f_name,
dataset_id, None,
feature, simple_feature,
utm_crs,
2020-09-09 13:29:45 +01:00
estimate_number_of_smartphones_in_area(f_id),
])
repo.insert_broadcast_areas(areas_to_add, keep_old_polygons)
2020-09-04 17:26:37 +01:00
def add_wards_local_authorities_and_counties():
dataset_name = "Local authorities"
dataset_name_singular = "local authority"
dataset_id = "wd20-lad20-ctyua19"
repo.insert_broadcast_area_library(
dataset_id,
name=dataset_name,
name_singular=dataset_name_singular,
is_group=True,
)
_add_electoral_wards(dataset_id)
_add_local_authorities(dataset_id)
2020-09-04 17:26:37 +01:00
_add_counties_and_unitary_authorities(dataset_id)
def _add_electoral_wards(dataset_id):
areas_to_add = []
for feature in geojson.loads(wd20_filepath.read_text())["features"]:
2020-09-04 17:26:37 +01:00
ward_code = feature["properties"]["wd20cd"]
ward_name = feature["properties"]["wd20nm"]
ward_id = "wd20-" + ward_code
print() # noqa: T201
print(ward_name) # noqa: T201
try:
la_id = "lad20-" + ward_code_to_la_id_mapping[ward_code]
2020-09-04 17:26:37 +01:00
feature, simple_feature, utm_crs = (
2020-09-04 17:26:37 +01:00
polygons_and_simplified_polygons(feature["geometry"])
)
Estimate number of phones in an arbitrary polygon We want to know how many phones are in a user-supplied polygon, so we can show the impact of a broadcast, in the same way that we do when users pick areas from our library. We already know how many phones are in each electoral ward. But there are challenges with an arbitrary polygon: - where it does overlap a ward, the overlap could be partial - it could overlap more than one ward - finding out which wards it overlaps by brute force (looping through all the wards and seeing which ones intersect with our polygon) would be way to slow to do in real time Instead we can use a data structure called an R-tree[1] to build an index which provides a much, much faster way of looking up which polygons overlap another. We can build this tree in advance and save it somewhere, which means there’s a lot of computation we don’t need to do in real time. The R-tree returns a set of objects (ward IDs) which we can go and look up in our library of electoral wards. These wards will be the ones that might have some overlap with our custom polygon. Once we have this small set of wards which might overlap our ward, we can look at the size of the area of overlap (relative to the size of the whole ward) and multiply that by the known count of phones in that ward to get an approximation of the count of phones in the overlap area. Summing these approximations give an estimate for the whole area of the custom polygon. 1. https://en.wikipedia.org/wiki/R-tree
2021-03-18 23:02:32 +00:00
if feature:
rtree_index.insert(ward_id, Rect(*Polygons(feature).bounds))
Estimate number of phones in an arbitrary polygon We want to know how many phones are in a user-supplied polygon, so we can show the impact of a broadcast, in the same way that we do when users pick areas from our library. We already know how many phones are in each electoral ward. But there are challenges with an arbitrary polygon: - where it does overlap a ward, the overlap could be partial - it could overlap more than one ward - finding out which wards it overlaps by brute force (looping through all the wards and seeing which ones intersect with our polygon) would be way to slow to do in real time Instead we can use a data structure called an R-tree[1] to build an index which provides a much, much faster way of looking up which polygons overlap another. We can build this tree in advance and save it somewhere, which means there’s a lot of computation we don’t need to do in real time. The R-tree returns a set of objects (ward IDs) which we can go and look up in our library of electoral wards. These wards will be the ones that might have some overlap with our custom polygon. Once we have this small set of wards which might overlap our ward, we can look at the size of the area of overlap (relative to the size of the whole ward) and multiply that by the known count of phones in that ward to get an approximation of the count of phones in the overlap area. Summing these approximations give an estimate for the whole area of the custom polygon. 1. https://en.wikipedia.org/wiki/R-tree
2021-03-18 23:02:32 +00:00
areas_to_add.append([
ward_id, ward_name,
dataset_id, la_id,
2020-09-09 13:29:45 +01:00
feature, simple_feature,
utm_crs,
2020-09-09 13:29:45 +01:00
estimate_number_of_smartphones_in_area(ward_code),
])
except KeyError:
print("Skipping", ward_code, ward_name) # noqa: T201
rtree_index_path.open('wb').write(pickle.dumps(rtree_index))
repo.insert_broadcast_areas(areas_to_add, keep_old_polygons)
def _add_local_authorities(dataset_id):
areas_to_add = []
for feature in geojson.loads(lad20_filepath.read_text())["features"]:
Update shapes to bring in fixes for Bristol I emailed the Geography team at the ONS: > Hi geography team, > > I work on GOV.UK Notify, which is a service run by Government Digital Service (part of the Cabinet Office). I was given your email address by [redacted] who’s been helping answer some of my questions on the cross-government Slack. > > We’re using some of the boundary datasets from the Open Geography Portal, and mostly they’ve been excellent. > > In the abstract, the problem we’re trying to solve is, given a point outside an area, what is the minimum distance to a point within that area. So, for example, if a crow was somewhere in Cardiff, what’s the shortest distance it would have to fly to reach somewhere in the Bristol local authority district? > > We’ve noticed some problems with the data that means our calculations would be wrong. We’ve noticed this around Torquay, Norwich and Bristol. Here are some screenshots of Bristol, from the generalised and full resolution boundaries: > > The artefacts I’ve highlighted are closer to Cardiff than any actual part of the land area of Bristol. They are either: > - in the sea > - land that’s part of North Somerset > > I suspect that this is being caused by the process of clipping the actual region of Bristol (which, unusually, extends into the water) to the mean high water line. > > I’ve worked around this by filtering out any polygons that are smaller than ~7,500m². It’s a bit hacky because parts of the Scilly Isles start disappearing. That’s not a problem for what I’m working on, but it would be nice to not need the hack. > > So my questions would be: > > - Is there a better way to remove these artefacts than filtering by area? > - Is there a plan to remove these artefacts from the data in future releases? > > Thanks in advance, > Chris They emailed back to say: > Hi Chris > > Thank you for your enquiry. > > We have completed the amendments to the LAD MAY 2020 BFC and BGC boundaries as mentioned so you should be able to download them from the portal now. > > Hope this helps. > > Kind regards > [redacted] This commit brings in the files they’ve updated. We still have to do some filtering (but now at a higher resolution) because they haven’t fixed Norwich yet. I’ll email them separately about that.
2020-09-24 14:37:28 +01:00
la_id = feature["properties"]["LAD20CD"]
group_name = feature["properties"]["LAD20NM"]
print() # noqa: T201
print(group_name) # noqa: T201
2020-09-04 17:26:37 +01:00
group_id = "lad20-" + la_id
feature, simple_feature, utm_crs = (
polygons_and_simplified_polygons(feature["geometry"])
)
ctyua_id = la_code_to_cty_id_mapping.get(la_id)
areas_to_add.append([
group_id,
group_name,
dataset_id,
'ctyua19-' + ctyua_id if ctyua_id else None,
feature,
2020-09-09 13:29:45 +01:00
simple_feature,
utm_crs,
2020-09-09 13:29:45 +01:00
None,
])
repo.insert_broadcast_areas(areas_to_add, keep_old_polygons)
# counties and unitary authorities
def _add_counties_and_unitary_authorities(dataset_id):
areas_to_add = []
for feature in geojson.loads(ctyua19_filepath.read_text())['features']:
ctyua_id = feature["properties"]["ctyua19cd"]
group_name = feature["properties"]["ctyua19nm"]
la_id = 'lad20-' + ctyua_id
if repo.get_areas([la_id]):
continue
group_id = "ctyua19-" + ctyua_id
feature, simple_feature, utm_crs = (
polygons_and_simplified_polygons(feature["geometry"])
)
areas_to_add.append([
group_id, group_name,
dataset_id, None,
2020-09-09 13:29:45 +01:00
feature, simple_feature,
utm_crs,
2020-09-09 13:29:45 +01:00
None,
])
repo.insert_broadcast_areas(areas_to_add, keep_old_polygons)
# cheeky global variable
keep_old_polygons = sys.argv[1:] == ['--keep-old-polygons']
print('keep_old_polygons: ', keep_old_polygons) # noqa: T201
repo = BroadcastAreasRepository()
if keep_old_polygons:
repo.delete_library_data()
else:
repo.delete_db()
repo.create_tables()
add_test_areas()
add_countries()
add_wards_local_authorities_and_counties()
most_detailed_polygons = formatted_list(
sorted(point_counts, reverse=True)[:5],
before_each='',
after_each='',
)
2020-09-09 13:29:45 +01:00
print( # noqa: T201
'\n'
'DONE\n'
f' Processed {len(point_counts):,} polygons.\n'
f' Cleaned up {len(invalid_polygons):,} polygons.\n'
f' Highest point counts once simplifed: {most_detailed_polygons}\n'
)