Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat 3.0.0rc #39

Merged
merged 13 commits into from
Sep 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ dependencies:
- m2r2=0.3.3
- pip=23.0.1
- pip:
- pydata_sphinx_theme==0.13.3
- pydata-sphinx-theme==0.13.3
- python=3.10
- sphinx=6.1.3
1 change: 0 additions & 1 deletion docs/requirements.txt

This file was deleted.

469 changes: 246 additions & 223 deletions pylandstats/landscape.py

Large diffs are not rendered by default.

140 changes: 55 additions & 85 deletions pylandstats/multilandscape.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
import numpy as np
import pandas as pd

from . import landscape as pls_landscape
from . import settings
from .landscape import Landscape

_compute_class_metrics_df_doc = """
Compute the data frame of class-level metrics, which is {index_descr}.
Expand Down Expand Up @@ -83,12 +83,11 @@ def __init__(self, landscapes, attribute_name, attribute_values, **landscape_kws
`pylandstats.Landscape` for each element of `landscapes`. Ignored if the
elements of `landscapes` are already instances of `pylandstats.Landcape`.
"""
if isinstance(landscapes[0], pls_landscape.Landscape):
if isinstance(landscapes[0], Landscape):
self.landscapes = landscapes
else:
self.landscapes = [
pls_landscape.Landscape(landscape, **landscape_kws)
for landscape in landscapes
Landscape(landscape, **landscape_kws) for landscape in landscapes
]

if len(self.landscapes) != len(attribute_values):
Expand All @@ -98,15 +97,13 @@ def __init__(self, landscapes, attribute_name, attribute_values, **landscape_kws
)
)

# set a `attribute_name` attribute with the value `attribute_values`,
# so that children classes can access it (e.g., for
# `SpatioTemporalAnalysis`, `attribute_name` will be 'dates' and
# `attribute_values` will be a list of dates that will therefore be
# accessible as an attribute as in `instance.dates`
# set a `attribute_name` attribute with the value `attribute_values`, so that
# children classes can access it (e.g., for `SpatioTemporalAnalysis`,
# `attribute_name` will be 'dates' and `attribute_values` will be a list of
# dates that will therefore be accessible as an attribute as in `instance.dates`
setattr(self, attribute_name, attribute_values)
# also set a `attribute_name` attribute so that the methods of this
# class know how to access such attribute, i.e., as in
# `getattr(self, self.attribute_name)`
# also set a `attribute_name` attribute so that the methods of this class know
# how to access such attribute, i.e., as in `getattr(self, self.attribute_name)`
setattr(self, "attribute_name", attribute_name)

# get the all classes present in the provided landscapes
Expand All @@ -115,13 +112,12 @@ def __init__(self, landscapes, attribute_name, attribute_values, **landscape_kws
tuple(landscape.classes for landscape in self.landscapes),
)

