Skip to content

Commit

Permalink
Merge branch 'main' of https://github.com/wri/cities-cif into CIF-234…
Browse files Browse the repository at this point in the history
…-veg-water-change
  • Loading branch information
weiqi-tori committed Jan 10, 2025
2 parents 6adc0d3 + aa6bdbc commit 215600a
Show file tree
Hide file tree
Showing 9 changed files with 60 additions and 14 deletions.
7 changes: 6 additions & 1 deletion city_metrix/layers/layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ class LayerGroupBy:
def __init__(self, aggregate, zones, layer=None, masks=[]):
self.aggregate = aggregate
self.masks = masks
self.zones = zones.reset_index()
self.zones = zones.reset_index(drop=True)
self.layer = layer

def mean(self):
Expand All @@ -136,6 +136,9 @@ def mean(self):
def count(self):
return self._zonal_stats("count")

def sum(self):
return self._zonal_stats("sum")

def _zonal_stats(self, stats_func):
if box(*self.zones.total_bounds).area <= MAX_TILE_SIZE_DEGREES**2:
stats = self._zonal_stats_tile(self.zones, [stats_func])
Expand Down Expand Up @@ -315,6 +318,8 @@ def _aggregate_stats(df, stats_func):
elif stats_func == "mean":
# mean must weight by number of pixels used for each tile
return (df["mean"] * df["count"]).sum() / df["count"].sum()
elif stats_func == "sum":
return df["sum"].sum()


def get_stats_funcs(stats_func):
Expand Down
19 changes: 13 additions & 6 deletions city_metrix/layers/open_street_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@


class OpenStreetMapClass(Enum):
# ALL includes all 29 primary features https://wiki.openstreetmap.org/wiki/Map_features
ALL = {'aerialway': True, 'aeroway': True, 'amenity': True, 'barrier': True, 'boundary': True,
'building': True, 'craft': True, 'emergency': True, 'geological': True, 'healthcare': True,
'highway': True, 'historic': True, 'landuse': True, 'leisure': True, 'man_made': True,
'military': True, 'natural': True, 'office': True, 'place': True, 'power': True,
'public_transport': True, 'railway': True, 'route': True, 'shop': True, 'sport': True,
'telecom': True, 'tourism': True, 'water': True, 'waterway': True}
OPEN_SPACE = {'leisure': ['park', 'nature_reserve', 'common', 'playground', 'pitch', 'track'],
'boundary': ['protected_area', 'national_park']}
OPEN_SPACE_HEAT = {'leisure': ['park', 'nature_reserve', 'common', 'playground', 'pitch', 'garden', 'golf_course', 'dog_park', 'recreation_ground', 'disc_golf_course'],
Expand All @@ -23,7 +30,7 @@ class OpenStreetMapClass(Enum):
'building': ['office', 'commercial', 'industrial', 'retail', 'supermarket'],
'shop': True}
SCHOOLS = {'building': ['school',],
'amenity': ['school', 'kindergarten']}
'amenity': ['school', 'kindergarten']}
HIGHER_EDUCATION = {'amenity': ['college', 'university'],
'building': ['college', 'university']}
TRANSIT_STOP = {'amenity':['ferry_terminal'],
Expand All @@ -35,19 +42,19 @@ class OpenStreetMapClass(Enum):


class OpenStreetMap(Layer):
def __init__(self, osm_class=None, **kwargs):
def __init__(self, osm_class=OpenStreetMapClass.ALL, **kwargs):
super().__init__(**kwargs)
self.osm_class = osm_class

def get_data(self, bbox):
north, south, east, west = bbox[3], bbox[1], bbox[0], bbox[2]
left, top, right, bottom = bbox
# Set the OSMnx configuration to disable caching
ox.settings.use_cache = False
try:
osm_feature = ox.features_from_bbox(bbox=(north, south, east, west), tags=self.osm_class.value)
osm_feature = ox.features_from_bbox(bbox=(left, bottom, right, top), tags=self.osm_class.value)
# When no feature in bbox, return an empty gdf
except ox._errors.InsufficientResponseError as e:
osm_feature = gpd.GeoDataFrame(pd.DataFrame(columns=['osmid', 'geometry']+list(self.osm_class.value.keys())), geometry='geometry')
osm_feature = gpd.GeoDataFrame(pd.DataFrame(columns=['id', 'geometry']+list(self.osm_class.value.keys())), geometry='geometry')
osm_feature.crs = "EPSG:4326"

# Filter by geo_type
Expand All @@ -62,7 +69,7 @@ def get_data(self, bbox):
osm_feature = osm_feature[osm_feature.geom_type.isin(['Polygon', 'MultiPolygon'])]

# keep only columns desired to reduce file size
keep_col = ['osmid', 'geometry']
keep_col = ['id', 'geometry']
for key in self.osm_class.value:
if key in osm_feature.columns:
keep_col.append(key)
Expand Down
1 change: 1 addition & 0 deletions city_metrix/metrics/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .urban_open_space import urban_open_space
from .natural_areas import natural_areas
from .era_5_met_preprocessing import era_5_met_preprocessing
from .recreational_space_per_capita import recreational_space_per_capita
from .vegetation_water_change import vegetation_water_change_gain_area
from .vegetation_water_change import vegetation_water_change_loss_area
from .vegetation_water_change import vegetation_water_change_gain_loss_ratio
16 changes: 16 additions & 0 deletions city_metrix/metrics/recreational_space_per_capita.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from geopandas import GeoDataFrame, GeoSeries

from city_metrix.layers import WorldPop, OpenStreetMap, OpenStreetMapClass


def recreational_space_per_capita(zones: GeoDataFrame, spatial_resolution=100) -> GeoSeries:
world_pop = WorldPop(spatial_resolution=spatial_resolution)
open_space = OpenStreetMap(osm_class=OpenStreetMapClass.OPEN_SPACE)

# per 1000 people
world_pop_sum = world_pop.groupby(zones).sum() / 1000
# convert square meter to hectare
open_space_counts = open_space.mask(world_pop).groupby(zones).count()
open_space_area = open_space_counts.fillna(0) * spatial_resolution ** 2 / 10000

return open_space_area / world_pop_sum
5 changes: 3 additions & 2 deletions environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ dependencies:
- python=3.10
- earthengine-api=0.1.411
- geocube=0.4.2
- geopandas=0.14.4
- geopandas=1.0.1
- gdal=3.10.0
- xarray=2024.7.0
- rioxarray=0.15.0
- odc-stac=0.3.8
Expand All @@ -14,7 +15,7 @@ dependencies:
- xarray-spatial=0.3.7
- xee=0.0.15
- utm=0.7.0
- osmnx=1.9.0
- osmnx=2.0.0
- dask[complete]=2023.11.0
- matplotlib=3.8.2
- jupyterlab=4.0.10
Expand Down
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
"xee",
"rioxarray",
"utm",
"osmnx",
"osmnx>=2.0.0",
"geopandas",
"xarray",
"s3fs",
"dask>=2023.11.0",
"boto3",
Expand Down
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
from city_metrix.layers.layer import create_fishnet_grid
from geocube.api.core import make_geocube

# EXECUTE_IGNORED_TESTS is the master control for whether to execute tests decorated with
# pytest.mark.skipif. These tests are temporarily ignored due to some unresolved issue.
# Setting EXECUTE_IGNORED_TESTS to True turns on code execution. This should be done for local testing.
# The value must be set to False when pushing to GitHub since the ignored tests would otherwise fail
# in GitHub Actions.
EXECUTE_IGNORED_TESTS = False

def test_create_fishnet_grid(min_x, min_y, max_x, max_y, cell_size):
# Slightly reduce aoi to avoid partial cells
reduction = 0.000001
Expand Down
5 changes: 3 additions & 2 deletions tests/test_layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
VegetationWaterMap,
WorldPop
)
from tests.conftest import EXECUTE_IGNORED_TESTS
from tests.resources.bbox_constants import BBOX_BRA_LAURO_DE_FREITAS_1

