From bcfa21428f3161b1e3964671d1ffda720c71b358 Mon Sep 17 00:00:00 2001 From: Ben Thorner Date: Tue, 24 Aug 2021 15:49:46 +0100 Subject: [PATCH] Support aggregating local authority clusters This applies some heuristics to try and keep the overall list of areas short when many are selected in the same wider area. Currently we only have relationship information between upper and lower tier local authorities, so we can't / won't aggregate up to Greater London (it's own special thing) or whole countries. --- app/broadcast_areas/utils.py | 39 ++++++++++++ tests/app/broadcast_areas/custom_polygons.py | 16 +++++ tests/app/broadcast_areas/test_utils.py | 66 +++++++++++++++++++- 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/app/broadcast_areas/utils.py b/app/broadcast_areas/utils.py index 26da631cb..fdcc1f83a 100644 --- a/app/broadcast_areas/utils.py +++ b/app/broadcast_areas/utils.py @@ -1,9 +1,12 @@ +from collections import defaultdict + from app.broadcast_areas.models import CustomBroadcastArea def aggregate_areas(areas): areas = _convert_custom_areas_to_wards(areas) areas = _aggregate_wards_by_local_authority(areas) + areas = _aggregate_lower_tier_authorities(areas) return areas @@ -24,3 +27,39 @@ def _aggregate_wards_by_local_authority(areas): area.parent if area.id.startswith('wd20-') else area for area in areas } + + +def _aggregate_lower_tier_authorities(areas): + results = set() + clusters = _cluster_lower_tier_authorities(areas) + + for cluster in clusters: + # always keep lone area as itself + if len(cluster) == 1: + results |= set(cluster) + # aggregate multi-area cluster + elif len(cluster) > 3: + results |= {cluster[0].parent} + # aggregate many small clusters + elif len(clusters) > 1: + area = cluster[0] + results |= {area.parent or area} + # keep one small cluster in full + else: + results |= set(cluster) + + return results + + +def _cluster_lower_tier_authorities(areas): + result = defaultdict(lambda: []) + + for area in areas: + # group lower tier authorities by "county" + if area.id.startswith('lad20-') and area.parent: + result[area.parent] += [area] + # leave countries, unitary authorities as-is + else: + result[area] = [area] + + return result.values() diff --git a/tests/app/broadcast_areas/custom_polygons.py b/tests/app/broadcast_areas/custom_polygons.py index 03a0dcc47..e79360649 100644 --- a/tests/app/broadcast_areas/custom_polygons.py +++ b/tests/app/broadcast_areas/custom_polygons.py @@ -37,3 +37,19 @@ CHELTENHAM = [ [51.9328, -2.0221], [51.9324, -2.1265], ] + +CHELTENHAM_AND_GLOUCESTER = [ + [51.8820, -2.2920], + [51.8234, -2.2570], + [51.8883, -2.0262], + [51.9425, -2.0771], + [51.8820, -2.2920], +] + +SEVERN_ESTUARY = [ + [51.5719, -2.9388], + [51.4180, -2.7259], + [51.8493, -2.1958], + [51.8883, -2.3016], + [51.5719, -2.9388], +] diff --git a/tests/app/broadcast_areas/test_utils.py b/tests/app/broadcast_areas/test_utils.py index 74e940fcc..c95b75eff 100644 --- a/tests/app/broadcast_areas/test_utils.py +++ b/tests/app/broadcast_areas/test_utils.py @@ -7,12 +7,24 @@ from tests.app.broadcast_areas.custom_polygons import ( BRISTOL, BURFORD, CHELTENHAM, + CHELTENHAM_AND_GLOUCESTER, SANTA_A, + SEVERN_ESTUARY, SKYE, ) @pytest.mark.parametrize(('area_ids', 'expected_area_names'), [ + ( + [ + 'wd20-E05009336', # Whitechapel, Tower Hamlets (electoral ward) + 'wd20-E05009372', # Hackney Central, Hackney (electoral ward) + 'wd20-E05009374', # Hackney Wick, Hackney (electoral ward) + ], [ + 'Hackney', # in Greater London* (DB doesn't know this) + 'Tower Hamlets', # in Greater London* + ], + ), ( [ 'wd20-E05004294', # Hester’s Way, Cheltenham (electoral ward) @@ -31,6 +43,16 @@ from tests.app.broadcast_areas.custom_polygons import ( 'Hackney', # in Greater London* (DB doesn't know this) ], ), + ( + [ + 'wd20-E05004294', # Hester’s Way, Cheltenham (electoral ward) + 'wd20-E05010981', # Painswick & Upton, Stroud (electoral ward) + 'wd20-E05009372', # Hackney Central, Hackney (electoral ward) + ], [ + 'Gloucestershire', # upper tier authority + 'Hackney', # in Greater London* (DB doesn't know this) + ], + ), ( [ 'lad20-E07000037', # High Peak (lower tier authority) @@ -49,6 +71,17 @@ from tests.app.broadcast_areas.custom_polygons import ( 'High Peak', # in Derbyshire ] ), + ( + [ + 'lad20-E07000037', # High Peak (lower tier authority) + 'lad20-E07000035', # Derbyshire Dales (lower tier authority) + 'ctyua19-E10000028', # Staffordshire (upper tier authority) + ], + [ + 'Derbyshire', # upper tier authority + 'Staffordshire', + ] + ), ( [ 'ctry19-E92000001', # England @@ -84,6 +117,19 @@ def test_aggregate_areas( 'Bristol, City of', ] ), + ( + [SEVERN_ESTUARY], [ + # Area covers various lower-tier authorities + # with more than three in Gloucestershire so + # we aggregate them into that + 'Bristol, City of', + 'Gloucestershire', + 'Monmouthshire', + 'Newport', + 'North Somerset', + 'South Gloucestershire', + ] + ), ( [CHELTENHAM], [ # Area covers three lower-tier authorities @@ -93,14 +139,30 @@ def test_aggregate_areas( 'Tewkesbury', ] ), + ( + [CHELTENHAM_AND_GLOUCESTER], [ + # Area covers more than 3 lower-tier authorities + # in Gloucestershire so we aggregate them + 'Gloucestershire', + ] + ), ( [BURFORD], [ - # Area covers two lower-tier authorities - # in Gloucestershire (upper tier authority) + # Area covers one lower-tier authority in + # Gloucestershire and one in Oxfordshire (both + # upper tier authorities) 'Cotswold', 'West Oxfordshire', ], ), + ( + [CHELTENHAM_AND_GLOUCESTER, BURFORD], [ + # Area covers many lower-tier authorities + # in Gloucestershire but only one in Oxfordshire + 'Gloucestershire', + 'West Oxfordshire', + ], + ), ( [SANTA_A], [ # Does not overlap with the UK