# src/fluxfootprints/representativeness_plotting.py
"""
representativeness_plotting.py
==============================
Figures for the footprint-to-target-area representativeness analysis.
Draws the four diagnostics of :mod:`~fluxfootprints.representativeness` in the
form Chu et al. (2021) publish them:
Chu, H., et al. (2021). Representativeness of Eddy-Covariance flux
footprints for areas surrounding AmeriFlux sites. *Agricultural and Forest
Meteorology*, **301-302**, 108350.
https://doi.org/10.1016/j.agrformet.2021.108350
===================================== ==========================
Function Figure in the paper
===================================== ==========================
:func:`plot_landcover_composition` Fig. 1e
:func:`plot_footprint_target_scatter` Fig. 1f, Fig. 6
:func:`plot_bias_density` Fig. 7
:func:`plot_level_bars` Fig. 5, Fig. 8
===================================== ==========================
Each takes what the analysis functions already return -- a list of
:class:`~fluxfootprints.CategoricalResult`, or the tidy frames of
:func:`~fluxfootprints.sensor_location_bias_series` and
:func:`~fluxfootprints.assess_representativeness` -- and each returns
``(fig, ax)`` without showing or saving anything, so the caller keeps control
of the title, the layout, and the output.
Colour convention
-----------------
Target-area radius is an *ordered* quantity, so it is drawn on a single-hue
sequential ramp (:data:`RADIUS_CMAP`), dark at the smallest radius and light at
the largest -- as the paper's own legends read, "from dark to light, indicating
an increasing distance from the tower". The three-level index gets its own
ordered ramp (:data:`LEVEL_CMAP`), dark for HIGH through light for LOW. The
footprint-weighted series is not a step of either sequence: it is the reference
the target areas are read against, so it wears a single contrasting accent
(:data:`FOOTPRINT_COLOR`).
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib import colormaps
from matplotlib.axes import Axes
from matplotlib.figure import Figure
from matplotlib.lines import Line2D
from scipy.stats import gaussian_kde
from .representativeness import CategoricalResult, Level, rma_regression
__all__ = [
"RADIUS_CMAP",
"LEVEL_CMAP",
"FOOTPRINT_COLOR",
"REFERENCE_COLOR",
"radius_colors",
"level_colors",
"plot_landcover_composition",
"plot_footprint_target_scatter",
"plot_bias_density",
"plot_level_bars",
]
# ------------------------------
# Style constants
# ------------------------------
#: Sequential colormap for series keyed by target-area radius, sampled
#: dark-to-light with increasing radius.
RADIUS_CMAP: str = "Oranges"
#: Sequential colormap for the three-level representativeness index, sampled
#: dark for HIGH through light for LOW.
LEVEL_CMAP: str = "Blues"
#: Accent for the footprint-weighted series, which is the reference the target
#: areas are read against rather than one more step of the radius ramp.
FOOTPRINT_COLOR: str = "#1b4f9c"
#: Ink for reference geometry -- the 1:1 line, the zero-bias rule.
REFERENCE_COLOR: str = "#767676"
#: Ink for the hairline grid, one shade off the surface.
GRID_COLOR: str = "#dcdcdc"
#: Fraction of a colormap the ramps are sampled over, darkest first. Stopping
#: short of both ends keeps the lightest step visible on white and the darkest
#: step from reading as black.
_RAMP_BAND: tuple[float, float] = (0.85, 0.38)
#: Order the three-level index is drawn in, darkest first.
_LEVEL_ORDER: tuple[Level, ...] = (Level.HIGH, Level.MEDIUM, Level.LOW)
#: Columns searched for the per-period sensor location bias, in order.
#: ``"delta"`` is what :func:`sensor_location_bias_series` names it, ``"bias"``
#: what :func:`assess_representativeness` names it.
_BIAS_COLUMNS: tuple[str, ...] = ("delta", "bias")
#: Columns searched for a three-level index, in order.
_LEVEL_COLUMNS: tuple[str, ...] = ("level", "landcover_level", "continuous_level")
# ------------------------------
# Shared helpers
# ------------------------------
def _sample_ramp(name: str, n: int) -> list[tuple[float, float, float, float]]:
"""
Sample `n` steps from a sequential colormap, darkest first.
Parameters
----------
name : str
Name of a Matplotlib colormap, e.g. ``"Oranges"``.
n : int
Number of steps. A single step takes the dark end of the band.
Returns
-------
list of tuple
RGBA tuples, dark to light.
"""
cmap = colormaps[name]
if n <= 1:
return [cmap(_RAMP_BAND[0])]
return [cmap(position) for position in np.linspace(*_RAMP_BAND, n)]
[docs]
def radius_colors(
radii: Sequence[float],
cmap: str = RADIUS_CMAP,
) -> dict[float, tuple[float, float, float, float]]:
"""
Map target-area radii onto the sequential ramp, dark to light.
The step a radius gets depends on its rank within `radii` rather than on
its value, so the ramp spans the series actually drawn and two figures over
the same series of radii agree.
Parameters
----------
radii : sequence of float
Target-area radii [m]. Sorted ascending here; duplicates collapse.
cmap : str, default RADIUS_CMAP
Name of a Matplotlib sequential colormap.
Returns
-------
dict
Radius -> RGBA, the smallest radius darkest.
See Also
--------
level_colors : The same for the three-level index.
Examples
--------
>>> sorted(radius_colors([3000, 250]))
[250.0, 3000.0]
"""
unique = sorted({float(radius) for radius in radii})
return dict(zip(unique, _sample_ramp(cmap, len(unique))))
[docs]
def level_colors(
cmap: str = LEVEL_CMAP,
) -> dict[Level, tuple[float, float, float, float]]:
"""
Map the three-level index onto its ramp, HIGH darkest.
Parameters
----------
cmap : str, default LEVEL_CMAP
Name of a Matplotlib sequential colormap.
Returns
-------
dict
:class:`~fluxfootprints.Level` -> RGBA.
See Also
--------
radius_colors : The same for target-area radii.
Examples
--------
>>> list(level_colors())
[<Level.HIGH: 'high'>, <Level.MEDIUM: 'medium'>, <Level.LOW: 'low'>]
"""
return dict(zip(_LEVEL_ORDER, _sample_ramp(cmap, len(_LEVEL_ORDER))))
def _axes(ax: Axes | None, figsize: tuple[float, float]) -> tuple[Figure, Axes]:
"""
Resolve the axes to draw on, opening a figure when none is given.
Parameters
----------
ax : matplotlib.axes.Axes or None
Axes to draw on, or None to open a new figure.
figsize : tuple of float
Size of the new figure [in], ignored when `ax` is given.
Returns
-------
tuple of (matplotlib.figure.Figure, matplotlib.axes.Axes)
"""
if ax is None:
return plt.subplots(figsize=figsize)
return ax.figure, ax
def _style(ax: Axes, axis: str = "both") -> None:
"""
Apply recessive chrome to an axes, in place.
A solid hairline grid one shade off the surface, and no top or right
spine, so that the marks rather than the frame carry the figure.
Parameters
----------
ax : matplotlib.axes.Axes
Axes to style.
axis : {'both', 'x', 'y'}, default 'both'
Which grid to draw.
"""
ax.grid(True, axis=axis, color=GRID_COLOR, linewidth=0.6)
ax.set_axisbelow(True)
for side in ("top", "right"):
ax.spines[side].set_visible(False)
for side in ("left", "bottom"):
ax.spines[side].set_linewidth(0.8)
ax.spines[side].set_color("#8c8c8c")
def _numeric(values: Any) -> np.ndarray:
"""
Coerce a column to float64, missing where it does not convert.
Handles the nullable dtypes the analysis frames carry, which
``astype(float)`` refuses once a value is missing.
Parameters
----------
values : array_like
The column, Series, or array to coerce.
Returns
-------
numpy.ndarray
Float64, ``nan`` where the input was missing or unparseable.
"""
return pd.to_numeric(pd.Series(np.asarray(values, dtype=object)), errors="coerce").to_numpy(
dtype=float
)
def _as_frame(data: pd.DataFrame, required: Sequence[str], name: str) -> pd.DataFrame:
"""
Normalise a results frame so the columns a plot needs are columns.
The tidy frame of :func:`~fluxfootprints.assess_representativeness` carries
``radius`` and the rest of :data:`~fluxfootprints.RESULT_INDEX` in its
index, while :func:`~fluxfootprints.sensor_location_bias_series` carries
them as columns. Either is accepted here by resetting any index level a
required name is hiding in.
Parameters
----------
data : pandas.DataFrame
The frame to normalise.
required : sequence of str
Names the caller needs as columns. Absent ones are reported together.
name : str
Name of the argument, for error messages.
Returns
-------
pandas.DataFrame
A frame carrying every name in `required` as a column: a reset copy
when a level had to be lifted out of the index, otherwise the input.
Raises
------
TypeError
If `data` is not a DataFrame.
ValueError
If a required name is neither a column nor an index level.
"""
if not isinstance(data, pd.DataFrame):
raise TypeError(
f"{name} must be a pandas DataFrame, not {type(data).__name__}. "
"Pass the frame from sensor_location_bias_series, "
"assess_representativeness, or representativeness_summary."
)
levels = [level for level in data.index.names if level is not None]
hidden = [
column for column in required if column not in data.columns and column in levels
]
frame = data.reset_index() if hidden else data
missing = [column for column in required if column not in frame.columns]
if missing:
raise ValueError(
f"{name} is missing the column(s) {missing}; it carries "
f"{list(frame.columns)}. Name the columns to read with the "
"*_column arguments if they go by other names here."
)
return frame
def _selected_radii(
values: Any,
radii: Sequence[float] | None,
name: str,
) -> list[float]:
"""
Resolve which radii to draw, ascending.
Parameters
----------
values : array_like
Radii present in the data, coerced to float here.
radii : sequence of float or None
The subset the caller asked for, or None for every radius present.
name : str
Name of the data argument, for error messages.
Returns
-------
list of float
The radii to draw, ascending.
Raises
------
ValueError
If the data holds no finite radius, or if a requested radius is absent.
"""
numeric = _numeric(values)
present = sorted({float(value) for value in numeric if np.isfinite(value)})
if not present:
raise ValueError(f"{name} holds no finite target-area radius to group by.")
if radii is None:
return present
wanted = sorted({float(radius) for radius in radii})
absent = [radius for radius in wanted if radius not in present]
if absent:
raise ValueError(f"radii {absent} are not in {name}, which holds {present}.")
return wanted
def _detect_column(
frame: pd.DataFrame,
candidates: Sequence[str],
explicit: str | None,
name: str,
role: str,
) -> str:
"""
Resolve a column the caller named, or find it by convention.
Parameters
----------
frame : pandas.DataFrame
Frame to search.
candidates : sequence of str
Conventional names, in search order.
explicit : str or None
The name the caller gave, which wins outright.
name : str
Name of the data argument, for error messages.
role : str
What the column holds, for error messages.
Returns
-------
str
The resolved column name.
Raises
------
ValueError
If `explicit` is absent from `frame`, or if no candidate is present.
"""
if explicit is not None:
if explicit not in frame.columns:
raise ValueError(
f"{name} has no column {explicit!r}; it carries "
f"{list(frame.columns)}."
)
return explicit
for column in candidates:
if column in frame.columns:
return column
raise ValueError(
f"{name} carries none of the {role} columns {list(candidates)}; it holds "
f"{list(frame.columns)}. Name the column to draw explicitly."
)
def _radius_label(radius: float) -> str:
"""
Render a radius as a legend entry.
Parameters
----------
radius : float
Target-area radius [m].
Returns
-------
str
e.g. ``"250 m"``.
"""
return f"{radius:g} m"
# ------------------------------
# Fig. 1e -- land-cover composition
# ------------------------------
[docs]
def plot_landcover_composition(
results: Sequence[CategoricalResult],
*,
class_labels: Mapping[Any, str] | None = None,
max_classes: int | None = None,
radii: Sequence[float] | None = None,
ax: Axes | None = None,
cmap: str = RADIUS_CMAP,
footprint_color: str = FOOTPRINT_COLOR,
figsize: tuple[float, float] = (7.0, 4.5),
) -> tuple[Figure, Axes]:
"""
Plot footprint-weighted against target-area land-cover shares, Fig. 1e.
One row per land-cover class, ordered by footprint-weighted share. The
footprint-weighted percentage is a filled circle in the accent colour, and
each target area a triangle on the radius ramp, so the drift of a class's
share as the disc widens reads as a trail away from the circle.
Parameters
----------
results : sequence of CategoricalResult
The output of :func:`~fluxfootprints.evaluate_landcover`, one entry per
radius. Every entry carries the same footprint composition -- the
footprint does not depend on the disc -- so the first entry's is drawn.
class_labels : mapping, optional
Class code -> display name, e.g. ``{41: "Deciduous forest"}``. A code
with no entry is labelled by the code itself.
max_classes : int, optional
Keep only this many classes, those with the largest share in either the
footprint or any target area. Every class present is drawn when omitted.
radii : sequence of float, optional
Draw only these radii. The ramp is still sampled over every radius in
`results`, so a subset keeps the colours of the full series.
ax : matplotlib.axes.Axes, optional
Axes to draw on. A new figure is opened when omitted.
cmap : str, default RADIUS_CMAP
Sequential colormap for the target-area series.
footprint_color : str, default FOOTPRINT_COLOR
Colour of the footprint-weighted series.
figsize : tuple of float, default (7.0, 4.5)
Size of a new figure [in]. Ignored when `ax` is given.
Returns
-------
tuple of (matplotlib.figure.Figure, matplotlib.axes.Axes)
The figure and the axes drawn on. Nothing is shown or saved.
Raises
------
ValueError
If `results` is empty, if `radii` names a radius `results` does not
hold, if `max_classes` is not positive, or if no class carries a share
in either the footprint or a target area.
See Also
--------
fluxfootprints.evaluate_landcover : Produces `results`.
plot_level_bars : The same evaluation reduced to its three-level index and
pooled across sites.
Notes
-----
Shares are the percentages :class:`~fluxfootprints.CategoricalResult`
carries, so a column of circles sums to 100 % only when `max_classes`
dropped nothing.
Examples
--------
>>> results = evaluate_landcover(model.fclim_2d, nlcd) # doctest: +SKIP
>>> fig, ax = plot_landcover_composition( # doctest: +SKIP
... results, class_labels={41: "Deciduous forest", 82: "Cropland"}
... )
>>> fig.savefig("landcover.png", dpi=200) # doctest: +SKIP
References
----------
Chu, H., et al. (2021). Agric. For. Meteorol., 301-302, 108350, Fig. 1e.
"""
entries = list(results)
if not entries:
raise ValueError(
"results is empty, so there is no composition to draw. Pass the "
"output of evaluate_landcover."
)
if max_classes is not None and max_classes < 1:
raise ValueError(f"max_classes must be positive, got {max_classes}.")
available = [float(entry.radius) for entry in entries]
keep = _selected_radii(available, radii, "results")
colors = radius_colors(available, cmap=cmap)
footprint = dict(entries[0].footprint_composition)
targets = {
float(entry.radius): dict(entry.target_composition)
for entry in entries
if float(entry.radius) in keep
}
def _largest_target(code: Any) -> float:
return max(float(share.get(code, 0.0)) for share in targets.values())
codes = set(footprint) | {code for share in targets.values() for code in share}
ranked = sorted(
(
code
for code in codes
if max(float(footprint.get(code, 0.0)), _largest_target(code)) > 0.0
),
key=lambda code: (-float(footprint.get(code, 0.0)), -_largest_target(code)),
)
if not ranked:
raise ValueError(
"No land-cover class carries a share in either the footprint or a "
"target area, so there is nothing to draw."
)
if max_classes is not None:
ranked = ranked[:max_classes]
fig, ax = _axes(ax, figsize)
positions = np.arange(len(ranked), dtype=float)
for radius in keep:
share = targets[radius]
ax.plot(
[float(share.get(code, 0.0)) for code in ranked],
positions,
marker="v",
markersize=6.0,
markeredgecolor="white",
markeredgewidth=0.6,
linestyle="none",
color=colors[radius],
label=_radius_label(radius),
zorder=2,
)
ax.plot(
[float(footprint.get(code, 0.0)) for code in ranked],
positions,
marker="o",
markersize=7.0,
markeredgecolor="white",
markeredgewidth=0.6,
linestyle="none",
color=footprint_color,
label="Footprint-weighted",
zorder=3,
)
labels = class_labels or {}
ax.set_yticks(positions)
ax.set_yticklabels([str(labels.get(code, code)) for code in ranked])
ax.invert_yaxis()
ax.set_xlim(-2.0, 102.0)
ax.set_xlabel("Land-cover percentage (%)")
_style(ax, axis="x")
# The footprint is the reference, so it leads the legend rather than
# trailing the radii it was drawn over.
handles, texts = ax.get_legend_handles_labels()
order = [len(handles) - 1, *range(len(handles) - 1)]
ax.legend(
[handles[index] for index in order],
[texts[index] for index in order],
loc="best",
frameon=False,
fontsize="small",
handletextpad=0.4,
borderaxespad=0.6,
)
return fig, ax
# ------------------------------
# Fig. 1f, Fig. 6 -- footprint against target area
# ------------------------------
# ------------------------------
# Fig. 7 -- sensor location bias
# ------------------------------
[docs]
def plot_bias_density(
data: pd.DataFrame,
*,
radii: Sequence[float] | None = None,
bias_column: str | None = None,
radius_column: str = "radius",
percent: bool = True,
clip: tuple[float, float] = (-100.0, 100.0),
bandwidth: float | str | None = None,
gridsize: int = 512,
threshold: float | None = None,
ax: Axes | None = None,
cmap: str = RADIUS_CMAP,
figsize: tuple[float, float] = (6.0, 4.0),
) -> tuple[Figure, Axes]:
"""
Plot kernel densities of the sensor location bias by radius, Fig. 7.
One Gaussian kernel density per target area over the per-period biases of
Eq. 6, drawn on the radius ramp. The peak sharpens and rises towards the
smallest radius, which is the paper's Fig. 7 result: the wider the disc,
the further a month's footprint-weighted value strays from it.
Parameters
----------
data : pandas.DataFrame
Per-period biases, either the frame of
:func:`~fluxfootprints.sensor_location_bias_series` (column ``delta``)
or the tidy frame of :func:`~fluxfootprints.assess_representativeness`
(column ``bias``), whose ``radius`` index level is reset here. Subset
the latter to one ``variable`` and ``period`` first.
radii : sequence of float, optional
Draw only these radii. Every radius present is drawn when omitted.
bias_column : str, optional
Column holding the bias as a *fraction*. Detected among ``"delta"`` and
``"bias"`` when omitted.
radius_column : str, default 'radius'
Column keying the densities.
percent : bool, default True
Draw the bias as a percentage, as the paper's axis does. Set False to
keep the fractions the analysis returns.
clip : tuple of float, default (-100.0, 100.0)
Range the densities are drawn over, in the units of the axis. Every
finite bias enters its kernel, including those outside this window;
only the drawing is clipped, as the paper's +/-100 % axis is.
bandwidth : float or str, optional
Passed to :class:`scipy.stats.gaussian_kde` as ``bw_method``. Scott's
rule when omitted.
gridsize : int, default 512
Number of points each density is evaluated on.
threshold : float, optional
Draw guides at ``+/- threshold``, as a fraction -- pass
:data:`~fluxfootprints.BIAS_THRESHOLD` for the +/-10 % of Sect. 2.4.
No guides when omitted, as in the published figure.
ax : matplotlib.axes.Axes, optional
Axes to draw on. A new figure is opened when omitted.
cmap : str, default RADIUS_CMAP
Sequential colormap for the radius series.
figsize : tuple of float, default (6.0, 4.0)
Size of a new figure [in]. Ignored when `ax` is given.
Returns
-------
tuple of (matplotlib.figure.Figure, matplotlib.axes.Axes)
The figure and the axes drawn on.
Raises
------
TypeError
If `data` is not a DataFrame.
ValueError
If no bias column is found, if `radii` names an absent radius, if
`clip` does not increase, if `gridsize` is below two, or if no radius
holds the spread a kernel density needs.
See Also
--------
fluxfootprints.sensor_location_bias_series : Produces `data`.
fluxfootprints.BIAS_THRESHOLD : The +/-10 % the paper judges against.
plot_footprint_target_scatter : The same mismatch pair by pair.
Notes
-----
A radius whose biases are all but identical -- a single period, or a
perfectly homogeneous site -- offers no bandwidth to estimate and is
skipped rather than drawn as a spike. Its absence from the legend is the
signal that it was.
Examples
--------
>>> bias = sensor_location_bias_series(pairs, model.x, model.y) # doctest: +SKIP
>>> fig, ax = plot_bias_density(bias, threshold=BIAS_THRESHOLD) # doctest: +SKIP
>>> ax.set_title("Daytime footprints") # doctest: +SKIP
References
----------
Chu, H., et al. (2021). Agric. For. Meteorol., 301-302, 108350,
Sect. 3.3, Fig. 7.
"""
if clip[1] <= clip[0]:
raise ValueError(f"clip must increase, got {clip}.")
if gridsize < 2:
raise ValueError(f"gridsize must be at least 2, got {gridsize}.")
frame = _as_frame(data, [radius_column], "data")
column = _detect_column(frame, _BIAS_COLUMNS, bias_column, "data", "bias")
radius_values = _numeric(frame[radius_column])
keep = _selected_radii(radius_values, radii, "data")
colors = radius_colors(radius_values[np.isfinite(radius_values)], cmap=cmap)
scale = 100.0 if percent else 1.0
fig, ax = _axes(ax, figsize)
grid = np.linspace(clip[0], clip[1], gridsize)
drawn = 0
for radius in keep:
values = _numeric(frame.loc[radius_values == radius, column])
values = values[np.isfinite(values)] * scale
if values.size < 2 or np.allclose(values, values[0]):
# No spread to estimate a bandwidth from; a spike would be a lie.
continue
try:
density = gaussian_kde(values, bw_method=bandwidth)
except np.linalg.LinAlgError: # pragma: no cover - degenerate covariance
continue
ax.plot(
grid,
density(grid),
color=colors[radius],
linewidth=2.0,
solid_capstyle="round",
label=_radius_label(radius),
zorder=2,
)
drawn += 1
if drawn == 0:
raise ValueError(
f"No radius in data holds two or more distinct {column!r} values, "
"so no kernel density can be estimated."
)
ax.axvline(0.0, color=REFERENCE_COLOR, linewidth=0.8)
if threshold is not None:
for edge in (-threshold * scale, threshold * scale):
ax.axvline(
edge, color=REFERENCE_COLOR, linewidth=0.8, linestyle="--"
)
ax.set_xlim(*clip)
ax.set_ylim(bottom=0.0)
ax.set_xlabel("Sensor location bias (%)" if percent else "Sensor location bias (-)")
ax.set_ylabel("Kernel density")
_style(ax)
ax.legend(loc="best", frameon=False, fontsize="small", handletextpad=0.6)
return fig, ax
# ------------------------------
# Fig. 5, Fig. 8 -- the three-level index
# ------------------------------
[docs]
def plot_level_bars(
data: pd.DataFrame,
*,
level_column: str | None = None,
radius_column: str = "radius",
radii: Sequence[float] | None = None,
percent: bool = True,
ax: Axes | None = None,
cmap: str = LEVEL_CMAP,
figsize: tuple[float, float] = (6.0, 4.0),
width: float = 0.72,
) -> tuple[Figure, Axes]:
"""
Plot the three-level index stacked across target areas, Fig. 5 and Fig. 8.
One stacked bar per radius, HIGH at the bottom in the darkest step through
LOW at the top in the lightest. Over a table of many sites this is the
paper's Fig. 5 (land cover) and Fig. 8 (vegetation index): the share of
sites whose footprint still represents the disc, falling as the disc widens.
Parameters
----------
data : pandas.DataFrame
One row per site and radius -- the frames of
:func:`~fluxfootprints.representativeness_summary` concatenated across
sites, or the site-scope rows of
:func:`~fluxfootprints.assess_representativeness`, whose ``radius``
index level is reset here. Rows are counted as given, so subset to one
ecosystem type, ecoregion, or period first to draw a panel of Fig. 5.
level_column : str, optional
Column holding the :class:`~fluxfootprints.Level`. Detected among
``"level"``, ``"landcover_level"``, and ``"continuous_level"`` when
omitted -- ambiguous on a summary table carrying both halves, so name
the one to draw.
radius_column : str, default 'radius'
Column keying the bars.
radii : sequence of float, optional
Draw only these radii. Every radius present is drawn when omitted.
percent : bool, default True
Scale each bar to 100 %, as the paper's axis does. Set False to stack
raw site counts, which also shows how many sites each bar rests on.
ax : matplotlib.axes.Axes, optional
Axes to draw on. A new figure is opened when omitted.
cmap : str, default LEVEL_CMAP
Sequential colormap for the three levels, darkest for HIGH.
figsize : tuple of float, default (6.0, 4.0)
Size of a new figure [in]. Ignored when `ax` is given.
width : float, default 0.72
Bar width as a fraction of the spacing between radii.
Returns
-------
tuple of (matplotlib.figure.Figure, matplotlib.axes.Axes)
The figure and the axes drawn on. Radii are laid out at even spacing
and labelled by value, not to scale, as the paper's panels are.
Raises
------
TypeError
If `data` is not a DataFrame.
ValueError
If no level column is found, if `radii` names an absent radius, if the
level column holds a value that is not one of ``"high"``, ``"medium"``,
and ``"low"``, or if every row is missing a level.
See Also
--------
fluxfootprints.representativeness_summary : Produces one site's rows.
fluxfootprints.Level : The index being counted.
plot_landcover_composition : The composition behind one site's verdict.
Notes
-----
Rows missing a level are dropped before counting, which is the paper's own
treatment: 34 of its 214 sites carried a land-cover product that disagreed
with the site metadata and were left out of the land-cover panels, and a
site with fewer than :data:`~fluxfootprints.MIN_MATCHES` matched scenes has
no regression to classify. With ``percent=True`` each bar is therefore a
share of the sites classified at *that* radius, which need not be the same
count from bar to bar.
Examples
--------
>>> table = pd.concat(summaries) # doctest: +SKIP
>>> fig, ax = plot_level_bars(table, level_column="landcover_level")
... # doctest: +SKIP
>>> ax.set_title("Land-cover representativeness") # doctest: +SKIP
References
----------
Chu, H., et al. (2021). Agric. For. Meteorol., 301-302, 108350,
Fig. 5, Fig. 8.
"""
frame = _as_frame(data, [radius_column], "data")
column = _detect_column(frame, _LEVEL_COLUMNS, level_column, "data", "level")
rows = frame[frame[column].notna()]
if rows.empty:
raise ValueError(
f"Every row of data is missing {column!r}, so there is no verdict "
"to count."
)
known = {level.value for level in _LEVEL_ORDER}
labels = rows[column].astype(str).to_numpy()
unknown = sorted(set(labels) - known)
if unknown:
raise ValueError(
f"data[{column!r}] holds the value(s) {unknown}, which are not "
f"levels; expected {[level.value for level in _LEVEL_ORDER]}."
)
radius_values = _numeric(rows[radius_column])
keep = _selected_radii(radius_values, radii, "data")
colors = level_colors(cmap=cmap)
counts = {
level: np.array(
[
float(((radius_values == radius) & (labels == level.value)).sum())
for radius in keep
]
)
for level in _LEVEL_ORDER
}
totals = sum(counts.values())
if percent:
counts = {
level: np.divide(
100.0 * value, totals, out=np.zeros_like(value), where=totals > 0
)
for level, value in counts.items()
}
fig, ax = _axes(ax, figsize)
positions = np.arange(len(keep), dtype=float)
bottom = np.zeros(len(keep), dtype=float)
for level in _LEVEL_ORDER:
heights = counts[level]
ax.bar(
positions,
heights,
width=width,
bottom=bottom,
color=colors[level],
# A surface-coloured gap, rather than a dark border, separates the
# segments of a stack.
edgecolor="white",
linewidth=1.5,
label=level.value.capitalize(),
zorder=2,
)
bottom = bottom + heights
ax.set_xticks(positions)
ax.set_xticklabels([f"{radius:g}" for radius in keep])
ax.set_xlabel("Target area around tower (m)")
ax.set_ylabel("Percentage of sites (%)" if percent else "Number of sites")
if percent:
ax.set_ylim(0.0, 100.0)
_style(ax, axis="y")
# Square swatches read the stack better than the bar patches' own handles.
handles = [
Line2D([], [], marker="s", linestyle="none", markersize=8, color=colors[level])
for level in _LEVEL_ORDER
]
# Stacked bars leave no room inside the axes, so the key sits above them.
ax.legend(
handles,
[level.value.capitalize() for level in _LEVEL_ORDER],
loc="lower right",
bbox_to_anchor=(1.0, 1.0),
ncols=len(_LEVEL_ORDER),
frameon=False,
fontsize="small",
handletextpad=0.4,
columnspacing=1.4,
)
return fig, ax