Merge pull request #3844 from alphagov/calculate-phones-custom-areas

Calculate the estimated number of phones in an arbitrary polygon
This commit is contained in:
Chris Hill-Scott
2021-04-13 09:55:00 +01:00
committed by GitHub
10 changed files with 133 additions and 18 deletions

View File

@@ -7,7 +7,7 @@ from notifications_utils.serialised_model import SerialisedModelCollection
from werkzeug.utils import cached_property from werkzeug.utils import cached_property
from .populations import CITY_OF_LONDON from .populations import CITY_OF_LONDON
from .repo import BroadcastAreasRepository from .repo import BroadcastAreasRepository, rtree_index
class SortableMixin: class SortableMixin:
@@ -139,10 +139,6 @@ class BroadcastArea(BaseBroadcastArea, SortableMixin):
class CustomBroadcastArea(BaseBroadcastArea): class CustomBroadcastArea(BaseBroadcastArea):
# We dont yet have a way to estimate the number of phones in a
# user-defined polygon
count_of_phones = 0
def __init__(self, *, name, polygons=None): def __init__(self, *, name, polygons=None):
self.name = name self.name = name
self._polygons = polygons or [] self._polygons = polygons or []
@@ -157,6 +153,21 @@ class CustomBroadcastArea(BaseBroadcastArea):
simple_polygons = polygons simple_polygons = polygons
@property
def overlapping_areas(self):
if not self.polygons:
return []
return broadcast_area_libraries.get_areas(
*rtree_index.intersection(self.polygons.bounds, objects='raw')
)
@cached_property
def count_of_phones(self):
return sum(
area.polygons.ratio_of_intersection_with(self.polygons) * area.count_of_phones
for area in self.overlapping_areas
)
class CustomBroadcastAreas(SerialisedModelCollection): class CustomBroadcastAreas(SerialisedModelCollection):
model = CustomBroadcastArea model = CustomBroadcastArea

View File

