Skip to content

Commit

Permalink
Added more predefined comfort categories, and heatmap_histogram method (
Browse files Browse the repository at this point in the history
  • Loading branch information
jamesramsden-bh authored Nov 13, 2024
2 parents db86acc + f9fc7f5 commit 10f8524
Show file tree
Hide file tree
Showing 3 changed files with 140 additions and 77 deletions.
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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):
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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]:
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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,
Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand All @@ -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]],
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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]
)
31 changes: 22 additions & 9 deletions LadybugTools_Engine/Python/tests/test_categorical.py
Original file line number Diff line number Diff line change
@@ -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

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

0 comments on commit 10f8524

Please sign in to comment.