From 5b2c2aebd4c7c317f4c8f70cf3c19bc3cfd8ad5d Mon Sep 17 00:00:00 2001 From: Thomas Edward Kingstone Date: Mon, 2 Sep 2024 14:06:01 +0100 Subject: [PATCH 1/3] removed heatmap from LBT and referenced python toolkit instead --- .../bhom/wrapped/plot/heatmap.py | 3 +- .../categorical/categorical.py | 2 +- .../external_comfort/_externalcomfortbase.py | 2 +- .../plot/_evaporative_cooling_potential.py | 2 +- .../src/ladybugtools_toolkit/plot/_heatmap.py | 112 ------------------ .../src/ladybugtools_toolkit/plot/_misc.py | 2 +- .../src/ladybugtools_toolkit/plot/_utci.py | 2 +- 7 files changed, 7 insertions(+), 118 deletions(-) delete mode 100644 LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_heatmap.py diff --git a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/bhom/wrapped/plot/heatmap.py b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/bhom/wrapped/plot/heatmap.py index 37c9f730..17a39b59 100644 --- a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/bhom/wrapped/plot/heatmap.py +++ b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/bhom/wrapped/plot/heatmap.py @@ -11,7 +11,8 @@ def heatmap(epw_file: str, data_type_key: str, colour_map: str, return_file: str try: from ladybug.epw import EPW from ladybug.datacollection import HourlyContinuousCollection - from ladybugtools_toolkit.plot._heatmap import heatmap + from python_toolkit.plot.heatmap import heatmap + #from ladybugtools_toolkit.plot._heatmap import heatmap from ladybugtools_toolkit.ladybug_extension.datacollection import collection_to_series from ladybugtools_toolkit.bhom.wrapped.metadata.collection import collection_metadata from ladybugtools_toolkit.ladybug_extension.epw import wet_bulb_temperature diff --git a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/categorical/categorical.py b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/categorical/categorical.py index f09d3781..be7a66d3 100644 --- a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/categorical/categorical.py +++ b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/categorical/categorical.py @@ -22,8 +22,8 @@ from python_toolkit.bhom.analytics import bhom_analytics from ..helpers import rolling_window, validate_timeseries -from ..plot._heatmap import heatmap from ..plot.utilities import contrasting_color +from python_toolkit.plot.heatmap import heatmap @dataclass(init=True, repr=True) diff --git a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/external_comfort/_externalcomfortbase.py b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/external_comfort/_externalcomfortbase.py index 55341e15..6506c6e2 100644 --- a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/external_comfort/_externalcomfortbase.py +++ b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/external_comfort/_externalcomfortbase.py @@ -19,7 +19,7 @@ from ..helpers import convert_keys_to_snake_case from ..ladybug_extension.analysisperiod import describe_analysis_period from ..ladybug_extension.datacollection import collection_to_series -from ..plot._heatmap import heatmap +from python_toolkit.plot.heatmap import heatmap from ..plot._utci import utci_day_comfort_metrics, utci_heatmap_histogram from ..plot.colormaps import ( DBT_COLORMAP, diff --git a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_evaporative_cooling_potential.py b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_evaporative_cooling_potential.py index c0092c4d..1ee32b1e 100644 --- a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_evaporative_cooling_potential.py +++ b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_evaporative_cooling_potential.py @@ -4,7 +4,7 @@ from pathlib import Path from ladybug.epw import EPW -from ._heatmap import heatmap +from python_toolkit.plot.heatmap import heatmap from ..ladybug_extension.datacollection import collection_to_series from ..ladybug_extension.location import location_to_string diff --git a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_heatmap.py b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_heatmap.py deleted file mode 100644 index da586dea..00000000 --- a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_heatmap.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Methods for plotting heatmaps from time-indexed data.""" - -import matplotlib.dates as mdates -import matplotlib.pyplot as plt -import numpy as np -import pandas as pd - -from python_toolkit.bhom.analytics import bhom_analytics -from ..helpers import validate_timeseries - - -@bhom_analytics() -def heatmap( - series: pd.Series, - ax: plt.Axes = None, - **kwargs, -) -> plt.Axes: - """Create a heatmap of a pandas Series. - - Args: - 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. - **kwargs: - Additional keyword arguments to pass to plt.pcolormesh(). - show_colorbar (bool, optional): - If True, show the colorbar. Defaults to True. - title (str, optional): - The title of the plot. Defaults to None. - mask (List[bool], optional): - A list of booleans to mask the data. Defaults to None. - - Returns: - plt.Axes: - The populated plt.Axes object. - """ - - validate_timeseries(series) - - if ax is None: - ax = plt.gca() - - day_time_matrix = ( - series.dropna() - .to_frame() - .pivot_table(columns=series.index.date, index=series.index.time) - ) - x = mdates.date2num(day_time_matrix.columns.get_level_values(1)) - y = mdates.date2num( - pd.to_datetime([f"2017-01-01 {i}" for i in day_time_matrix.index]) - ) - z = day_time_matrix.values - - if "mask" in kwargs: - if len(kwargs["mask"]) != len(series): - raise ValueError( - f"Length of mask ({len(kwargs['mask'])}) must match length of data ({len(series)})." - ) - z = np.ma.masked_array(z, mask=kwargs.pop("mask")) - - # handle non-standard kwargs - extend = kwargs.pop("extend", "neither") - title = kwargs.pop("title", series.name) - show_colorbar = kwargs.pop("show_colorbar", True) - - # Plot data - pcm = ax.pcolormesh( - x, - y, - z[:-1, :-1], - **kwargs, - ) - - ax.xaxis_date() - if len(set(series.index.year)) > 1: - date_formatter = mdates.DateFormatter("%b %Y") - else: - date_formatter = mdates.DateFormatter("%b") - ax.yaxis.set_major_formatter(date_formatter) - - ax.yaxis_date() - ax.yaxis.set_major_formatter(mdates.DateFormatter("%H:%M")) - - ax.tick_params(labelleft=True, labelbottom=True) - plt.setp(ax.get_xticklabels(), ha="left") - - for spine in ["top", "bottom", "left", "right"]: - ax.spines[spine].set_visible(False) - - for i in ax.get_xticks(): - ax.axvline(i, color="w", ls=":", lw=0.5, alpha=0.5) - for i in ax.get_yticks(): - ax.axhline(i, color="w", ls=":", lw=0.5, alpha=0.5) - - if show_colorbar: - cb = plt.colorbar( - pcm, - ax=ax, - orientation="horizontal", - drawedges=False, - fraction=0.05, - aspect=100, - pad=0.075, - extend=extend, - label=series.name, - ) - cb.outline.set_visible(False) - - ax.set_title(title) - - return ax diff --git a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_misc.py b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_misc.py index 3a2e3ca9..0dd5c747 100644 --- a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_misc.py +++ b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_misc.py @@ -14,7 +14,7 @@ from ..ladybug_extension.datacollection import collection_to_series from ..helpers import sunrise_sunset -from ._heatmap import heatmap +from python_toolkit.plot.heatmap import heatmap from ..categorical.categories import Categorical diff --git a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_utci.py b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_utci.py index 1af88181..5d6db2da 100644 --- a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_utci.py +++ b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_utci.py @@ -25,7 +25,7 @@ CategoricalComfort, ) from ..ladybug_extension.datacollection import collection_to_series -from ._heatmap import heatmap +from python_toolkit.plot.heatmap import heatmap from .colormaps import UTCI_DIFFERENCE_COLORMAP from .utilities import contrasting_color, lighten_color From 1dcf584b2287d570430b6d0da33ef8346749bf46 Mon Sep 17 00:00:00 2001 From: Thomas Edward Kingstone Date: Mon, 2 Sep 2024 14:12:11 +0100 Subject: [PATCH 2/3] removed diurnal and timeseries from lbt and referenced python toolkit in their place --- .../bhom/wrapped/plot/diurnal.py | 2 +- .../src/ladybugtools_toolkit/plot/_diurnal.py | 293 ------------------ .../plot/_radiant_cooling_potential.py | 3 +- .../ladybugtools_toolkit/plot/_timeseries.py | 59 ---- .../Python/src/ladybugtools_toolkit/wind.py | 2 +- 5 files changed, 4 insertions(+), 355 deletions(-) delete mode 100644 LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_diurnal.py delete mode 100644 LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_timeseries.py diff --git a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/bhom/wrapped/plot/diurnal.py b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/bhom/wrapped/plot/diurnal.py index 4c69fbde..294097cc 100644 --- a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/bhom/wrapped/plot/diurnal.py +++ b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/bhom/wrapped/plot/diurnal.py @@ -11,7 +11,7 @@ def diurnal(epw_file, return_file: str, data_type_key="Dry Bulb Temperature", co from ladybug.epw import EPW, AnalysisPeriod from ladybugtools_toolkit.ladybug_extension.datacollection import collection_to_series from ladybugtools_toolkit.ladybug_extension.epw import wet_bulb_temperature - from ladybugtools_toolkit.plot._diurnal import diurnal + from python_toolkit.plot.diurnal import diurnal from ladybug.datacollection import HourlyContinuousCollection from ladybugtools_toolkit.plot.utilities import figure_to_base64 from ladybugtools_toolkit.bhom.wrapped.metadata.collection import collection_metadata diff --git a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_diurnal.py b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_diurnal.py deleted file mode 100644 index ce3c3fe3..00000000 --- a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_diurnal.py +++ /dev/null @@ -1,293 +0,0 @@ -"""Methods for plotting diurnal profiles from time-indexed data.""" - -# pylint: disable=E0401 -import calendar -import textwrap - -# pylint: enable=E0401 - -import matplotlib.collections as mcollections -import matplotlib.lines as mlines -import matplotlib.patches as mpatches -import matplotlib.pyplot as plt -import matplotlib.ticker as mticker -import numpy as np -import pandas as pd - -from python_toolkit.bhom.analytics import bhom_analytics -from .utilities import create_title - - -@bhom_analytics() -def diurnal( - series: pd.Series, - ax: plt.Axes = None, - period: str = "daily", - **kwargs, -) -> plt.Axes: - """Plot a profile aggregated across days in the specified timeframe. - - Args: - series (pd.Series): - A time-indexed Pandas Series object. - ax (plt.Axes, optional): - A matplotlib Axes object. Defaults to None. - period (str, optional): - The period to aggregate over. Must be one of "dailyy", "weekly", or "monthly". Defaults to "daily". - **kwargs (Dict[str, Any], optional): - Additional keyword arguments to pass to the matplotlib plotting function. - legend (bool, optional): - If True, show the legend. Defaults to True. - - Returns: - plt.Axes: - A matplotlib Axes object. - """ - - if ax is None: - ax = plt.gca() - - if not isinstance(series.index, pd.DatetimeIndex): - raise ValueError("Series passed is not datetime indexed.") - - show_legend = kwargs.pop("legend", True) - - # NOTE - no checks here for missing days, weeks, or months, it should be evident from the plot - - # obtain plotting parameters - minmax_range = kwargs.pop("minmax_range", [0.0001, 0.9999]) - if minmax_range[0] > minmax_range[1]: - raise ValueError("minmax_range must be increasing.") - minmax_alpha = kwargs.pop("minmax_alpha", 0.1) - - quantile_range = kwargs.pop("quantile_range", [0.05, 0.95]) - if quantile_range[0] > quantile_range[1]: - raise ValueError("quantile_range must be increasing.") - if quantile_range[0] < minmax_range[0] or quantile_range[1] > minmax_range[1]: - raise ValueError("quantile_range must be within minmax_range.") - quantile_alpha = kwargs.pop("quantile_alpha", 0.3) - - color = kwargs.pop("color", "slategray") - - # resample to hourly to ensuure hour alignment - # TODO - for now we only resample to hourly, but this could be made more flexible by allowing any subset of period - series = series.resample("h").mean() - - # remove nan/inf - series = series.replace([-np.inf, np.inf], np.nan).dropna() - - # Remove outliers - series = series[ - (series >= series.quantile(minmax_range[0])) - & (series <= series.quantile(minmax_range[1])) - ] - - # group data - if period == "daily": - group = series.groupby(series.index.hour) - target_idx = range(24) - major_ticks = target_idx[::3] - minor_ticks = target_idx - major_ticklabels = [f"{i:02d}:00" for i in major_ticks] - elif period == "weekly": - group = series.groupby([series.index.dayofweek, series.index.hour]) - target_idx = pd.MultiIndex.from_product([range(7), range(24)]) - major_ticks = range(len(target_idx))[::12] - minor_ticks = range(len(target_idx))[::3] - major_ticklabels = [] - for i in target_idx: - if i[1] == 0: - major_ticklabels.append(f"{calendar.day_abbr[i[0]]}") - elif i[1] == 12: - major_ticklabels.append("") - elif period == "monthly": - group = series.groupby([series.index.month, series.index.hour]) - target_idx = pd.MultiIndex.from_product([range(1, 13, 1), range(24)]) - major_ticks = range(len(target_idx))[::12] - minor_ticks = range(len(target_idx))[::6] - major_ticklabels = [] - for i in target_idx: - if i[1] == 0: - major_ticklabels.append(f"{calendar.month_abbr[i[0]]}") - elif i[1] == 12: - major_ticklabels.append("") - else: - raise ValueError("period must be one of 'daily', 'weekly', or 'monthly'") - - samples_per_timestep = group.count().mean() - ax.set_title( - create_title( - kwargs.pop("title", None), - f"Average {period} diurnal profile (≈{samples_per_timestep:0.0f} samples per timestep)", - ) - ) - - # Get values to plot - minima = group.min() - lower = group.quantile(quantile_range[0]) - median = group.median() - mean = group.mean() - upper = group.quantile(quantile_range[1]) - maxima = group.max() - - # create df for re-indexing - df = pd.concat( - [minima, lower, median, mean, upper, maxima], - axis=1, - keys=["minima", "lower", "median", "mean", "upper", "maxima"], - ).reindex(target_idx) - - # populate plot - for n, i in enumerate(range(len(df) + 1)[::24]): - if n == len(range(len(df) + 1)[::24]) - 1: - continue - # q-q - ax.fill_between( - range(len(df) + 1)[i : i + 25], - (df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24] - + [(df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24][0]], - (df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24] - + [(df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24][0]], - alpha=quantile_alpha, - color=color, - lw=None, - ec=None, - label=f"{quantile_range[0]:0.0%}-{quantile_range[1]:0.0%}ile" - if n == 0 - else "_nolegend_", - ) - # q-extreme - ax.fill_between( - range(len(df) + 1)[i : i + 25], - (df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24] - + [(df["lower"].tolist() + [df["lower"].values[0]])[i : i + 24][0]], - (df["minima"].tolist() + [df["minima"].values[0]])[i : i + 24] - + [(df["minima"].tolist() + [df["minima"].values[0]])[i : i + 24][0]], - alpha=minmax_alpha, - color=color, - lw=None, - ec=None, - label="Range" if n == 0 else "_nolegend_", - ) - ax.fill_between( - range(len(df) + 1)[i : i + 25], - (df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24] - + [(df["upper"].tolist() + [df["upper"].values[0]])[i : i + 24][0]], - (df["maxima"].tolist() + [df["maxima"].values[0]])[i : i + 24] - + [(df["maxima"].tolist() + [df["maxima"].values[0]])[i : i + 24][0]], - alpha=minmax_alpha, - color=color, - lw=None, - ec=None, - label="_nolegend_", - ) - # mean/median - ax.plot( - range(len(df) + 1)[i : i + 25], - (df["mean"].tolist() + [df["mean"].values[0]])[i : i + 24] - + [(df["mean"].tolist() + [df["mean"].values[0]])[i : i + 24][0]], - c=color, - ls="-", - lw=1, - label="Average" if n == 0 else "_nolegend_", - ) - ax.plot( - range(len(df) + 1)[i : i + 25], - (df["median"].tolist() + [df["median"].values[0]])[i : i + 24] - + [(df["median"].tolist() + [df["median"].values[0]])[i : i + 24][0]], - c=color, - ls="--", - lw=1, - label="Median" if n == 0 else "_nolegend_", - ) - - # format axes - ax.set_xlim(0, len(df)) - ax.xaxis.set_major_locator(mticker.FixedLocator(major_ticks)) - ax.xaxis.set_minor_locator(mticker.FixedLocator(minor_ticks)) - ax.set_xticklabels( - major_ticklabels, - minor=False, - ha="left", - ) - if show_legend: - ax.legend( - bbox_to_anchor=(0.5, -0.2), - loc=8, - ncol=6, - borderaxespad=0, - ) - - ax.set_ylabel(series.name) - - return ax - - -@bhom_analytics() -def stacked_diurnals( - datasets: list[pd.Series], period: str = "monthly", **kwargs -) -> plt.Figure: - """Create a matplotlib figure with stacked diurnal profiles. - - Args: - datasets (list[pd.Series]): - A list of time-indexed Pandas Series objects. - period (str, optional): - The period to aggregate over. Must be one of "dailyy", "weekly", or "monthly". Defaults to "monthly". - **kwargs (Dict[str, Any], optional): - Additional keyword arguments to pass to the matplotlib plotting function. - colors (list[str], optional): - A list of colors to use for the plots. Defaults to None, which uses the default diurnal color. - - Returns: - plt.Figure: - A matplotlib Figure object. - """ - - if len(datasets) <= 1: - raise ValueError("stacked_diurnals requires at least two datasets.") - - fig, axes = plt.subplots( - len(datasets), 1, figsize=(12, 2 * len(datasets)), sharex=True - ) - - for n, (ax, series) in enumerate(zip(axes, datasets)): - if "colors" in kwargs: - kwargs["color"] = kwargs["colors"][n] - diurnal(series, ax=ax, period=period, **kwargs) - ax.set_title(None) - ax.get_legend().remove() - ax.set_ylabel(textwrap.fill(ax.get_ylabel(), 20)) - - handles, labels = axes[-1].get_legend_handles_labels() - new_handles = [] - for handle in handles: - if isinstance(handle, mcollections.PolyCollection): - new_handles.append( - mpatches.Patch( - color="slategray", alpha=handle.get_alpha(), edgecolor=None - ) - ) - if isinstance(handle, mlines.Line2D): - new_handles.append( - mlines.Line2D( - (0,), (0,), color="slategray", linestyle=handle.get_linestyle() - ) - ) - - plt.legend( - new_handles, labels, bbox_to_anchor=(0.5, -0.12), loc="upper center", ncol=4 - ) - - fig.suptitle( - create_title( - kwargs.pop("title", None), - f"Average {period} diurnal profile" + "s" if len(datasets) > 1 else "", - ), - x=fig.subplotpars.left, - ha="left", - ) - - plt.tight_layout() - - return fig diff --git a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_radiant_cooling_potential.py b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_radiant_cooling_potential.py index a22b0dcb..9a6d3870 100644 --- a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_radiant_cooling_potential.py +++ b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_radiant_cooling_potential.py @@ -2,8 +2,9 @@ import matplotlib.pyplot as plt import pandas as pd +import textwrap -from ._diurnal import diurnal, textwrap +from python_toolkit.plot.diurnal import diurnal def radiant_cooling_potential( diff --git a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_timeseries.py b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_timeseries.py deleted file mode 100644 index a4a6cb68..00000000 --- a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/_timeseries.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Mwethods for plotting time-indexed data.""" - -from datetime import datetime # pylint: disable=E0401 - - -import matplotlib.pyplot as plt -import pandas as pd - -from python_toolkit.bhom.analytics import bhom_analytics -from ..helpers import validate_timeseries - - -@bhom_analytics() -def timeseries( - series: pd.Series, - ax: plt.Axes = None, - xlims: tuple[datetime] = None, - ylims: tuple[datetime] = None, - **kwargs, -) -> plt.Axes: - """Create a timeseries plot of a pandas Series. - - Args: - 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. - xlims (tuple[datetime], optional): - Set the x-limits. Defaults to None. - ylims (tuple[datetime], optional): - Set the y-limits. Defaults to None. - **kwargs: - Additional keyword arguments to pass to the plt.plot() function. - - Returns: - plt.Axes: - The populated plt.Axes object. - """ - - validate_timeseries(series) - - if ax is None: - ax = plt.gca() - - ax.plot(series.index, series.values, **kwargs) ## example plot here - - # TODO - add cmap arg to color line by y value - - # https://matplotlib.org/stable/gallery/lines_bars_and_markers/multicolored_line.html - - if xlims is None: - ax.set_xlim(series.index.min(), series.index.max()) - else: - ax.set_xlim(xlims) - if ylims is None: - ax.set_ylim(ax.get_ylim()) - else: - ax.set_ylim(ylims) - - return ax diff --git a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/wind.py b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/wind.py index 4dd30125..533d0b63 100644 --- a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/wind.py +++ b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/wind.py @@ -44,7 +44,7 @@ analysis_period_to_datetimes, describe_analysis_period, ) -from .plot._timeseries import timeseries +from python_toolkit.plot.timeseries import timeseries from .plot.utilities import contrasting_color, format_polar_plot From b8f9d09e98957169dbda3a72ca9b1408d39306a9 Mon Sep 17 00:00:00 2001 From: Thomas Edward Kingstone Date: Thu, 7 Nov 2024 14:54:37 +0000 Subject: [PATCH 3/3] move helpers and utilities methods, and fix any import errors afterwards --- .../src/ladybugtools_toolkit/helpers.py | 400 +---------- .../ladybugtools_toolkit/plot/utilities.py | 634 +----------------- LadybugTools_Engine/Python/tests/test_plot.py | 8 +- 3 files changed, 41 insertions(+), 1001 deletions(-) diff --git a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/helpers.py b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/helpers.py index 1c58f877..86be3a97 100644 --- a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/helpers.py +++ b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/helpers.py @@ -38,30 +38,23 @@ from python_toolkit.bhom.logging import CONSOLE_LOGGER from .ladybug_extension.dt import lb_datetime_from_datetime -# pylint: enable=E0401 - - -def sanitise_string(string: str) -> str: - """Sanitise a string so that only path-safe characters remain.""" - - keep_characters = r"[^.A-Za-z0-9_-]" - - return re.sub(keep_characters, "_", string).replace("__", "_").rstrip() - - -def convert_keys_to_snake_case(d: dict): - """Given a dictionary, convert all keys to snake_case.""" - keys_to_skip = ["_t"] - if isinstance(d, dict): - return { - snakecase(k) if k not in keys_to_skip else k: convert_keys_to_snake_case(v) - for k, v in d.items() - } - if isinstance(d, list): - return [convert_keys_to_snake_case(x) for x in d] - - return d +from python_toolkit.helpers import ( + validate_timeseries, + sanitise_string, + convert_keys_to_snake_case, + DecayMethod, + proximity_decay, + timedelta_tostring, + decay_rate_smoother, + cardinality, + angle_from_cardinal, + angle_from_north, + angle_to_vector, + remove_leap_days, + safe_filename +) +# pylint: enable=E0401 @bhom_analytics() def default_hour_analysis_periods() -> list[AnalysisPeriod]: @@ -354,7 +347,6 @@ def scrape_weather( return df - @bhom_analytics() def rolling_window(array: list[Any], window: int): """Throwaway function here to roll a window along a list. @@ -383,298 +375,6 @@ def rolling_window(array: list[Any], window: int): return np.lib.stride_tricks.as_strided(a, shape=shape, strides=strides) -class DecayMethod(Enum): - """An enumeration of decay methods.""" - - LINEAR = auto() - PARABOLIC = auto() - SIGMOID = auto() - - -@bhom_analytics() -def proximity_decay( - value: float, - distance_to_value: float, - max_distance: float, - decay_method: DecayMethod = DecayMethod.LINEAR, -) -> float: - """Calculate the "decayed" value based on proximity (up to a maximum distance). - - Args: - value (float): - The value to be distributed. - distance_to_value (float): - A distance at which to return the magnitude. - max_distance (float): - The maximum distance to which magnitude is to be distributed. Beyond this, the input - value is 0. - decay_method (DecayMethod, optional): - A type of distribution (the shape of the distribution profile). Defaults to "DecayMethod.LINEAR". - - Returns: - float: - The value at the given distance. - """ - - distance_to_value = np.interp(distance_to_value, [0, max_distance], [0, 1]) - - if decay_method == DecayMethod.LINEAR: - return (1 - distance_to_value) * value - if decay_method == DecayMethod.PARABOLIC: - return (-(distance_to_value**2) + 1) * value - if decay_method == DecayMethod.SIGMOID: - return (1 - (0.5 * (np.sin(distance_to_value * np.pi - np.pi / 2) + 1))) * value - - raise ValueError(f"Unknown curve type: {decay_method}") - - -@bhom_analytics() -def timedelta_tostring(time_delta: timedelta) -> str: - """timedelta objects don't have a nice string representation, so this function converts them. - - Args: - time_delta (datetime.timedelta): - The timedelta object to convert. - Returns: - str: - A string representation of the timedelta object. - """ - s = time_delta.seconds - hours, remainder = divmod(s, 3600) - minutes, _ = divmod(remainder, 60) - return f"{hours:02d}:{minutes:02d}" - - -@bhom_analytics() -def decay_rate_smoother( - series: pd.Series, - difference_threshold: float = -10, - transition_window: int = 4, - ewm_span: float = 1.25, -) -> pd.Series: - """Helper function that adds a decay rate to a time-series for values dropping significantly - below the previous values. - - Args: - series (pd.Series): - The series to modify - difference_threshold (float, optional): - The difference between current/previous values which class as a "transition". - Defaults to -10. - transition_window (int, optional): - The number of values after the "transition" within which an exponentially weighted mean - should be applied. Defaults to 4. - ewm_span (float, optional): - The rate of decay. Defaults to 1.25. - - Returns: - pd.Series: - A modified series - """ - - # Find periods of major transition (where values vary significantly) - transition_index = series.diff() < difference_threshold - - # Get boolean index for all periods within window from the transition indices - ewm_mask = [] - n = 0 - for i in transition_index: - if i: - n = 0 - if n < transition_window: - ewm_mask.append(True) - else: - ewm_mask.append(False) - n += 1 - - # Run an EWM to get the smoothed values following changes to values - ewm_smoothed: pd.Series = series.ewm(span=ewm_span).mean() - - # Choose from ewm or original values based on ewm mask - new_series = ewm_smoothed.where(ewm_mask, series) - - return new_series - - -@bhom_analytics() -def cardinality(direction_angle: float, directions: int = 16): - """Returns the cardinal orientation of a given angle, where that angle is related to north at - 0 degrees. - Args: - direction_angle (float): - The angle to north in degrees (+Ve is interpreted as clockwise from north at 0.0 - degrees). - directions (int): - The number of cardinal directions into which angles shall be binned (This value should - be one of 4, 8, 16 or 32, and is centred about "north"). - Returns: - int: - The cardinal direction the angle represents. - """ - - if direction_angle > 360 or direction_angle < 0: - raise ValueError( - "The angle entered is beyond the normally expected range for an orientation in degrees." - ) - - cardinal_directions = { - 4: ["N", "E", "S", "W"], - 8: ["N", "NE", "E", "SE", "S", "SW", "W", "NW"], - 16: [ - "N", - "NNE", - "NE", - "ENE", - "E", - "ESE", - "SE", - "SSE", - "S", - "SSW", - "SW", - "WSW", - "W", - "WNW", - "NW", - "NNW", - ], - 32: [ - "N", - "NbE", - "NNE", - "NEbN", - "NE", - "NEbE", - "ENE", - "EbN", - "E", - "EbS", - "ESE", - "SEbE", - "SE", - "SEbS", - "SSE", - "SbE", - "S", - "SbW", - "SSW", - "SWbS", - "SW", - "SWbW", - "WSW", - "WbS", - "W", - "WbN", - "WNW", - "NWbW", - "NW", - "NWbN", - "NNW", - "NbW", - ], - } - - if directions not in cardinal_directions: - raise ValueError( - f'The input "directions" must be one of {list(cardinal_directions.keys())}.' - ) - - val = int((direction_angle / (360 / directions)) + 0.5) - - arr = cardinal_directions[directions] - - return arr[(val % directions)] - - -@bhom_analytics() -def angle_from_cardinal(cardinal_direction: str) -> float: - """ - For a given cardinal direction, return the corresponding angle in degrees. - - Args: - cardinal_direction (str): - The cardinal direction. - Returns: - float: - The angle associated with the cardinal direction. - """ - cardinal_directions = [ - "N", - "NbE", - "NNE", - "NEbN", - "NE", - "NEbE", - "ENE", - "EbN", - "E", - "EbS", - "ESE", - "SEbE", - "SE", - "SEbS", - "SSE", - "SbE", - "S", - "SbW", - "SSW", - "SWbS", - "SW", - "SWbW", - "WSW", - "WbS", - "W", - "WbN", - "WNW", - "NWbW", - "NW", - "NWbN", - "NNW", - "NbW", - ] - if cardinal_direction not in cardinal_directions: - raise ValueError(f"{cardinal_direction} is not a known cardinal_direction.") - angles = np.arange(0, 360, 11.25) - - lookup = dict(zip(cardinal_directions, angles)) - - return lookup[cardinal_direction] - - -def angle_from_north(vector: list[float]) -> float: - """For an X, Y vector, determine the clockwise angle to north at [0, 1]. - - Args: - vector (list[float]): - A vector of length 2. - - Returns: - float: - The angle between vector and north in degrees clockwise from [0, 1]. - """ - north = [0, 1] - angle1 = np.arctan2(*north[::-1]) - angle2 = np.arctan2(*vector[::-1]) - return np.rad2deg((angle1 - angle2) % (2 * np.pi)) - - -def angle_to_vector(clockwise_angle_from_north: float) -> list[float]: - """Return the X, Y vector from of an angle from north at 0-degrees. - - Args: - clockwise_angle_from_north (float): - The angle from north in degrees clockwise from [0, 360], though - any number can be input here for angles greater than a full circle. - - Returns: - list[float]: - A vector of length 2. - """ - - clockwise_angle_from_north = np.radians(clockwise_angle_from_north) - - return np.sin(clockwise_angle_from_north), np.cos(clockwise_angle_from_north) - def epw_wind_vectors(epw: EPW, normalise: bool = False) -> list[Vector2D]: """Return a list of vectors from the EPW wind direction and speed. @@ -1615,50 +1315,6 @@ def dry_bulb_temperature_at_height( ] return dbt_collection - -@bhom_analytics() -def validate_timeseries( - obj: Any, - is_annual: bool = False, - is_hourly: bool = False, - is_contiguous: bool = False, -) -> None: - """Check if the input object is a pandas Series, and has a datetime index. - - Args: - obj (Any): - The object to check. - is_annual (bool, optional): - If True, check that the series is annual. Defaults to False. - is_hourly (bool, optional): - If True, check that the series is hourly. Defaults to False. - is_contiguous (bool, optional): - If True, check that the series is contiguous. Defaults to False. - - Raises: - TypeError: If the object is not a pandas Series. - TypeError: If the series does not have a datetime index. - ValueError: If the series is not annual. - ValueError: If the series is not hourly. - ValueError: If the series is not contiguous. - """ - if not isinstance(obj, pd.Series): - raise TypeError("series must be a pandas Series") - if not isinstance(obj.index, pd.DatetimeIndex): - raise TypeError("series must have a datetime index") - if is_annual: - if (obj.index.day_of_year.nunique() != 365) or ( - obj.index.day_of_year.nunique() != 366 - ): - raise ValueError("series is not annual") - if is_hourly: - if obj.index.hour.nunique() != 24: - raise ValueError("series is not hourly") - if is_contiguous: - if not obj.index.is_monotonic_increasing: - raise ValueError("series is not contiguous") - - def evaporative_cooling_effect( dry_bulb_temperature: float, relative_humidity: float, @@ -1757,20 +1413,6 @@ def evaporative_cooling_effect_collection( return [dbt, rh] - -@bhom_analytics() -def remove_leap_days(pd_object: pd.DataFrame | pd.Series) -> pd.DataFrame | pd.Series: - """A removal of all timesteps within a time-indexed pandas - object where the day is the 29th of February.""" - - if not isinstance(pd_object.index, pd.DatetimeIndex): - raise ValueError("The object provided should be datetime-indexed.") - - mask = (pd_object.index.month == 2) & (pd_object.index.day == 29) - - return pd_object[~mask] - - @bhom_analytics() def month_hour_binned_series( series: pd.Series, @@ -1963,12 +1605,4 @@ def sunrise_sunset(location: Location) -> pd.DataFrame(): "nautical twilight end", "astronomical twilight end", ] - ] - - -@bhom_analytics() -def safe_filename(filename: str) -> str: - """Remove all non-alphanumeric characters from a filename.""" - return "".join( - [c for c in filename if c.isalpha() or c.isdigit() or c == " "] - ).strip() + ] \ No newline at end of file diff --git a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/utilities.py b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/utilities.py index 34eb6caa..0670d39e 100644 --- a/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/utilities.py +++ b/LadybugTools_Engine/Python/src/ladybugtools_toolkit/plot/utilities.py @@ -1,166 +1,27 @@ """Color handling utilities""" -# pylint: disable=E0401 -import base64 -import colorsys -import copy -import io -from pathlib import Path -from typing import Any -# pylint: enable=E0401 - -import matplotlib.image as mimage -import matplotlib.pyplot as plt -import matplotlib.ticker as mticker -import numpy as np from ladybug.color import Colorset from matplotlib.colors import ( - LinearSegmentedColormap, - cnames, - colorConverter, - is_color_like, - rgb2hex, - to_hex, - to_rgb, - to_rgba_array, + LinearSegmentedColormap +) +from python_toolkit.plot.utilities import ( + animation, + colormap_sequential, + relative_luminance, + contrasting_colour as contrasting_color, + annotate_imshow, + lighten_color, + create_title, + average_color, + base64_to_image, + image_to_base64, + figure_to_base64, + figure_to_image, + tile_images, + triangulation_area, + create_triangulation, + format_polar_plot ) -from matplotlib.tri import Triangulation -from PIL import Image - -from python_toolkit.bhom.analytics import bhom_analytics - - -@bhom_analytics() -def animation( - images: list[str | Path | Image.Image], - output_gif: str | Path, - ms_per_image: int = 333, - transparency_idx: int = 0, -) -> Path: - """Create an animated gif from a set of images. - - Args: - images (list[str | Path | Image.Image]): - A list of image files or PIL Image objects. - output_gif (str | Path): - The output gif file to be created. - ms_per_image (int, optional): - Number of milliseconds per image. Default is 333, for 3 images per second. - transparency_idx (int, optional): - The index of the color to be used as the transparent color. Default is 0. - - Returns: - Path: - The animated gif. - - """ - _images = [] - for i in images: - if isinstance(i, (str, Path)): - _images.append(Image.open(i)) - elif isinstance(i, Image.Image): - _images.append(i) - else: - raise ValueError( - f"images must be a list of strings, Paths or PIL Image objects - {i} is not valid." - ) - - # create white background - background = Image.new("RGBA", _images[0].size, (255, 255, 255)) - - _images = [Image.alpha_composite(background, i) for i in _images] - - _images[0].save( - output_gif, - save_all=True, - append_images=_images[1:], - optimize=False, - duration=ms_per_image, - loop=0, - disposal=2, - transparency=transparency_idx, - ) - - return output_gif - - -def relative_luminance(color: Any): - """Calculate the relative luminance of a color according to W3C standards - - Args: - color (Any): - matplotlib color or sequence of matplotlib colors - Hex code, - rgb-tuple, or html color name. - - Returns: - float: - Luminance value between 0 and 1. - """ - rgb = colorConverter.to_rgba_array(color)[:, :3] - rgb = np.where(rgb <= 0.03928, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4) - lum = rgb.dot([0.2126, 0.7152, 0.0722]) - try: - return lum.item() - except ValueError: - return lum - - -def contrasting_color(color: Any): - """Calculate the contrasting color for a given color. - - Args: - color (Any): - matplotlib color or sequence of matplotlib colors - Hex code, - rgb-tuple, or html color name. - - Returns: - str: - String code of the contrasting color. - """ - return ".15" if relative_luminance(color) > 0.408 else "w" - - -def colormap_sequential( - *colors: str | float | int | tuple, N: int = 256 -) -> LinearSegmentedColormap: - """ - Create a sequential colormap from a list of input colors. - - Args: - *colors (str | float | int | tuple): - A list of colors according to their hex-code, string name, character code or - RGBA values. - N (int, optional): - The number of colors in the colormap. Defaults to 256. - - Returns: - LinearSegmentedColormap: - A matplotlib colormap. - - Examples: - >> colormap_sequential( - (0.89411764705, 0.01176470588, 0.01176470588), - "darkorange", - "#FFED00", - "#008026", - (36/255, 64/255, 142/255), - "#732982" - ) - """ - - if len(colors) < 2: - raise KeyError("Not enough colors input to create a colormap.") - - fixed_colors = [] - for color in colors: - fixed_colors.append(to_hex(color)) - - return LinearSegmentedColormap.from_list( - name=f"{'_'.join(fixed_colors)}", - colors=fixed_colors, - N=N, - ) - def lb_colormap(name: int | str = "original") -> LinearSegmentedColormap: """Create a Matplotlib from a colormap provided by Ladybug. @@ -193,459 +54,4 @@ def lb_colormap(name: int | str = "original") -> LinearSegmentedColormap: lb_cmap = getattr(colorset, name)() rgb = [[getattr(rgb, i) / 255 for i in ["r", "g", "b", "a"]] for rgb in lb_cmap] rgb = [tuple(i) for i in rgb] - return colormap_sequential(*rgb) - - -def annotate_imshow( - im: mimage.AxesImage, - data: list[float] = None, - valfmt: str = "{x:.2f}", - textcolors: tuple[str] = ("black", "white"), - threshold: float = None, - exclude_vals: list[float] = None, - **text_kw, -) -> list[str]: - """A function to annotate a heatmap. - - Args: - im (AxesImage): - The AxesImage to be labeled. - data (list[float], optional): - Data used to annotate. If None, the image's data is used. Defaults to None. - valfmt (_type_, optional): - The format of the annotations inside the heatmap. This should either use the string - format method, e.g. "$ {x:.2f}", or be a `matplotlib.ticker.Formatter`. - Defaults to "{x:.2f}". - textcolors (tuple[str], optional): - A pair of colors. The first is used for values below a threshold, the second for - those above.. Defaults to ("black", "white"). - threshold (float, optional): - Value in data units according to which the colors from textcolors are applied. If None - (the default) uses the middle of the colormap as separation. Defaults to None. - exclude_vals (float, optional): - A list of values where text should not be added. Defaults to None. - **text_kw (dict, optional): - All other keyword arguments are passed on to the created `~matplotlib.text.Text` - - Returns: - list[str]: - The texts added to the AxesImage. - """ - - if not isinstance(data, (list, np.ndarray)): - data = im.get_array() - - # Normalize the threshold to the images color range. - if threshold is not None: - threshold = im.norm(threshold) - else: - threshold = im.norm(data.max()) / 2.0 - - # Set default alignment to center, but allow it to be overwritten by textkw. - text_kw = {"ha": "center", "va": "center"} - text_kw.update({"ha": "center", "va": "center"}) - - # Get the formatter in case a string is supplied - if isinstance(valfmt, str): - valfmt = mticker.StrMethodFormatter(valfmt) - - # Loop over the data and create a `Text` for each "pixel". - # Change the text's color depending on the data. - texts = [] - for i in range(data.shape[0]): - for j in range(data.shape[1]): - if data[i, j] in exclude_vals: - pass - else: - text_kw.update(color=textcolors[int(im.norm(data[i, j]) > threshold)]) - text = im.axes.text(j, i, valfmt(data[i, j], None), **text_kw) - texts.append(text) - - return texts - - -def lighten_color(color: str | tuple, amount: float = 0.5) -> tuple[float]: - """ - Lightens the given color by multiplying (1-luminosity) by the given amount. - - Args: - color (str): - A color-like string. - amount (float): - The amount of lightening to apply. - - Returns: - tuple[float]: - An RGB value. - - Examples: - >> lighten_color('g', 0.3) - >> lighten_color('#F034A3', 0.6) - >> lighten_color((.3,.55,.1), 0.5) - """ - try: - c = cnames[color] - except KeyError: - c = color - c = colorsys.rgb_to_hls(*to_rgb(c)) - return colorsys.hls_to_rgb(c[0], 1 - amount * (1 - c[1]), c[2]) - - -@bhom_analytics() -def create_title(text: str, plot_type: str) -> str: - """Create a title for a plot. - - Args: - text (str): - The title of the plot. - plot_type (str): - The type of plot. - - Returns: - str: - The title of the plot. - """ - return "\n".join( - [ - i - for i in [ - text, - plot_type, - ] - if i is not None - ] - ) - - -@bhom_analytics() -def average_color(colors: Any, keep_alpha: bool = False) -> str: - """Return the average color from a list of colors. - - Args: - colors (Any): - A list of colors. - keep_alpha (bool, optional): - If True, the alpha value of the color is kept. Defaults to False. - - Returns: - color: str - The average color in hex format. - """ - - if not isinstance(colors, (list, tuple)): - raise ValueError("colors must be a list") - - for i in colors: - if not is_color_like(i): - raise ValueError( - f"colors must be a list of valid colors - '{i}' is not valid." - ) - - if len(colors) == 1: - return colors[0] - - return rgb2hex(to_rgba_array(colors).mean(axis=0), keep_alpha=keep_alpha) - - -@bhom_analytics() -def base64_to_image(base64_string: str, image_path: Path) -> Path: - """Convert a base64 encoded image into a file on disk. - - Arguments: - base64_string (str): - A base64 string encoding of an image file. - image_path (Path): - The location where the image should be stored. - - Returns: - Path: - The path to the image file. - """ - - # remove html pre-amble, if necessary - if base64_string.startswith("data:image"): - base64_string = base64_string.split(";")[-1] - - with open(Path(image_path), "wb") as fp: - fp.write(base64.decodebytes(base64_string)) - - return image_path - - -@bhom_analytics() -def image_to_base64(image_path: Path, html: bool = False) -> str: - """Load an image file from disk and convert to base64 string. - - Arguments: - image_path (Path): - The file path for the image to be converted. - html (bool, optional): - Set to True to include the HTML preamble for a base64 encoded image. Default is False. - - Returns: - str: - A base64 string encoding of the input image file. - """ - - # convert path string to Path object - image_path = Path(image_path).absolute() - - # ensure format is supported - supported_formats = [".png", ".jpg", ".jpeg"] - if image_path.suffix not in supported_formats: - raise ValueError( - f"'{image_path.suffix}' format not supported. Use one of {supported_formats}" - ) - - # load image and convert to base64 string - with open(image_path, "rb") as image_file: - base64_string = base64.b64encode(image_file.read()).decode("utf-8") - - if html: - content_type = f"data:image/{image_path.suffix.replace('.', '')}" - content_encoding = "utf-8" - return f"{content_type};charset={content_encoding};base64,{base64_string}" - - return base64_string - - -@bhom_analytics() -def figure_to_base64(figure: plt.Figure, html: bool = False, transparent: bool = True) -> str: - """Convert a matplotlib figure object into a base64 string. - - Arguments: - figure (Figure): - A matplotlib figure object. - html (bool, optional): - Set to True to include the HTML preamble for a base64 encoded image. Default is False. - - Returns: - str: - A base64 string encoding of the input figure object. - """ - - buffer = io.BytesIO() - figure.savefig(buffer, transparent=transparent) - buffer.seek(0) - base64_string = base64.b64encode(buffer.read()).decode("utf-8") - - if html: - content_type = "data:image/png" - content_encoding = "utf-8" - return f"{content_type};charset={content_encoding};base64,{base64_string}" - - return base64_string - - -@bhom_analytics() -def figure_to_image(fig: plt.Figure) -> Image: - """Convert a matplotlib Figure object into a PIL Image. - - Args: - fig (Figure): - A matplotlib Figure object. - - Returns: - Image: - A PIL Image. - """ - - # draw the renderer - fig.canvas.draw() - - # Get the RGBA buffer from the figure - w, h = fig.canvas.get_width_height() - buf = np.frombuffer(fig.canvas.tostring_argb(), dtype=np.uint8) - buf.shape = (w, h, 4) - buf = np.roll(buf, 3, axis=2) - - return Image.fromarray(buf) - - -@bhom_analytics() -def tile_images( - imgs: list[Path] | list[Image.Image], rows: int, cols: int -) -> Image.Image: - """Tile a set of images into a grid. - - Args: - imgs (Union[list[Path], list[Image.Image]]): - A list of images to tile. - rows (int): - The number of rows in the grid. - cols (int): - The number of columns in the grid. - - Returns: - Image.Image: - A PIL image of the tiled images. - """ - - imgs = np.array([Path(i) for i in np.array(imgs).flatten()]) - - # open images if paths passed - imgs = [Image.open(img) if isinstance(img, Path) else img for img in imgs] - - if len(imgs) != rows * cols: - raise ValueError( - f"The number of images given ({len(imgs)}) does not equal ({rows}*{cols})" - ) - - # ensure each image has the same dimensions - w, h = imgs[0].size - for img in imgs: - if img.size != (w, h): - raise ValueError("All images must have the same dimensions") - - w, h = imgs[0].size - grid = Image.new("RGBA", size=(cols * w, rows * h)) - - for i, img in enumerate(imgs): - grid.paste(img, box=(i % cols * w, i // cols * h)) - img.close() - - return grid - - -@bhom_analytics() -def triangulation_area(triang: Triangulation) -> float: - """Calculate the area of a matplotlib Triangulation. - - Args: - triang (Triangulation): - A matplotlib Triangulation object. - - Returns: - float: - The area of the Triangulation in the units given. - """ - - triangles = triang.triangles - x, y = triang.x, triang.y - a, _ = triangles.shape - i = np.arange(a) - area = np.sum( - np.abs( - 0.5 - * ( - (x[triangles[i, 1]] - x[triangles[i, 0]]) - * (y[triangles[i, 2]] - y[triangles[i, 0]]) - - (x[triangles[i, 2]] - x[triangles[i, 0]]) - * (y[triangles[i, 1]] - y[triangles[i, 0]]) - ) - ) - ) - - return area - - -@bhom_analytics() -def create_triangulation( - x: list[float], - y: list[float], - alpha: float = None, - max_iterations: int = 250, - increment: float = 0.01, -) -> Triangulation: - """Create a matplotlib Triangulation from a list of x and y coordinates, including a mask to - remove elements with edges larger than alpha. - - Args: - x (list[float]): - A list of x coordinates. - y (list[float]): - A list of y coordinates. - alpha (float, optional): - A value to start alpha at. - Defaults to None, with an estimate made for a suitable starting point. - max_iterations (int, optional): - The number of iterations to run to check against triangulation validity. - Defaults to 250. - increment (int, optional): - The value by which to increment alpha by when searching for a valid triangulation. - Defaults to 0.01. - - Returns: - Triangulation: - A matplotlib Triangulation object. - """ - - if alpha is None: - # TODO - add method here to automatically determine appropriate alpha value - alpha = 1.1 - - if len(x) != len(y): - raise ValueError("x and y must be the same length") - - # Triangulate X, Y locations - triang = Triangulation(x, y) - - xtri = x[triang.triangles] - np.roll(x[triang.triangles], 1, axis=1) - ytri = y[triang.triangles] - np.roll(y[triang.triangles], 1, axis=1) - maxi = np.max(np.sqrt(xtri**2 + ytri**2), axis=1) - - # Iterate triangulation masking until a possible mask is found - count = 0 - fig, ax = plt.subplots(1, 1) - synthetic_values = range(len(x)) - success = False - while not success: - count += 1 - try: - tr = copy.deepcopy(triang) - tr.set_mask(maxi > alpha) - ax.tricontour(tr, synthetic_values) - success = True - except ValueError: - alpha += increment - else: - break - if count > max_iterations: - plt.close(fig) - raise ValueError( - f"Could not create a valid triangulation mask within {max_iterations}" - ) - plt.close(fig) - triang.set_mask(maxi > alpha) - return triang - - -@bhom_analytics() -def format_polar_plot(ax: plt.Axes, yticklabels: bool = True) -> plt.Axes: - """Format a polar plot, to save on having to write this every time!""" - ax.set_theta_zero_location("N") - ax.set_theta_direction(-1) - - # format plot area - ax.spines["polar"].set_visible(False) - ax.grid(True, which="both", ls="--", zorder=0, alpha=0.3) - ax.yaxis.set_major_locator(plt.MaxNLocator(6)) - plt.setp(ax.get_yticklabels(), fontsize="small") - ax.set_xticks(np.radians((0, 90, 180, 270)), minor=False) - ax.set_xticklabels(("N", "E", "S", "W"), minor=False, **{"fontsize": "medium"}) - ax.set_xticks( - np.radians( - (22.5, 45, 67.5, 112.5, 135, 157.5, 202.5, 225, 247.5, 292.5, 315, 337.5) - ), - minor=True, - ) - ax.set_xticklabels( - ( - "NNE", - "NE", - "ENE", - "ESE", - "SE", - "SSE", - "SSW", - "SW", - "WSW", - "WNW", - "NW", - "NNW", - ), - minor=True, - **{"fontsize": "x-small"}, - ) - if not yticklabels: - ax.set_yticklabels([]) + return colormap_sequential(*rgb) \ No newline at end of file diff --git a/LadybugTools_Engine/Python/tests/test_plot.py b/LadybugTools_Engine/Python/tests/test_plot.py index efa6a385..0ef62918 100644 --- a/LadybugTools_Engine/Python/tests/test_plot.py +++ b/LadybugTools_Engine/Python/tests/test_plot.py @@ -5,8 +5,8 @@ from ladybug.analysisperiod import AnalysisPeriod from ladybug_comfort.collection.utci import UTCI from ladybugtools_toolkit.ladybug_extension.datacollection import collection_to_series -from ladybugtools_toolkit.plot._diurnal import diurnal, stacked_diurnals -from ladybugtools_toolkit.plot._heatmap import heatmap +from python_toolkit.plot.diurnal import diurnal, stacked_diurnals +from python_toolkit.plot.heatmap import heatmap from ladybugtools_toolkit.plot._sunpath import sunpath from ladybugtools_toolkit.plot._degree_days import ( cooling_degree_days, @@ -24,13 +24,13 @@ utci_journey, utci_pie, ) -from ladybugtools_toolkit.plot.colormaps import colormap_sequential -from ladybugtools_toolkit.plot.spatial_heatmap import spatial_heatmap +from python_toolkit.plot.spatial_heatmap import spatial_heatmap from ladybugtools_toolkit.plot.utilities import ( contrasting_color, create_triangulation, lighten_color, relative_luminance, + colormap_sequential ) from ladybugtools_toolkit.plot._radiant_cooling_potential import ( radiant_cooling_potential,