diff --git a/pylandstats/spatiotemporal.py b/pylandstats/spatiotemporal.py index 8889196..fd90374 100644 --- a/pylandstats/spatiotemporal.py +++ b/pylandstats/spatiotemporal.py @@ -1,7 +1,6 @@ """Spatio-temporal analysis.""" import functools -import geopandas as gpd import matplotlib.pyplot as plt import numpy as np import pandas as pd @@ -136,7 +135,7 @@ def compute_landscape_metrics_df( # noqa: D102 # ax.hist() -class SpatioTemporalZonalAnalysis(SpatioTemporalAnalysis): +class SpatioTemporalZonalAnalysis(SpatioTemporalAnalysis, zonal.ZonalAnalysis): """Spatio-temporal zonal analysis.""" def __init__( @@ -268,50 +267,18 @@ def compute_landscape_metrics_df( # noqa: D102 ) ) - def compute_zonal_statistics_gdf( - self, metrics, *, class_val=None, metrics_kws=None + def compute_zonal_statistics_gdf( # noqa: D102 + self, *, metrics=None, class_val=None, metrics_kws=None ): - """Compute the zonal statistics geo-data frame over the landscape raster. - - Parameters - ---------- - metrics : list-like, optional - A list-like of strings with the names of the metrics that should be - computed. If `None`, all the implemented class-level metrics will be - computed. - class_val : int, optional - If provided, the zonal statistics will be computed at the level of the - corresponding class, otherwise they will be computed at the landscape level. - metrics_kws : dict, optional - Dictionary mapping the keyword arguments (values) that should be passed to - each metric method (key), e.g., to exclude the boundary from the computation - of `total_edge`, metric_kws should map the string 'total_edge' (method name) - to {'count_boundary': False}. If `None`, each metric will be computed - according to FRAGSTATS defaults. - - Returns - ------- - zonal_statistics_gdf : geopandas.GeoDataFrame - Geo-data frame with the computed zonal statistics. - """ - if class_val is None: - metrics_df = self.compute_landscape_metrics_df( - metrics=metrics, metrics_kws=metrics_kws - ) - else: - metrics_df = self.compute_class_metrics_df( - metrics=metrics, classes=[class_val], metrics_kws=metrics_kws - ) + return super().compute_zonal_statistics_gdf( + metrics=metrics, class_val=class_val, metrics_kws=metrics_kws + ) - zone_col = self.zone_gser.index.name - return gpd.GeoDataFrame( - # first set zone as outermost index - metrics_df.reset_index().set_index( - [zone_col] + metrics_df.index.names.difference([zone_col]) - ), - geometry=metrics_df.reset_index()[zone_col].map(self.zone_gser).values, - crs=self.zone_gser.crs, + compute_zonal_statistics_gdf.__doc__ = ( + zonal._compute_zonal_statistics_gdf_doc.format( + col_return="metrics and dates (multi-index)" ) + ) def plot_metric( # noqa: D102 self, diff --git a/pylandstats/zonal.py b/pylandstats/zonal.py index 72e451c..bc321ad 100644 --- a/pylandstats/zonal.py +++ b/pylandstats/zonal.py @@ -14,6 +14,35 @@ __all__ = ["ZonalAnalysis", "BufferAnalysis", "ZonalGridAnalysis"] +_compute_zonal_statistics_gdf_doc = """ +Compute the zonal statistics geo-data frame over the landscape raster. + +Parameters +---------- +metrics : list-like, optional + A list-like of strings with the names of the metrics that should be computed. If + `None`, all the implemented metrics at the specified level will be computed. +level : {{'class', 'landscape'}}, optional + Whether the metrics should be computed at the class or landscape level. If `None`, + the metrics will be computed (a) at the class level when a non-None `classes` is + provided, otherwise (b) at the landscape level. +class_val : int, optional + If provided, the metric will be computed at the level of the corresponding class, + otherwise it will be computed at the landscape level. +metrics_kws : dict, optional + Dictionary mapping the keyword arguments (values) that should be passed to + each metric method (key), e.g., to exclude the boundary from the computation + of `total_edge`, metric_kws should map the string 'total_edge' (method name) + to {{'count_boundary': False}}. If `None`, each metric will be computed + according to FRAGSTATS defaults. + +Returns +------- +zonal_statistics_gdf : geopandas.GeoDataFrame + Geo-data frame with the computed zonal statistics, with the zones as rows and + {col_return} as columns. +""" + class ZonalAnalysis(multilandscape.MultiLandscape): """Zonal analysis.""" @@ -120,8 +149,8 @@ def __init__( zones = zones.geometry else: # take just the "geometry" column, treat `zones` as GeoSeries but - # rename it to "zone" - zones = zones.geometry.rename("zone") + # rename the index to "zone" + zones = zones.geometry.rename_axis("zone") # at this point, `zones` must be a geo-series or a list-like of shapely # geometries @@ -189,51 +218,39 @@ def __init__( self.zone_gser.index.values, ) - def compute_zonal_statistics_gdf( - self, metrics, *, class_val=None, metrics_kws=None + def compute_zonal_statistics_gdf( # noqa: D102 + self, + *, + metrics=None, + class_val=None, + metrics_kws=None, ): - """Compute the zonal statistics geo-data frame over the landscape raster. - - Parameters - ---------- - metrics : list-like, optional - A list-like of strings with the names of the metrics that should be - computed. If `None`, all the implemented class-level metrics will be - computed. - class_val : int, optional - If provided, the zonal statistics will be computed at the level of the - corresponding class, otherwise they will be computed at the landscape level. - metrics_kws : dict, optional - Dictionary mapping the keyword arguments (values) that should be passed to - each metric method (key), e.g., to exclude the boundary from the computation - of `total_edge`, metric_kws should map the string 'total_edge' (method name) - to {'count_boundary': False}. If `None`, each metric will be computed - according to FRAGSTATS defaults. - - Returns - ------- - zonal_statistics_gdf : geopandas.GeoDataFrame - Geo-data frame with the computed zonal statistics. - """ - if class_val is None: - zonal_metrics_df = self.compute_landscape_metrics_df( - metrics=metrics, metrics_kws=metrics_kws - ) - else: + if class_val is not None: zonal_metrics_df = self.compute_class_metrics_df( metrics=metrics, classes=[class_val], metrics_kws=metrics_kws + ).loc[class_val] + else: + zonal_metrics_df = self.compute_landscape_metrics_df( + metrics=metrics, metrics_kws=metrics_kws ) + # ensure that we have numeric types (not strings) # metric_ser = pd.to_numeric(metric_ser) + # return a geo-data frame + zone_name = self.zone_gser.index.name return gpd.GeoDataFrame( - zonal_metrics_df, - geometry=zonal_metrics_df.reset_index()[self.landscape_ser.index.name] - .map(self.zone_gser) - .values, - crs=self.zone_gser.crs, + zonal_metrics_df.pivot_table( + index=zone_name, + columns=zonal_metrics_df.index.names.difference([zone_name]), + ), + geometry=self.zone_gser, ) + compute_zonal_statistics_gdf.__doc__ = _compute_zonal_statistics_gdf_doc.format( + col_return="metrics" + ) + class BufferAnalysis(ZonalAnalysis): """Buffer analysis around a feature of interest.""" diff --git a/tests/test_pylandstats.py b/tests/test_pylandstats.py index 88b85a4..9ea5592 100644 --- a/tests/test_pylandstats.py +++ b/tests/test_pylandstats.py @@ -957,7 +957,9 @@ def test_compute_zonal_statistics_gdf(self): # geometry column) for class_val in [None, za.present_classes[0]]: metrics = ["patch_density"] - zs_gdf = za.compute_zonal_statistics_gdf(metrics, class_val=class_val) + zs_gdf = za.compute_zonal_statistics_gdf( + metrics=metrics, class_val=class_val + ) self.assertEqual(zs_gdf.shape, (len(self.zone_gdf), len(metrics) + 1)) # test that the crs is set correctly self.assertEqual(zs_gdf.crs, self.zone_gdf.crs) @@ -969,11 +971,13 @@ def test_compute_zonal_statistics_gdf(self): metric = "total_edge" metric_kws = {"count_boundary": True} self.assertLessEqual( - za.compute_zonal_statistics_gdf([metric], class_val=class_val)[ + za.compute_zonal_statistics_gdf(metrics=[metric], class_val=class_val)[ metric ].sum(), za.compute_zonal_statistics_gdf( - [metric], class_val=class_val, metrics_kws={metric: metric_kws} + metrics=[metric], + class_val=class_val, + metrics_kws={metric: metric_kws}, )[metric].sum(), ) @@ -1274,11 +1278,14 @@ def test_compute_zonal_statistics_gdf(self): # + geometry column) for class_val in [None, stza.present_classes[0]]: metrics = ["patch_density"] - zs_gdf = stza.compute_zonal_statistics_gdf(metrics, class_val=class_val) - self.assertEqual( - zs_gdf.shape, - (len(stza.zone_gser) * len(self.dates), len(metrics) + 1), + zs_gdf = stza.compute_zonal_statistics_gdf( + metrics=metrics, class_val=class_val + ) + self.assertLessEqual( + zs_gdf.shape[0], + len(stza.zone_gser), ) + self.assertEqual(zs_gdf.shape[1], len(metrics) * len(self.dates) + 1) # test that the crs is set correctly self.assertEqual(zs_gdf.crs, self.zone_gser.crs) # test that the geometry column is not None @@ -1289,12 +1296,18 @@ def test_compute_zonal_statistics_gdf(self): metric = "total_edge" metric_kws = {"count_boundary": True} self.assertLessEqual( - stza.compute_zonal_statistics_gdf([metric], class_val=class_val)[ - metric - ].sum(), stza.compute_zonal_statistics_gdf( - [metric], class_val=class_val, metrics_kws={metric: metric_kws} - )[metric].sum(), + metrics=[metric], class_val=class_val + )[metric] + .sum() + .sum(), + stza.compute_zonal_statistics_gdf( + metrics=[metric], + class_val=class_val, + metrics_kws={metric: metric_kws}, + )[metric] + .sum() + .sum(), ) def test_plot_metric(self):