From f9fc7f5852b0c3ff03512adee56d1289df01320f Mon Sep 17 00:00:00 2001 From: Tristan Gerrish Date: Thu, 7 Nov 2024 12:01:37 +0000 Subject: [PATCH] Added more predefined comfort categories, and heatmap_histogram method --- .../categorical/categorical.py | 162 ++++++++++-------- .../categorical/categories.py | 24 +++ .../Python/tests/test_categorical.py | 31 +++- 3 files changed, 140 insertions(+), 77 deletions(-) diff --git a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/categorical/categorical.py b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/categorical/categorical.py index 1c36513f..f09d3781 100644 --- a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/categorical/categorical.py +++ b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/categorical/categorical.py @@ -1,31 +1,29 @@ """Categorical objects for grouping data into bins.""" -# pylint: disable=W0212 -# pylint: disable=E0401 + +# pylint: disable=W0212,E0401 import calendar +import textwrap from dataclasses import dataclass, field from enum import Enum, auto from typing import Any +import matplotlib.ticker as mticker # pylint: enable=E0401 import numpy as np import pandas as pd from ladybug.legend import Color from matplotlib import patches from matplotlib import pyplot as plt -from matplotlib.colors import ( - BoundaryNorm, - Colormap, - ListedColormap, - to_hex, - to_rgba, -) +from matplotlib.colors import (BoundaryNorm, Colormap, ListedColormap, to_hex, + to_rgba) +from matplotlib.figure import Figure from matplotlib.legend import Legend -import matplotlib.ticker as mticker - +from mpl_toolkits.axes_grid1 import make_axes_locatable from python_toolkit.bhom.analytics import bhom_analytics + from ..helpers import rolling_window, validate_timeseries -from ..plot.utilities import contrasting_color from ..plot._heatmap import heatmap +from ..plot.utilities import contrasting_color @dataclass(init=True, repr=True) @@ -34,8 +32,8 @@ class Categorical: Args: bins (tuple[float], optional): - The bin edges for the categories. These are right-inclusive, with the exception of the first bin which is - also left-inclusive. + The bin edges for the categories. These are right-inclusive, with + the exception of the first bin which is also left-inclusive. bin_names (tuple[str], optional): The names of the categories. colors (tuple[str | tuple], optional): @@ -102,9 +100,11 @@ def from_cmap( cmap (Colormap): The colormap to use. bin_names (tuple[str], optional): - The names for each of the bins. Defaults to () which names each bin using its boundaries. + The names for each of the bins. Defaults to () which names + each bin using its boundaries. name (str, optional): - The name for this categories object. Defaults to "" which uses the colormap name. + The name for this categories object. Defaults to "" which + uses the colormap name. Returns: Categories: The resulting categories object. @@ -150,10 +150,7 @@ def descriptions(self) -> list[str]: @property def bin_names_detailed(self) -> list[str]: """The detailed bin names.""" - return [ - f"{nom} ({desc})" - for desc, nom in list(zip(*[self.descriptions, self.bin_names])) - ] + return [f"{nom} ({desc})" for desc, nom in list(zip(*[self.descriptions, self.bin_names]))] @property def cmap(self) -> Colormap: @@ -199,9 +196,7 @@ def norm(self) -> BoundaryNorm: else: pass if len(boundaries) == 1: - raise ValueError( - "The current Categorical object has unbounded edges and cannot be used to create a BoundaryNorm." - ) + raise ValueError("The current Categorical object has unbounded edges and cannot be used to create a BoundaryNorm.") return BoundaryNorm(boundaries=boundaries, ncolors=self.cmap.N) @property @@ -222,12 +217,7 @@ def lb_colors(self) -> tuple[Color]: tuple[Color]: The ladybug color objects. """ - return tuple( - Color(*i) - for i in (np.array([to_rgba(color) for color in self.colors]) * 255).astype( - int - ) - ) + return tuple(Color(*i) for i in (np.array([to_rgba(color) for color in self.colors]) * 255).astype(int)) @property def _bin_name_interval(self) -> dict[str, pd.Interval]: @@ -319,10 +309,7 @@ def get_color(self, value: float | int, as_array: bool = False) -> str: The color as a hex string. """ if value <= self.bins[0] or value > self.bins[-1]: - raise ValueError( - "The input value/s are outside the range of the categories " - f"({min(self).left} < x <= {max(self).right})." - ) + raise ValueError(f"The input value/s are outside the range of the categories ({min(self).left} < x <= {max(self).right}).") color = self.cmap(self.norm(value)) if not as_array: return to_hex(color, keep_alpha=True) @@ -340,13 +327,9 @@ def categorise(self, data: Any) -> pd.Categorical: pd.Categorical: The categorised data. """ - categorical = pd.cut( - data, self.bins, labels=self.bin_names_detailed, include_lowest=True - ) + categorical = pd.cut(data, self.bins, labels=self.bin_names_detailed, include_lowest=True) if categorical.isna().any(): - raise ValueError( - f"The input value/s are outside the range of the categories ({self.bins[0]} <= x <= {self.bins[-1]})." - ) + raise ValueError(f"The input value/s are outside the range of the categories ({self.bins[0]} <= x <= {self.bins[-1]}).") return categorical @bhom_analytics() @@ -373,9 +356,7 @@ def value_counts( return result @bhom_analytics() - def timeseries_summary_monthly( - self, series: pd.Series, density: bool = False - ) -> pd.DataFrame: + def timeseries_summary_monthly(self, series: pd.Series, density: bool = False) -> pd.DataFrame: """Return a table summary of the categories. Args: @@ -395,9 +376,7 @@ def timeseries_summary_monthly( if not isinstance(series.index, pd.DatetimeIndex): raise ValueError("The series must have a time series.") - counts = ( - self.categorise(series).groupby(series.index.month).value_counts().unstack() - ).sort_index(axis=0) + counts = (self.categorise(series).groupby(series.index.month).value_counts().unstack()).sort_index(axis=0) counts.columns.name = None counts.index.name = "Month" if density: @@ -424,15 +403,11 @@ def summarise( statements = [] for desc, (idx, val) in list(zip(*[self.bin_names, result.items()])): - statements.append( - f'"{desc}" occurs {val} times ({result_density[idx]:0.1%}).' - ) + statements.append(f'"{desc}" occurs {val} times ({result_density[idx]:0.1%}).') return "\n".join(statements) @bhom_analytics() - def create_legend( - self, ax: plt.Axes = None, verbose: bool = True, **kwargs - ) -> Legend: + def create_legend(self, ax: plt.Axes = None, verbose: bool = True, **kwargs) -> Legend: """Create a legend for this categorical. Args: @@ -454,9 +429,7 @@ def create_legend( handles = [] labels = [] - for color, description, iidx in list( - zip(*[self.colors, self.descriptions, self.interval_index]) - ): + for color, description, iidx in list(zip(*[self.colors, self.descriptions, self.interval_index])): handles.append( patches.Patch( facecolor=color, @@ -482,7 +455,8 @@ def annual_monthly_histogram( series (pd.Series): The pandas Series to plot. Must have a datetime index. ax (plt.Axes, optional): - An optional plt.Axes object to populate. Defaults to None, which creates a new plt.Axes object. + An optional plt.Axes object to populate. Defaults to None, + which creates a new plt.Axes object. show_legend (bool, optional): Whether to show the legend. Defaults to False. show_labels (bool, optional): @@ -533,9 +507,7 @@ def annual_monthly_histogram( if show_labels: for i, c in enumerate(ax.containers): label_colors = [contrasting_color(i.get_facecolor()) for i in c.patches] - labels = [ - f"{v.get_height():0.1%}" if v.get_height() > 0.15 else "" for v in c - ] + labels = [f"{v.get_height():0.1%}" if v.get_height() > 0.15 else "" for v in c] ax.bar_label( c, labels=labels, @@ -547,9 +519,7 @@ def annual_monthly_histogram( return ax @bhom_analytics() - def annual_heatmap( - self, series: pd.Series, ax: plt.Axes = None, **kwargs - ) -> plt.Axes: + def annual_heatmap(self, series: pd.Series, ax: plt.Axes = None, **kwargs) -> plt.Axes: """Create a heatmap showing the annual hourly categorical assignment for the given series. Args: @@ -625,9 +595,7 @@ def __post_init__(self): if len(self.comfort_classes) == 0: raise ValueError("The comfort classes cannot be empty.") if len(self.comfort_classes) != len(self): - raise ValueError( - "The number of comfort classes must match the number of bins." - ) + raise ValueError("The number of comfort classes must match the number of bins.") return super().__post_init__() @bhom_analytics() @@ -644,10 +612,7 @@ def simplify(self) -> "CategoricalComfort": continue d[comfort_class] = bin_left if len(d.keys()) != len(ComfortClass): - raise ValueError( - "The comfort classes must include all comfort classes " - f"{[i.name for i in ComfortClass]}." - ) + raise ValueError(f"The comfort classes must include all comfort classes {[i.name for i in ComfortClass]}.") return CategoricalComfort( bins=list(d.values()) + [self.bins[-1]], @@ -656,3 +621,64 @@ def simplify(self) -> "CategoricalComfort": name=self.name + " (simplified)", comfort_classes=list(ComfortClass), ) + + def heatmap_histogram(self, series: pd.Series, show_colorbar: bool = True, figsize: tuple[float] = (15, 5)) -> Figure: + """Create a heatmap histogram. This combines the heatmap and histogram. + + Args: + series (pd.Series): + A time indexed pandas series. + show_colorbar (bool, optional): + Whether to show the colorbar in the plot. Defaults to True. + figsize (tuple[float], optional): + Change the figsize. Defaults to (15, 5). + + Returns: + Figure: + A figure! + """ + + fig = plt.figure(figsize=figsize, constrained_layout=True) + spec = fig.add_gridspec(ncols=1, nrows=2, width_ratios=[1], height_ratios=[5, 2], hspace=0.0) + heatmap_ax = fig.add_subplot(spec[0, 0]) + histogram_ax = fig.add_subplot(spec[1, 0]) + + # Add heatmap + self.annual_heatmap(series, ax=heatmap_ax, show_colorbar=False) + # Add stacked plot + self.annual_monthly_histogram(series=series, ax=histogram_ax, show_labels=True) + + if show_colorbar: + # add colorbar + divider = make_axes_locatable(histogram_ax) + colorbar_ax = divider.append_axes("bottom", size="20%", pad=0.7) + cb = fig.colorbar( + mappable=heatmap_ax.get_children()[0], + cax=colorbar_ax, + orientation="horizontal", + drawedges=False, + extend="both", + ) + cb.outline.set_visible(False) + for bin_name, interval in list(zip(*[self.bin_names, self.interval_index])): + if np.isinf(interval.left): + ha = "right" + position = interval.right + elif np.isinf(interval.right): + ha = "left" + position = interval.left + else: + ha = "center" + position = np.mean([interval.left, interval.right]) + colorbar_ax.text( + position, + 1.05, + textwrap.fill(bin_name, 11), + ha=ha, + va="bottom", + fontsize="x-small", + ) + + heatmap_ax.set_title(f"{self.name}\n{series.name}", y=1, ha="left", va="bottom", x=0) + + return fig diff --git a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/categorical/categories.py b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/categorical/categories.py index ce08e198..e11400e9 100644 --- a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/categorical/categories.py +++ b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/categorical/categories.py @@ -134,3 +134,27 @@ bins=(0, 0.3, 1.5, 3.3, 5.5, 7.9, 10.7, 13.8, 17.1, 20.7, 24.4, 28.4, 32.6, np.inf), name="Beaufort scale", ) + +WBGT_CATEGORIES = CategoricalComfort( + name="Wet Bulb Globe Temperature", + bin_names=["Any activity", "Very heavy activity", "Heavy activity", "Moderate activity", "Light activity", "Resting only"], + bins=[-np.inf, 23, 25, 28, 30, 33, np.inf], + colors=["#c1c1c1", "#32cd32", "#ffff00", "#ffa500", "#ff0000", "#000000"], + comfort_classes=[ComfortClass.COMFORTABLE, ComfortClass.TOO_HOT, ComfortClass.TOO_HOT, ComfortClass.TOO_HOT, ComfortClass.TOO_HOT, ComfortClass.TOO_HOT] +) + +HUMIDEX_CATEGORIES = CategoricalComfort( + name="Humidex", + bin_names=["Comfort", "Little to no discomfort", "Some discomfort", "Great discomfort; avoid exertion", "Dangerous; heat stroke quite possible"], + bins=[-np.inf, 20, 29, 39, 45, np.inf], + colors=["#c1c1c1", "#32cd32", "#ffff00", "#ff8c00", "#ff0000"], + comfort_classes=[ComfortClass.COMFORTABLE, ComfortClass.TOO_HOT, ComfortClass.TOO_HOT, ComfortClass.TOO_HOT, ComfortClass.TOO_HOT] +) + +HEAT_INDEX_CATEGORIES = CategoricalComfort( + name="Heat Index", + bin_names=["Comfort", "Caution", "Extreme caution", "Danger", "Extreme danger"], + bins=[-np.inf, 27, 32, 41, 54, np.inf], + colors=["#c1c1c1", "#ffff66", "#ffd700", "#ff8c00", "#ff0000"], + comfort_classes=[ComfortClass.COMFORTABLE, ComfortClass.TOO_HOT, ComfortClass.TOO_HOT, ComfortClass.TOO_HOT, ComfortClass.TOO_HOT] +) \ No newline at end of file diff --git a/LadybugTools_Engine/Python/tests/test_categorical.py b/LadybugTools_Engine/Python/tests/test_categorical.py index 8a99c65b..8df9a653 100644 --- a/LadybugTools_Engine/Python/tests/test_categorical.py +++ b/LadybugTools_Engine/Python/tests/test_categorical.py @@ -1,15 +1,15 @@ -import pandas as pd -from ladybugtools_toolkit.categorical.categories import ( - Categorical, - UTCI_DEFAULT_CATEGORIES, - ComfortClass, - CategoricalComfort, -) -from matplotlib.legend import Legend import matplotlib.pyplot as plt import numpy as np +import pandas as pd import pytest -from ladybugtools_toolkit.ladybug_extension.datacollection import collection_to_series +from ladybugtools_toolkit.categorical.categories import ( + BEAUFORT_CATEGORIES, HEAT_INDEX_CATEGORIES, HUMIDEX_CATEGORIES, + UTCI_DEFAULT_CATEGORIES, WBGT_CATEGORIES, Categorical, CategoricalComfort, + ComfortClass) +from ladybugtools_toolkit.ladybug_extension.datacollection import \ + collection_to_series +from matplotlib.figure import Figure +from matplotlib.legend import Legend from . import EPW_OBJ @@ -176,3 +176,16 @@ def test_annual_monthly_histogram(): plt.Axes, ) plt.close("all") + +def test_heatmap_histogram() -> None: + """_""" + assert isinstance(UTCI_DEFAULT_CATEGORIES.heatmap_histogram(TEST_TIMESERIES_DATA), Figure) + plt.close("all") + +def test_predefined_categories(): + """_""" + assert isinstance(BEAUFORT_CATEGORIES, Categorical) + assert isinstance(WBGT_CATEGORIES, CategoricalComfort) + assert isinstance(HEAT_INDEX_CATEGORIES, CategoricalComfort) + assert isinstance(HUMIDEX_CATEGORIES, CategoricalComfort) + assert isinstance(UTCI_DEFAULT_CATEGORIES, CategoricalComfort)