2021-03-12 09:17:42 +00:00
|
|
|
|
import math
|
2021-03-19 16:21:43 +00:00
|
|
|
|
from abc import ABC, abstractmethod
|
2021-03-12 09:17:42 +00:00
|
|
|
|
|
2020-09-09 17:35:17 +01:00
|
|
|
|
from notifications_utils.formatters import formatted_list
|
2021-01-25 13:49:23 +00:00
|
|
|
|
from notifications_utils.polygons import Polygons
|
2020-07-06 10:53:40 +01:00
|
|
|
|
from notifications_utils.serialised_model import SerialisedModelCollection
|
2021-04-13 12:43:28 +01:00
|
|
|
|
from rtreelib import Rect
|
2020-08-10 10:46:31 +01:00
|
|
|
|
from werkzeug.utils import cached_property
|
2020-07-06 10:53:40 +01:00
|
|
|
|
|
2021-08-03 18:46:30 +01:00
|
|
|
|
from app.formatters import square_metres_to_square_miles
|
2022-02-01 11:34:39 +00:00
|
|
|
|
from app.models import SortByNameMixin
|
2021-08-03 18:46:30 +01:00
|
|
|
|
|
2020-09-16 11:35:40 +01:00
|
|
|
|
from .populations import CITY_OF_LONDON
|
2021-03-18 23:02:32 +00:00
|
|
|
|
from .repo import BroadcastAreasRepository, rtree_index
|
2020-07-24 12:54:22 +01:00
|
|
|
|
|
|
|
|
|
|
|
2022-02-01 11:36:30 +00:00
|
|
|
|
class IdEqualityMixin:
|
2020-07-06 10:53:40 +01:00
|
|
|
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
|
|
return f'{self.__class__.__name__}(<{self.id}>)'
|
|
|
|
|
|
|
2020-09-21 18:55:46 +01:00
|
|
|
|
def __eq__(self, other):
|
|
|
|
|
|
return self.id == other.id
|
|
|
|
|
|
|
|
|
|
|
|
def __hash__(self):
|
|
|
|
|
|
return hash(self.id)
|
|
|
|
|
|
|
2020-07-24 16:47:12 +01:00
|
|
|
|
|
2020-07-06 10:53:40 +01:00
|
|
|
|
class GetItemByIdMixin:
|
|
|
|
|
|
def get(self, id):
|
|
|
|
|
|
for item in self:
|
|
|
|
|
|
if item.id == id:
|
|
|
|
|
|
return item
|
|
|
|
|
|
raise KeyError(id)
|
|
|
|
|
|
|
|
|
|
|
|
|
2021-03-19 16:21:43 +00:00
|
|
|
|
class BaseBroadcastArea(ABC):
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
@abstractmethod
|
|
|
|
|
|
def simple_polygons(self):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
@abstractmethod
|
|
|
|
|
|
def polygons(self):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
@abstractmethod
|
|
|
|
|
|
def count_of_phones(self):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
@cached_property
|
|
|
|
|
|
def simple_polygons_with_bleed(self):
|
2021-08-03 18:46:30 +01:00
|
|
|
|
return self.simple_polygons.bleed_by(self.estimated_bleed_in_m)
|
2021-03-19 16:21:43 +00:00
|
|
|
|
|
|
|
|
|
|
@cached_property
|
|
|
|
|
|
def phone_density(self):
|
2021-08-05 18:38:50 +01:00
|
|
|
|
if not self.simple_polygons.estimated_area:
|
2021-03-19 16:21:43 +00:00
|
|
|
|
return 0
|
2021-08-05 18:38:50 +01:00
|
|
|
|
return self.count_of_phones / square_metres_to_square_miles(self.simple_polygons.estimated_area)
|
2021-03-19 16:21:43 +00:00
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def estimated_bleed_in_m(self):
|
|
|
|
|
|
'''
|
|
|
|
|
|
Estimates the amount of bleed based on the population of an
|
|
|
|
|
|
area. Higher density areas tend to have short range masts, so
|
|
|
|
|
|
the bleed is low (down to 500m). Lower density areas have longer
|
|
|
|
|
|
range masts, so the typical bleed will be high (up to 5,000m).
|
|
|
|
|
|
'''
|
|
|
|
|
|
if self.phone_density < 1:
|
2021-08-03 18:46:30 +01:00
|
|
|
|
return Polygons.approx_bleed_in_m
|
2021-03-19 16:21:43 +00:00
|
|
|
|
estimated_bleed = 5_900 - (math.log(self.phone_density, 10) * 1_250)
|
|
|
|
|
|
return max(500, min(estimated_bleed, 5000))
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-02-01 11:36:30 +00:00
|
|
|
|
class BroadcastArea(BaseBroadcastArea, IdEqualityMixin, SortByNameMixin):
|
2020-07-06 10:53:40 +01:00
|
|
|
|
|
2020-07-24 16:47:12 +01:00
|
|
|
|
def __init__(self, row):
|
2020-09-21 18:55:46 +01:00
|
|
|
|
self.id, self.name, self._count_of_phones, self.library_id = row
|
2020-07-06 10:53:40 +01:00
|
|
|
|
|
2021-08-24 16:06:46 +01:00
|
|
|
|
@cached_property
|
|
|
|
|
|
def is_lower_tier_local_authority(self):
|
|
|
|
|
|
return self.id.startswith('lad20-') and self.parent
|
|
|
|
|
|
|
|
|
|
|
|
@cached_property
|
|
|
|
|
|
def is_electoral_ward(self):
|
|
|
|
|
|
return self.id.startswith('wd20-')
|
|
|
|
|
|
|
2021-07-05 17:30:45 +01:00
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_row_with_simple_polygons(cls, row):
|
|
|
|
|
|
instance = cls(row[:4])
|
2021-08-05 19:37:57 +01:00
|
|
|
|
instance.simple_polygons = Polygons(
|
|
|
|
|
|
row[4],
|
|
|
|
|
|
utm_crs=row[5],
|
|
|
|
|
|
)
|
2021-07-05 17:30:45 +01:00
|
|
|
|
return instance
|
|
|
|
|
|
|
2020-08-24 14:43:28 +01:00
|
|
|
|
@cached_property
|
2020-07-28 18:38:40 +01:00
|
|
|
|
def polygons(self):
|
2021-08-05 19:37:57 +01:00
|
|
|
|
polygons, utm_crs = BroadcastAreasRepository().get_polygons_for_area(self.id)
|
2020-08-25 16:55:09 +01:00
|
|
|
|
return Polygons(
|
2021-08-05 19:37:57 +01:00
|
|
|
|
polygons, utm_crs=utm_crs
|
2020-08-25 16:55:09 +01:00
|
|
|
|
)
|
2020-07-28 18:38:40 +01:00
|
|
|
|
|
2020-08-24 14:43:28 +01:00
|
|
|
|
@cached_property
|
2020-07-28 18:38:40 +01:00
|
|
|
|
def simple_polygons(self):
|
2021-08-05 19:37:57 +01:00
|
|
|
|
simple_polygons, utm_crs = (
|
2020-08-25 16:55:09 +01:00
|
|
|
|
BroadcastAreasRepository().get_simple_polygons_for_area(self.id)
|
|
|
|
|
|
)
|
2021-08-05 19:37:57 +01:00
|
|
|
|
return Polygons(
|
|
|
|
|
|
simple_polygons, utm_crs=utm_crs
|
|
|
|
|
|
).utm_polygons
|
2020-07-30 17:24:20 +01:00
|
|
|
|
|
2020-09-04 15:22:32 +01:00
|
|
|
|
@cached_property
|
2020-07-31 13:21:21 +01:00
|
|
|
|
def sub_areas(self):
|
|
|
|
|
|
return [
|
|
|
|
|
|
BroadcastArea(row)
|
|
|
|
|
|
for row in BroadcastAreasRepository().get_all_areas_for_group(self.id)
|
|
|
|
|
|
]
|
2020-07-06 10:53:40 +01:00
|
|
|
|
|
2020-09-09 13:29:45 +01:00
|
|
|
|
@property
|
|
|
|
|
|
def count_of_phones(self):
|
|
|
|
|
|
if self.id.endswith(CITY_OF_LONDON.WARDS):
|
|
|
|
|
|
return CITY_OF_LONDON.DAYTIME_POPULATION * (
|
2021-08-03 18:46:30 +01:00
|
|
|
|
self.polygons.estimated_area / CITY_OF_LONDON.AREA_SQUARE_METRES
|
2020-09-09 13:29:45 +01:00
|
|
|
|
)
|
|
|
|
|
|
if self.sub_areas:
|
2020-09-17 11:00:17 +01:00
|
|
|
|
return sum(area.count_of_phones for area in self.sub_areas)
|
|
|
|
|
|
# TODO: remove the `or 0` once missing data is fixed, see
|
|
|
|
|
|
# https://www.pivotaltracker.com/story/show/174837293
|
|
|
|
|
|
return self._count_of_phones or 0
|
2020-09-09 13:29:45 +01:00
|
|
|
|
|
2020-09-21 18:55:46 +01:00
|
|
|
|
@cached_property
|
2021-08-23 16:48:21 +01:00
|
|
|
|
def ancestors(self):
|
|
|
|
|
|
return list(self._ancestors_iterator)
|
2020-09-21 18:55:46 +01:00
|
|
|
|
|
2021-08-24 15:01:53 +01:00
|
|
|
|
@cached_property
|
|
|
|
|
|
def parent(self):
|
|
|
|
|
|
return next(iter(self.ancestors), None)
|
|
|
|
|
|
|
2020-09-21 18:55:46 +01:00
|
|
|
|
@property
|
2021-08-23 16:48:21 +01:00
|
|
|
|
def _ancestors_iterator(self):
|
2020-09-21 18:55:46 +01:00
|
|
|
|
id = self.id
|
|
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
parent = BroadcastAreasRepository().get_parent_for_area(id)
|
|
|
|
|
|
|
|
|
|
|
|
if not parent:
|
2021-08-23 16:35:38 +01:00
|
|
|
|
return
|
2020-09-21 18:55:46 +01:00
|
|
|
|
|
|
|
|
|
|
parent_broadcast_area = BroadcastArea(parent)
|
|
|
|
|
|
yield parent_broadcast_area
|
|
|
|
|
|
id = parent_broadcast_area.id
|
|
|
|
|
|
|
2020-07-31 13:21:21 +01:00
|
|
|
|
|
2021-03-19 16:21:43 +00:00
|
|
|
|
class CustomBroadcastArea(BaseBroadcastArea):
|
2021-01-26 10:14:04 +00:00
|
|
|
|
|
|
|
|
|
|
def __init__(self, *, name, polygons=None):
|
|
|
|
|
|
self.name = name
|
|
|
|
|
|
self._polygons = polygons or []
|
|
|
|
|
|
|
2021-06-24 08:43:35 +01:00
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_polygon_objects(cls, polygon_objects):
|
2021-08-05 18:38:50 +01:00
|
|
|
|
instance = cls(name=None)
|
|
|
|
|
|
instance.polygons = polygon_objects
|
|
|
|
|
|
return instance
|
2021-06-24 08:43:35 +01:00
|
|
|
|
|
2021-08-05 18:38:50 +01:00
|
|
|
|
@cached_property
|
2021-01-26 10:14:04 +00:00
|
|
|
|
def polygons(self):
|
|
|
|
|
|
return Polygons(
|
|
|
|
|
|
# Polygons in the DB are stored with the coordinate pair
|
|
|
|
|
|
# order flipped – this flips them back again
|
2021-08-03 18:46:30 +01:00
|
|
|
|
[
|
|
|
|
|
|
[[lat, long] for long, lat in polygon]
|
|
|
|
|
|
for polygon in self._polygons
|
|
|
|
|
|
]
|
2021-01-26 10:14:04 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
simple_polygons = polygons
|
|
|
|
|
|
|
2021-08-24 14:36:14 +01:00
|
|
|
|
@cached_property
|
|
|
|
|
|
def overlapping_electoral_wards(self):
|
|
|
|
|
|
return [
|
|
|
|
|
|
area for area in self.nearby_electoral_wards
|
|
|
|
|
|
if area.simple_polygons.intersects(self.polygons)
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2021-08-06 10:11:24 +01:00
|
|
|
|
@cached_property
|
2021-08-23 16:50:56 +01:00
|
|
|
|
def nearby_electoral_wards(self):
|
2021-03-18 23:02:32 +00:00
|
|
|
|
if not self.polygons:
|
|
|
|
|
|
return []
|
2021-07-05 17:30:45 +01:00
|
|
|
|
return broadcast_area_libraries.get_areas_with_simple_polygons([
|
2021-08-23 16:50:56 +01:00
|
|
|
|
# We only index electoral wards in the RTree
|
2021-04-13 12:43:28 +01:00
|
|
|
|
overlap.data for overlap in rtree_index.query(
|
2021-08-05 18:38:50 +01:00
|
|
|
|
Rect(*self.simple_polygons.bounds)
|
2021-04-13 12:43:28 +01:00
|
|
|
|
)
|
|
|
|
|
|
])
|
2021-03-18 23:02:32 +00:00
|
|
|
|
|
|
|
|
|
|
@cached_property
|
|
|
|
|
|
def count_of_phones(self):
|
|
|
|
|
|
return sum(
|
2021-07-05 15:54:06 +01:00
|
|
|
|
area.simple_polygons.ratio_of_intersection_with(self.polygons) * area.count_of_phones
|
2021-08-23 16:50:56 +01:00
|
|
|
|
for area in self.nearby_electoral_wards
|
2021-03-18 23:02:32 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2021-01-26 10:14:04 +00:00
|
|
|
|
|
|
|
|
|
|
class CustomBroadcastAreas(SerialisedModelCollection):
|
|
|
|
|
|
model = CustomBroadcastArea
|
|
|
|
|
|
|
2021-09-06 10:02:01 +01:00
|
|
|
|
def __init__(self, *, names, polygons):
|
|
|
|
|
|
self.items = names
|
2021-01-26 10:14:04 +00:00
|
|
|
|
self._polygons = polygons
|
|
|
|
|
|
|
|
|
|
|
|
def __getitem__(self, index):
|
|
|
|
|
|
return self.model(
|
|
|
|
|
|
name=self.items[index],
|
|
|
|
|
|
polygons=self._polygons if index == 0 else None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2022-02-01 11:36:30 +00:00
|
|
|
|
class BroadcastAreaLibrary(SerialisedModelCollection, SortByNameMixin, IdEqualityMixin, GetItemByIdMixin):
|
2020-07-06 10:53:40 +01:00
|
|
|
|
|
|
|
|
|
|
model = BroadcastArea
|
|
|
|
|
|
|
2020-07-31 13:21:21 +01:00
|
|
|
|
def __init__(self, row):
|
2020-08-13 17:33:58 +01:00
|
|
|
|
id, name, name_singular, is_group = row
|
2020-07-31 13:21:21 +01:00
|
|
|
|
self.id = id
|
|
|
|
|
|
self.name = name
|
2020-08-13 17:33:58 +01:00
|
|
|
|
self.name_singular = name_singular
|
2020-07-31 13:21:21 +01:00
|
|
|
|
self.is_group = bool(is_group)
|
2020-08-12 10:17:45 +01:00
|
|
|
|
self.items = BroadcastAreasRepository().get_all_areas_for_library(self.id)
|
2020-07-06 10:53:40 +01:00
|
|
|
|
|
2020-07-24 16:47:12 +01:00
|
|
|
|
def get_examples(self):
|
2020-09-09 17:35:17 +01:00
|
|
|
|
# we show up to four things. three areas, then either a fourth area if there are exactly four, or "and X more".
|
|
|
|
|
|
areas_to_show = sorted(area.name for area in self)[:4]
|
|
|
|
|
|
|
|
|
|
|
|
count_of_areas_not_named = len(self.items) - 3
|
|
|
|
|
|
# if there's exactly one area not named, there are exactly four - we should just show all four.
|
|
|
|
|
|
if count_of_areas_not_named > 1:
|
|
|
|
|
|
areas_to_show = areas_to_show[:3] + [f'{count_of_areas_not_named} more…']
|
|
|
|
|
|
|
|
|
|
|
|
return formatted_list(areas_to_show, before_each='', after_each='')
|
2020-07-06 10:53:40 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BroadcastAreaLibraries(SerialisedModelCollection, GetItemByIdMixin):
|
|
|
|
|
|
|
|
|
|
|
|
model = BroadcastAreaLibrary
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
2020-08-12 10:13:16 +01:00
|
|
|
|
self.items = BroadcastAreasRepository().get_libraries()
|
2020-07-06 10:53:40 +01:00
|
|
|
|
|
2021-08-06 10:06:26 +01:00
|
|
|
|
def get_areas(self, area_ids):
|
2020-07-24 16:47:12 +01:00
|
|
|
|
areas = BroadcastAreasRepository().get_areas(area_ids)
|
|
|
|
|
|
return [BroadcastArea(area) for area in areas]
|
2020-07-06 10:53:40 +01:00
|
|
|
|
|
2021-08-06 10:06:26 +01:00
|
|
|
|
def get_areas_with_simple_polygons(self, area_ids):
|
2021-07-05 17:30:45 +01:00
|
|
|
|
areas = BroadcastAreasRepository().get_areas_with_simple_polygons(area_ids)
|
|
|
|
|
|
return [BroadcastArea.from_row_with_simple_polygons(area) for area in areas]
|
|
|
|
|
|
|
2020-07-06 10:53:40 +01:00
|
|
|
|
|
|
|
|
|
|
broadcast_area_libraries = BroadcastAreaLibraries()
|