# fillna for metrics in class metrics dataframes. Since some classes might
# not apprear in some of the landscapes (e.g., zones or temporal snapshots
# without any pixel of a particular class type), they will appear as `NaN`
# in the data frame. We can, however, infer the meaning of this situation
# for certain metrics, e.g, non-occurence of a given class in a landscape
# means a number of patches, total area, proportion of landscape, total
# edge... of the class of 0
# fillna for metrics in class metrics dataframes. Since some classes might not
# apprear in some of the landscapes (e.g., zones or temporal snapshots without any
# pixel of a particular class type), they will appear as `NaN` in the data frame. We
# can, however, infer the meaning of this situation for certain metrics, e.g,
# non-occurence of a given class in a landscape means a number of patches, total
# area, proportion of landscape, total edge... of the class of 0
METRIC_FILLNA_DICT = {
metric: 0
for metric in [
Expand All @@ -144,13 +140,13 @@ def __len__(self): # noqa: D105
return len(self.landscapes)

def compute_class_metrics_df( # noqa: D102
self, metrics=None, classes=None, metrics_kws=None, fillna=None
self, *, metrics=None, classes=None, metrics_kws=None, fillna=None
):
attribute_values = getattr(self, self.attribute_name)

# get the columns to init the data frame
if metrics is None:
columns = pls_landscape.Landscape.CLASS_METRICS
columns = Landscape.CLASS_METRICS
else:
columns = metrics
# if the classes kwarg is not provided, get the classes present in the
Expand All @@ -160,19 +156,18 @@ def compute_class_metrics_df( # noqa: D102
# to avoid issues with mutable defaults
if metrics_kws is None:
metrics_kws = {}
# to avoid setting the same default keyword argument in multiple
# methods, use the settings module
# to avoid setting the same default keyword argument in multiple methods, use
# the settings module
if fillna is None:
fillna = settings.CLASS_METRICS_DF_FILLNA

# IMPORTANT: here we need this approach (uglier when compared to the
# `compute_landscape_metrics_df` method below) because we need to
# filter each class metrics data frame so that we only include the
# classes considered in this `MultiLandscape` instance. We need to do
# it like this because the `Landcape.compute_class_metrics_df` does
# not have a `classes` argument that allows computing the data frame
# only for a custom set of classes. Should such `classes` argument be
# added at some point, we could use the approach of the
# `compute_landscape_metrics_df` method below) because we need to filter each
# class metrics data frame so that we only include the classes considered in
# this `MultiLandscape` instance. We need to do it like this because the
# `Landcape.compute_class_metrics_df` does not have a `classes` argument that
# allows computing the data frame only for a custom set of classes. Should such
# `classes` argument be added at some point, we could use the approach of the
# `compute_landscape_metrics_df` method below.
# TODO: one-level index if only one class?
class_metrics_df = pd.DataFrame(
Expand All @@ -184,16 +179,15 @@ def compute_class_metrics_df( # noqa: D102
class_metrics_df.columns.name = "metric"

for attribute_value, landscape in zip(attribute_values, self.landscapes):
# get the class metrics DataFrame for the landscape that
# corresponds to this attribute value
# get the class metrics DataFrame for the landscape that corresponds to this
# attribute value
df = landscape.compute_class_metrics_df(
metrics=metrics, metrics_kws=metrics_kws
)
# filter so we only check the classes considered in this
# `MultiLandscape` instance
# filter so we only check the classes considered in this `MultiLandscape`
# instance
df = df.loc[df.index.intersection(classes)]
# put every row of the filtered DataFrame of this particular
# attribute value
# put every row of the filtered DataFrame of this particular attribute value
for class_val, row in df.iterrows():
class_metrics_df.loc[(class_val, attribute_value), columns] = row

Expand All @@ -210,13 +204,13 @@ def compute_class_metrics_df( # noqa: D102
)

def compute_landscape_metrics_df( # noqa: D102
self, metrics=None, metrics_kws=None
self, *, metrics=None, metrics_kws=None
):
attribute_values = getattr(self, self.attribute_name)

# get the columns to init the data frame
if metrics is None:
columns = pls_landscape.Landscape.LANDSCAPE_METRICS
columns = Landscape.LANDSCAPE_METRICS
else:
columns = metrics
# to avoid issues with mutable defaults
Expand All @@ -236,7 +230,7 @@ def compute_landscape_metrics_df( # noqa: D102
landscape_metrics_df.loc[
attribute_value, columns
] = landscape.compute_landscape_metrics_df(
metrics, metrics_kws=metrics_kws
metrics=metrics, metrics_kws=metrics_kws
).iloc[
0
]
Expand All @@ -251,6 +245,7 @@ def compute_landscape_metrics_df( # noqa: D102
def plot_metric(
self,
metric,
*,
class_val=None,
ax=None,
metric_legend=True,
Expand Down Expand Up @@ -294,60 +289,34 @@ def plot_metric(
ax : matplotlib.axes.Axes
Returns the `Axes` object with the plot drawn onto it.
"""
# TODO: metric_legend parameter acepting a set of str values
# indicating, e.g., whether the metric label should appear as legend
# or as yaxis label
# TODO: if we use seaborn in the future, we can use the pd.Series
# directly, since its index corresponds to this SpatioTemporalAnalysis
# dates
# TODO: metric_legend parameter acepting a set of str values indicating, e.g.,
# whether the metric label should appear as legend or as yaxis label
# TODO: if we use seaborn in the future, we can use the pd.Series directly,
# since its index corresponds to this SpatioTemporalAnalysis dates
if metric_kws is None:
metric_kws = {}
# since we are using the compute data frame methods even though we are just
# computing a single metric (so that error management regarding the computation
# of metrics is defined in a single place), we need to provide the `metrics_kws`
# (mapping a metric to its keyword-arguments `metric_kws`).
metrics_kws = {metric: metric_kws}
metrics = [metric]
if class_val is None:
try:
metric_values = [
getattr(landscape, metric)(**metric_kws)
for landscape in self.landscapes
]
except AttributeError:
raise ValueError(
"{metric} is not among {metrics}".format(
metric=metric,
metrics=pls_landscape.Landscape.CLASS_METRICS,
)
)
except TypeError:
raise ValueError(
"{metric} cannot be computed at the landscape level".format(
metric=metric
)
)
metric_values = self.compute_landscape_metrics_df(
metrics=metrics, metrics_kws=metrics_kws
).values
else:
try:
metric_values = [
getattr(landscape, metric)(class_val=class_val, **metric_kws)
for landscape in self.landscapes
]
except AttributeError:
raise ValueError(
"{metric} is not among {metrics}".format(
metric=metric,
metrics=pls_landscape.Landscape.LANDSCAPE_METRICS,
)
)
except TypeError:
raise ValueError(
"{metric} cannot be computed at the class level".format(
metric=metric
)
)
metric_values = self.compute_class_metrics_df(
metrics=metrics, classes=[class_val], metrics_kws=metrics_kws
).values

if ax is None:
if subplots_kws is None:
subplots_kws = {}
fig, ax = plt.subplots(**subplots_kws)

# for `SpatioTemporalAnalysis`, `attribute_values` will be `dates`;
# for `BufferAnalysis`, `attribute_values` will be `buffer_dists`
# for `SpatioTemporalAnalysis`, `attribute_values` will be `dates`; for
# `BufferAnalysis`, `attribute_values` will be `buffer_dists`
attribute_values = getattr(self, self.attribute_name)

if plot_kws is None:
Expand All @@ -357,8 +326,8 @@ def plot_metric(

if metric_legend:
if metric_label is None:
# get the metric label from the settings, otherwise use the
# metric method name, i.e., metric name in camel-case
# get the metric label from the settings, otherwise use the metric
# method name, i.e., metric name in camel-case
metric_label = settings.metric_label_dict.get(metric, metric)

ax.set_ylabel(metric_label)
Expand All @@ -367,6 +336,7 @@ def plot_metric(

def plot_landscapes(
self,
*,
cmap=None,
legend=True,
subplots_kws=None,
Expand Down
28 changes: 13 additions & 15 deletions pylandstats/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,11 @@
"fractal_dimension": "FRAC",
"euclidean_nearest_neighbor": "ENN",
# class-level metrics (can also be landscape-level except for PLAND)
# ACHTUNG: the 'total_area' metric might be 'CA' or 'TA' in FRAGSTATS
# (depending on whether the metric is computed at the class or landscape
# level respectively). Nevertheless, considering the implementation/
# functioning of PyLandStats, making this disctinction in the
# abbreviations of 'total_area' might be arduous. To simplify, we will use
# 'TA' in all cases.
# ACHTUNG: the 'total_area' metric might be 'CA' or 'TA' in FRAGSTATS (depending on
# whether the metric is computed at the class or landscape level respectively).
# Nevertheless, considering the implementation/functioning of PyLandStats, making
# this disctinction in the abbreviations of 'total_area' might be arduous. To
# simplify, we will use 'TA' in all cases.
"total_area": "TA",
"proportion_of_landscape": "PLAND",
"number_of_patches": "NP",
Expand All @@ -38,8 +37,8 @@
"contagion": "CONTAG",
"shannon_diversity_index": "SHDI",
}
# add the class/landscape distribution statistics metrics to the fragstats
# abbreviation dictionary
# add the class/landscape distribution statistics metrics to the fragstats abbreviation
# dictionary
for metric in [
"area",
"perimeter",
Expand All @@ -54,13 +53,12 @@
)

# SETTINGS
# TODO: is it worth integrating `metrics` and `metrics_kws` into the settings
# scheme? The main difficulty is that depending on the method, the `metrics`
# argument might concern only patch-level metrics, class-level metrics ( or
# landscape-level metrics, e.g., see the methods of the form
# `landscape.Landscape.compute_{level}_metrics_df`, where 'level' can be
# `patch`, `class` or `landscape`. On the other hand, integrating `metrics_kws`
# should be more straight-forward.
# TODO: is it worth integrating `metrics` and `metrics_kws` into the settings scheme?
# The main difficulty is that depending on the method, the `metrics` argument might
# concern only patch-level metrics, class-level metrics (or landscape-level metrics,
# e.g., see the methods of the form `landscape.Landscape.compute_{level}_metrics_df`,
# where 'level' can be `patch`, `class` or `landscape`. On the other hand, integrating
# `metrics_kws` should be more straight-forward.
metric_label_dict = environ.get("METRIC_LABEL_DICT", fragstats_abbrev_dict)

# OTHER
Expand Down
Loading
Loading