@@ -15,7 +15,7 @@ from populations import (
SMARTPHONE_OWNERSHIP_BY_AGE_RANGE, SMARTPHONE_OWNERSHIP_BY_AGE_RANGE,
estimate_number_of_smartphones_for_population, estimate_number_of_smartphones_for_population,
) )
from repo import BroadcastAreasRepository from repo import BroadcastAreasRepository, rtree_index
source_files_path = Path(__file__).resolve().parent / 'source_files' source_files_path = Path(__file__).resolve().parent / 'source_files'
point_counts = [] point_counts = []
@@ -227,7 +227,7 @@ def add_wards_local_authorities_and_counties():
def _add_electoral_wards(dataset_id): def _add_electoral_wards(dataset_id):
areas_to_add = [] areas_to_add = []
for feature in geojson.loads(wd20_filepath.read_text())["features"]: for index, feature in enumerate(geojson.loads(wd20_filepath.read_text())["features"]):
ward_code = feature["properties"]["wd20cd"] ward_code = feature["properties"]["wd20cd"]
ward_name = feature["properties"]["wd20nm"] ward_name = feature["properties"]["wd20nm"]
ward_id = "wd20-" + ward_code ward_id = "wd20-" + ward_code
@@ -242,6 +242,9 @@ def _add_electoral_wards(dataset_id):
polygons_and_simplified_polygons(feature["geometry"]) polygons_and_simplified_polygons(feature["geometry"])
) )
if feature:
rtree_index.insert(index, Polygons(feature).bounds, obj=ward_id)
areas_to_add.append([ areas_to_add.append([
ward_id, ward_name, ward_id, ward_name,
dataset_id, la_id, dataset_id, la_id,
@@ -337,4 +340,6 @@ print( # noqa: T001
'DONE\n' 'DONE\n'
f' Processed {len(point_counts):,} polygons.\n' f' Processed {len(point_counts):,} polygons.\n'
f' Highest point counts once simplifed: {most_detailed_polygons}\n' f' Highest point counts once simplifed: {most_detailed_polygons}\n'
f' RTree bounds: {rtree_index.bounds}\n'
f' Number of objects in Rtree: {rtree_index.get_size():,}\n'
) )

View File

@@ -3,6 +3,13 @@ import os
import sqlite3 import sqlite3
from pathlib import Path from pathlib import Path
from rtree import index
rtree_index = index.Rtree(
str((Path(__file__).parent / 'rtree').absolute()),
interleaved=True,
)
class BroadcastAreasRepository(object): class BroadcastAreasRepository(object):
def __init__(self): def __init__(self):

Binary file not shown.

Binary file not shown.

View File

@@ -19,12 +19,13 @@ gunicorn==20.1.0
eventlet==0.30.2 eventlet==0.30.2
notifications-python-client==6.0.2 notifications-python-client==6.0.2
Shapely==1.7.1 Shapely==1.7.1
Rtree==0.9.7
# PaaS # PaaS
awscli-cwlogs>=1.4,<1.5 awscli-cwlogs>=1.4,<1.5
itsdangerous==1.1.0 itsdangerous==1.1.0
git+https://github.com/alphagov/notifications-utils.git@44.1.0#egg=notifications-utils==44.1.0 git+https://github.com/alphagov/notifications-utils.git@44.2.0#egg=notifications-utils==44.2.0
git+https://github.com/alphagov/govuk-frontend-jinja.git@v0.5.8-alpha#egg=govuk-frontend-jinja==0.5.8-alpha git+https://github.com/alphagov/govuk-frontend-jinja.git@v0.5.8-alpha#egg=govuk-frontend-jinja==0.5.8-alpha
# gds-metrics requires prometheseus 0.2.0, override that requirement as later versions bring significant performance gains # gds-metrics requires prometheseus 0.2.0, override that requirement as later versions bring significant performance gains

View File

@@ -108,7 +108,7 @@ mistune==0.8.4
# via notifications-utils # via notifications-utils
notifications-python-client==6.0.2 notifications-python-client==6.0.2
# via -r requirements.in # via -r requirements.in
git+https://github.com/alphagov/notifications-utils.git@44.1.0#egg=notifications-utils==44.1.0 git+https://github.com/alphagov/notifications-utils.git@44.2.0#egg=notifications-utils==44.2.0
# via -r requirements.in # via -r requirements.in
openpyxl==3.0.7 openpyxl==3.0.7
# via pyexcel-xlsx # via pyexcel-xlsx
@@ -171,6 +171,8 @@ requests==2.25.1
# notifications-utils # notifications-utils
rsa==4.7.2 rsa==4.7.2
# via awscli # via awscli
rtree==0.9.7
# via -r requirements.in
s3transfer==0.3.6 s3transfer==0.3.6
# via # via
# awscli # awscli

View File

@@ -0,0 +1,15 @@
BRISTOL = [
[51.4371, -2.6216],
[51.4371, -2.5750],
[51.4668, -2.5750],
[51.4668, -2.6216],
[51.4371, -2.6216],
]
SKYE = [
[57.1004, -6.8280],
[57.1004, -5.7733],
[57.7334, -5.7733],
[57.7334, -6.8280],
[57.1004, -6.8280],
]

View File

@@ -1,9 +1,11 @@
from math import isclose from math import isclose
import pytest import pytest
from custom_polygons import BRISTOL, SKYE
from app.broadcast_areas import ( from app.broadcast_areas import (
BroadcastAreasRepository, BroadcastAreasRepository,
CustomBroadcastArea,
broadcast_area_libraries, broadcast_area_libraries,
) )
from app.broadcast_areas.populations import ( from app.broadcast_areas.populations import (
@@ -350,3 +352,50 @@ def test_estimated_bleed(
broadcast_area_libraries.get_areas(area)[0].estimated_bleed_in_degrees, broadcast_area_libraries.get_areas(area)[0].estimated_bleed_in_degrees,
expected_bleed_in_degrees, expected_bleed_in_degrees,
) )
@pytest.mark.parametrize('polygon, expected_possible_overlaps, expected_count_of_phones', (
(
BRISTOL,
[
'Ashley',
'Bedminster',
'Central',
'Clifton',
'Clifton Down',
'Cotham',
'Hotwells and Harbourside',
'Knowle',
'Lawrence Hill',
'Southville',
'Stoke Bishop',
'Windmill Hill',
],
73_496,
),
(
SKYE,
[
'Caol and Mallaig',
'Eilean á Chèo',
'Na Hearadh agus Ceann a Deas nan Loch',
'Wester Ross, Strathpeffer and Lochalsh',
],
3_517,
),
))
def test_count_of_phones_for_custom_area(
polygon,
expected_possible_overlaps,
expected_count_of_phones,
):
area = CustomBroadcastArea(
name='Example',
polygons=[polygon],
)
assert sorted(
overlap.name for overlap in area.overlapping_areas
) == expected_possible_overlaps
assert close_enough(area.count_of_phones, expected_count_of_phones)

View File

@@ -8,6 +8,7 @@ from flask import url_for
from freezegun import freeze_time from freezegun import freeze_time
from tests import broadcast_message_json, sample_uuid, user_json from tests import broadcast_message_json, sample_uuid, user_json
from tests.app.broadcast_areas.custom_polygons import BRISTOL, SKYE
from tests.conftest import SERVICE_ONE_ID, normalize_spaces from tests.conftest import SERVICE_ONE_ID, normalize_spaces
sample_uuid = sample_uuid() sample_uuid = sample_uuid()
@@ -727,11 +728,42 @@ def test_preview_broadcast_areas_page(
] == estimates ] == estimates
@pytest.mark.parametrize('polygons, expected_list_items', (
(
[
[[1, 2], [3, 4], [5, 6]],
[[7, 8], [9, 10], [11, 12]],
],
[
'An area of 722.3 square miles Will get the alert',
'An extra area of 1,498.5 square miles is Likely to get the alert',
'Unknown number of phones',
]
),
(
[BRISTOL],
[
'An area of 6.6 square miles Will get the alert',
'An extra area of 6.4 square miles is Likely to get the alert',
'70,000 to 100,000 phones',
]
),
(
[SKYE],
[
'An area of 3,205.0 square miles Will get the alert',
'An extra area of 763.4 square miles is Likely to get the alert',
'4,000 to 5,000 phones',
]
),
))
def test_preview_broadcast_areas_page_with_custom_polygons( def test_preview_broadcast_areas_page_with_custom_polygons(
mocker, mocker,
client_request, client_request,
service_one, service_one,
fake_uuid, fake_uuid,
polygons,
expected_list_items,
): ):
service_one['permissions'] += ['broadcast'] service_one['permissions'] += ['broadcast']
mocker.patch( mocker.patch(
@@ -743,10 +775,7 @@ def test_preview_broadcast_areas_page_with_custom_polygons(
service_id=SERVICE_ONE_ID, service_id=SERVICE_ONE_ID,
status='draft', status='draft',
areas=['Area one', 'Area two', 'Area three'], areas=['Area one', 'Area two', 'Area three'],
simple_polygons=[ simple_polygons=polygons,
[[1, 2], [3, 4], [5, 6]],
[[7, 8], [9, 10], [11, 12]],
],
), ),
) )
page = client_request.get( page = client_request.get(
@@ -767,11 +796,7 @@ def test_preview_broadcast_areas_page_with_custom_polygons(
assert [ assert [
normalize_spaces(item.text) normalize_spaces(item.text)
for item in page.select('ul li.area-list-key') for item in page.select('ul li.area-list-key')
] == [ ] == expected_list_items
'An area of 722.3 square miles Will get the alert',
'An extra area of 1,498.5 square miles is Likely to get the alert',
'Unknown number of phones',
]
@pytest.mark.parametrize('areas, expected_list', ( @pytest.mark.parametrize('areas, expected_list', (