Skip to content

Commit bfa3aaa

Browse files
Merge pull request #49 from arup-group/infill-without-exclusivity
Allow infilling when target areas already have some required activities within them
2 parents c1b11e1 + c905646 commit bfa3aaa

8 files changed

+222
-83
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3131
### Added
3232

3333
- Activity infilling can use a geospatial point data source to fill OSM `landuse` areas, e.g. postcode data points.
34+
- Activity infilling can take place in target areas that have existing facilities, using the `max_existing_acts_fraction` argument to set the area that existing facilities can already take up in the target geometry while still allowing infilling.
3435

3536
## [v0.2.0]
3637

docs/config.md

+46-1
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ We have noted that it is not uncommon for some small areas to not have building
171171

172172
We therefore provide different methods for filling in areas, from [a very ad-hoc solution for filling with a grid of objects](#fill-with-grid-of-objects) to [a solution which relies on external source of building locations without any accompanying metadata](#fill-with-a-point-data-source).
173173

174-
These infill methods only cover areas that do not have any of the required activities already within them.
174+
These infill methods can cover areas that do not have any of the required activities already within them, or [those with a user-defined percentage of the area already occupied by required activities](#fill-in-areas-with-existing-facilities).
175175

176176
### Fill with grid of objects
177177

@@ -267,3 +267,48 @@ In this example, the point source is the UK <a href="https://osdatahub.os.uk/dow
267267

268268
!!! warning
269269
If using the `point_source` `fill_method`, the `spacing` configuration option will have no effect.
270+
271+
### Fill in areas with existing facilities
272+
273+
By default, if there are any of the required activities in a target area, that area will not be infilled.
274+
You can allow for infilling in these areas by setting `max_existing_acts_fraction`.
275+
This is the fraction of a target area that can be taken up by existing required activities while still allowing infilling.
276+
A value of 0.05 would allow target areas with up to 5% of the land area occupied by required activities to still be infilled.
277+
278+
!!! note
279+
This may become messy at high fractions as the infilling will cover the whole area, including parts where there are existing required activities.
280+
281+
```json
282+
{
283+
...
284+
"fill_missing_activities":
285+
[
286+
{
287+
"area_tags": [["landuse", "residential"]],
288+
"required_acts": ["home"],
289+
"new_tags": [["building", "house"]],
290+
"size": [10, 10],
291+
"fill_method": "spacing",
292+
"spacing": [25, 25],
293+
"max_existing_acts_fraction": 0.05
294+
}
295+
]
296+
}
297+
```
298+
299+
In the following images we can see two areas that require setting a `max_existing_acts_fraction` above zero to accomplish infilling.
300+
In the first, almost all of the area required infilling.
301+
In the second, some of the area was already captured by OSM data, but infilling proved to still be necessary to fill the remainder of the area.
302+
303+
<figure>
304+
<img src="../resources/activity-fill-5pc.png", width="100%", style="background-color:white;", alt="Suffolk data point missing activity fill allowing infilling where up to 5% of the area is already occupied">
305+
<figcaption>Example of filling missing activities for a residential area in Suffolk, UK with a small number of existing "home" activities in that area.
306+
In this example, the point source infill method was used.</figcaption>
307+
</figure>
308+
309+
<figure>
310+
<img src="../resources/activity-fill-5pc-overlap.png", width="100%", style="background-color:white;", alt="Suffolk data point missing activity fill allowing infilling where up to 5% of the area is already occupied">
311+
<figcaption>Example of filling missing activities for a residential area in Suffolk, UK with a clear overlap between infill and existing "home" activities in that area.
312+
Even though there is overlap, the infilling is worthwhile as there are many missing points.
313+
In this example, the point source infill method was used.</figcaption>
314+
</figure>
364 KB
Loading

resources/activity-fill-5pc.png

238 KB
Loading

src/osmox/build.py

+64-20
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import logging
2-
from collections import namedtuple
3-
from typing import Literal, Optional
2+
from collections import defaultdict, namedtuple
3+
from typing import Literal, Optional, Union
44

55
import geopandas as gp
6+
import numpy as np
67
import osmium
78
import pandas as pd
89
import shapely.wkb as wkblib
910
from pyproj import CRS, Transformer
10-
from shapely.geometry import MultiPoint
11+
from shapely.geometry import MultiPoint, Polygon
1112
from shapely.ops import nearest_points, transform
1213

1314
from osmox import helpers
@@ -358,19 +359,20 @@ def assign_activities(self):
358359
def fill_missing_activities(
359360
self,
360361
area_tags: tuple = ("landuse", "residential"),
361-
required_acts: str = "home",
362+
required_acts: Union[str, list[str]] = "home",
362363
new_tags: tuple = ("building", "house"),
363364
size: tuple[int, int] = (10, 10),
365+
max_existing_acts_fraction: float = 0.0,
364366
fill_method: Literal["spacing", "point_source"] = "spacing",
365367
point_source: Optional[str] = None,
366368
spacing: Optional[tuple[int, int]] = (25, 25),
367369
) -> tuple[int, int]:
368370
"""Fill "empty" areas with new objects.
369371
370-
Empty areas are defined as areas with the select_tags but not containing any objects of the required_acts.
372+
Empty areas are defined as areas with the select_tags but containing no / a maximum number of objects of the required_acts.
371373
372374
An example of such missing objects would be missing home facilities in a residential area.
373-
Empty areas are filled with new objects of given size at given spacing / using point source data.
375+
Empty areas are filled with new objects of given size based on the user-defined fill method.
374376
375377
Args:
376378
area_tags (tuple, optional):
@@ -381,16 +383,19 @@ def fill_missing_activities(
381383
Defaults to "home".
382384
new_tags (tuple, optional): Tags for new objects. Defaults to ("building", "house").
383385
size (tuple[int, int], optional):
384-
x,y dimensions of new object polygon (i.e. building footprint).
386+
x,y dimensions of new object polygon (i.e. building footprint), extending from the bottom-left.
385387
Defaults to (10, 10).
388+
max_existing_acts_fraction (float, optional):
389+
Infill target areas only if there is at most this much area already taken up by `required_acts`.
390+
Defaults to 0.0, i.e., if there are _any_ required activities already in a target area, do not infill.
386391
fill_method (Literal[spacing, point_source], optional):
387392
Method to use to distribute buildings within the tagged areas.
388393
Defaults to "spacing".
389394
point_source (Optional[str], optional):
390395
Path to geospatial data file (that can be loaded by GeoPandas) containing point source data to fill tagged areas, if using `point_source` fill method.
391396
Defaults to None.
392397
spacing (Optional[tuple[int, int]], optional):
393-
x,y dimensions of new objects centre-point spacing, if using `spacing` fill method.
398+
x,y dimensions of new object bottom-left point spacing, if using `spacing` fill method.
394399
Defaults to (25, 25).
395400
396401
Raises:
@@ -400,10 +405,12 @@ def fill_missing_activities(
400405
tuple[int, int]: A tuple of two ints representing number of empty zones, number of new objects
401406
"""
402407

403-
empty_zones = 0 # conuter for fill zones
408+
empty_zones = 0 # counter for fill zones
404409
i = 0 # counter for object id
405410
new_osm_tags = [OSMTag(key=k, value=v) for k, v in area_tags]
406411
new_tags = [OSMTag(key=k, value=v) for k, v in new_tags]
412+
if not isinstance(required_acts, list):
413+
required_acts = [required_acts]
407414

408415
if fill_method == "point_source":
409416
if point_source is None:
@@ -412,23 +419,24 @@ def fill_missing_activities(
412419
)
413420
gdf_point_source = helpers.read_geofile(point_source).to_crs(self.crs)
414421

415-
for area in helpers.progressBar(
422+
for target_area in helpers.progressBar(
416423
self.areas, prefix="Progress:", suffix="Complete", length=50
417424
):
418-
419-
if not helpers.tag_match(a=area_tags, b=area.activity_tags):
425+
geom = target_area.geom
426+
if not helpers.tag_match(a=area_tags, b=target_area.activity_tags):
420427
continue
421428

422-
if self.required_activities_in_target(required_acts, area.geom):
429+
area_of_acts_in_target = self._required_activities_in_target(required_acts, geom, size)
430+
if area_of_acts_in_target / geom.area > max_existing_acts_fraction:
423431
continue
424432

425433
empty_zones += 1 # increment another empty zone
426434

427435
# sample a grid
428436
if fill_method == "spacing":
429-
points = helpers.area_grid(area=area.geom, spacing=spacing)
437+
points = helpers.area_grid(area=geom, spacing=spacing)
430438
elif fill_method == "point_source":
431-
available_points = gdf_point_source[gdf_point_source.intersects(area.geom)].geometry
439+
available_points = gdf_point_source[gdf_point_source.intersects(geom)].geometry
432440
points = [i for i in zip(available_points.x, available_points.y)]
433441
for point in points: # add objects built from grid
434442
self.objects.auto_insert(
@@ -438,14 +446,50 @@ def fill_missing_activities(
438446

439447
return empty_zones, i
440448

441-
def required_activities_in_target(self, required_activities, target):
442-
found_activities = self.activities_from_area_intersection(target)
443-
return set(required_activities) & found_activities # in both
449+
def _required_activities_in_target(
450+
self, required_activities: list[str], target: Polygon, size: tuple[float, float]
451+
) -> float:
452+
"""Get total area occupied by existing required activities in target area.
453+
454+
If necessary, create activity polygons from points using infill polygon size.
455+
456+
Args:
457+
required_activities (list[str]): Activities whose geometries will be kept.
458+
target (Polygon): Target area in which to find activities.
459+
size (tuple[float, float]): Assumed size of geometries if only available as points.
460+
461+
Returns:
462+
float: Total area occupied by existing required activities in target area.
463+
"""
464+
found_activities = self._activities_from_area_intersection(target, size)
465+
relevant_activity_area = sum(
466+
v for k, v in found_activities.items() if k in required_activities
467+
)
468+
return relevant_activity_area
469+
470+
def _activities_from_area_intersection(
471+
self, target: Polygon, default_size: tuple[float, float]
472+
) -> dict[str, float]:
473+
"""Calculate footprint of all facilities matching an activity in a target area.
474+
475+
Args:
476+
target (Polygon): Target area in which to find facilities.
477+
default_size (tuple[float, float]): x, y dimensions of a default facility polygon, to infill any point activities.
444478
445-
def activities_from_area_intersection(self, target):
479+
Returns:
480+
dict[str, float]: Total area footprint of each activity in target area.
481+
"""
446482
objects = self.objects.intersection(target.bounds)
447483
objects = [o for o in objects if target.contains(o.geom)]
448-
return set([act for object in objects for act in object.activities])
484+
activity_polys = defaultdict(float)
485+
default_area = np.prod(default_size)
486+
for object in objects:
487+
obj_area = object.geom.area
488+
if obj_area == 0:
489+
obj_area = default_area
490+
for act in object.activities:
491+
activity_polys[act] += obj_area
492+
return activity_polys
449493

450494
def add_features(self):
451495
"""

src/osmox/helpers.py

+8-3
Original file line numberDiff line numberDiff line change
@@ -186,15 +186,20 @@ def area_grid(area, spacing):
186186

187187

188188
def fill_object(i, point, size, new_osm_tags, new_tags, required_acts):
189-
dx, dy = size[0], size[1]
190-
x, y = point
191-
geom = Polygon([(x, y), (x + dx, y), (x + dx, y + dy), (x, y + dy), (x, y)])
189+
geom = point_to_poly(point, size)
192190
idx = f"fill_{i}"
193191
object = build.Object(idx=idx, osm_tags=new_osm_tags, activity_tags=new_tags, geom=geom)
194192
object.activities = list(required_acts)
195193
return object
196194

197195

196+
def point_to_poly(point: tuple[float, float], size: tuple[float, float]) -> Polygon:
197+
dx, dy = size[0], size[1]
198+
x, y = point
199+
geom = Polygon([(x, y), (x + dx, y), (x + dx, y + dy), (x, y + dy), (x, y)])
200+
return geom
201+
202+
198203
def path_leaf(filepath):
199204
folder_path = Path(filepath).parent
200205
return folder_path

0 commit comments

Comments
 (0)