# Tests are implemented for the same bounding box in Brazil.
Expand All @@ -54,12 +55,12 @@ def test_built_up_height():
data = BuiltUpHeight().get_data(BBOX)
assert np.size(data) > 0

@pytest.mark.skip(reason="CDS API needs personal access token file to run")
@pytest.mark.skipif(EXECUTE_IGNORED_TESTS == False, reason="CDS API needs personal access token file to run")
def test_cams():
data = Cams().get_data(BBOX)
assert np.size(data) > 0

@pytest.mark.skip(reason="CDS API needs personal access token file to run")
@pytest.mark.skipif(EXECUTE_IGNORED_TESTS == False, reason="CDS API needs personal access token file to run")
def test_era_5_hottest_day():
data = Era5HottestDay().get_data(BBOX)
assert np.size(data) > 0
Expand Down
11 changes: 9 additions & 2 deletions tests/test_metrics.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from city_metrix import *
from .conftest import ZONES
from .conftest import ZONES, EXECUTE_IGNORED_TESTS
import pytest


Expand All @@ -24,7 +24,7 @@ def test_built_land_without_tree_cover():
assert expected_zone_size == actual_indicator_size


@pytest.mark.skip(reason="CDS API needs personal access token file to run")
@pytest.mark.skipif(EXECUTE_IGNORED_TESTS == False, reason="CDS API needs personal access token file to run")
def test_era_5_met_preprocess():
indicator = era_5_met_preprocessing(ZONES)
assert len(indicator) == 24
Expand All @@ -44,6 +44,13 @@ def test_natural_areas():
assert expected_zone_size == actual_indicator_size


def test_recreational_space_per_capita():
indicator = recreational_space_per_capita(ZONES)
expected_zone_size = ZONES.geometry.size
actual_indicator_size = indicator.size
assert expected_zone_size == actual_indicator_size


def test_urban_open_space():
indicator = urban_open_space(ZONES)
expected_zone_size = ZONES.geometry.size
Expand Down

0 comments on commit 215600a

Please sign in to comment.