Skip to content

Commit

Permalink
feat: single-class (or landscape-level) zonal gdf
Browse files Browse the repository at this point in the history
  • Loading branch information
martibosch committed Apr 30, 2024
1 parent a47f333 commit d480243
Show file tree
Hide file tree
Showing 3 changed files with 89 additions and 92 deletions.
53 changes: 10 additions & 43 deletions pylandstats/spatiotemporal.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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__(
Expand Down Expand Up @@ -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,
Expand Down
91 changes: 54 additions & 37 deletions pylandstats/zonal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
37 changes: 25 additions & 12 deletions tests/test_pylandstats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(),
)

Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down

0 comments on commit d480243

Please sign in to comment.