Skip to content

Polar plot method #183

New issue

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

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

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions Python_Engine/Python/src/python_toolkit/plot/polar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@

from typing import List
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
import numpy as np
import pandas as pd
from matplotlib.cm import ScalarMappable
from matplotlib.collections import PatchCollection
from matplotlib.colors import Colormap, ListedColormap, Normalize, to_hex
from matplotlib.patches import Rectangle
from pandas.tseries.frequencies import to_offset
import textwrap

from .utilities import process_polar_data, format_polar_plot


def polar(
data: pd.DataFrame,
value_column: str = "Value",
direction_column: str = "Direction",
ax: plt.Axes = None,
directions: int = 36,
value_bins: List[float] = None,
colours: list[str | tuple[float] | Colormap] = None, #set of colours to use for the value bins
title: str = None,
legend: bool = True,
ylim: tuple[float] = None,
label: bool = False,
density: bool = True,
) -> plt.Axes:
"""Create a polar plot showing frequencies by direction.

Args:
data (pd.DataFrame):
The data to plot. must be a dataframe with value and direction columns
value_column (str, optional):
The name of the column in the dataframe containing values.
Defaults to 'Value'.
direction_column (str):
The name of the column in the dataframe containing directions.
Defaults to 'Direction'.
ax (plt.Axes, optional):
The axes to plot this chart on. Defaults to None.
directions (int, optional):
The number of directions to use. Defaults to 36.
value_bins (list[float]):
The bins to use for the magnitudes of the values.
colours: (str | tuple[float] | Colormap, optional):
A list of colours to use for the value_bins. May also be a matplotlib colormap.
title (str, optional):
title to display above the plot. Defaults to the source of this wind object.
legend (bool, optional):
Set to False to remove the legend. Defaults to True.
ylim (tuple[float], optional):
The y-axis limits. Defaults to None.
label (bool, optional):
Set to False to remove the bin labels. Defaults to False.
density (bool, optional):
Set to False to see the sum of the values instead of their frequency density. Defaults to True.
Returns:
plt.Axes: The axes object.
"""

if ax is None:
_, ax = plt.subplots(subplot_kw={"projection": "polar"})

# create grouped data for plotting
binned = process_polar_data(
data,
value_column,
direction_column,
directions,
value_bins,
density
)

# set colors
if colours is None:
colours = [
to_hex(plt.get_cmap("viridis")(i))
for i in np.linspace(0, 1, len(binned.columns))
]
if isinstance(colours, str):
colours = plt.get_cmap(colours)
if isinstance(colours, Colormap):
colours = [to_hex(colours(i)) for i in np.linspace(0, 1, len(binned.columns))]
if isinstance(colours, list | tuple):
if len(colours) != len(binned.columns):
raise ValueError(
f"colors must be a list of length {len(binned.columns)}, or a colormap."
)

# HACK to ensure that bar ends are curved when using a polar plot.
fig = plt.figure()
rect = [0.1, 0.1, 0.8, 0.8]
hist_ax = plt.Axes(fig, rect)
hist_ax.bar(np.array([1]), np.array([1]))
# END HACK

if title is None or title == "":
ax.set_title(textwrap.fill(f"{value_column}", 75))
else:
ax.set_title(title)

theta_width = np.deg2rad(360 / directions)
patches = []
color_list = []
x = theta_width / 2
for _, data_values in binned.iterrows():
y = 0
for n, val in enumerate(data_values.values):
patches.append(
Rectangle(
xy=(x, y),
width=theta_width,
height=val,
alpha=1,
)
)
color_list.append(colours[n])
y += val
if label:
ax.text(x, y, f"{y:0.1%}", ha="center", va="center", fontsize="x-small")
x += theta_width
local_cmap = ListedColormap(np.array(color_list).flatten())
pc = PatchCollection(patches, cmap=local_cmap)
pc.set_array(np.arange(len(color_list)))
ax.add_collection(pc)

# construct legend
if legend:
handles = [
mpatches.Patch(color=colours[n], label=(f"{i} to {j}" if str(j) != str(np.inf) else f"{i} and above"))
for n, (i, j) in enumerate(binned.columns.values)
]
_ = ax.legend(
handles=handles,
bbox_to_anchor=(1.1, 0.5),
loc="center left",
ncol=1,
borderaxespad=0,
frameon=False,
fontsize="small",
title=binned.columns.name,
title_fontsize="small",
)

# set y-axis limits
if ylim is None:
ylim = (0, max(binned.sum(axis=1)))
ax.set_ylim(ylim)
elif len(ylim) != 2:
raise ValueError("ylim must be a tuple of length 2.")
else:
ax.set_ylim(ylim)

if density:
ax.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=1))

format_polar_plot(ax, yticklabels=True)

return ax
102 changes: 101 additions & 1 deletion Python_Engine/Python/src/python_toolkit/plot/utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
import colorsys
import io
from pathlib import Path
from typing import Any
from typing import Any, List
import copy

import pandas as pd

import numpy as np
from matplotlib.colors import (
LinearSegmentedColormap,
Expand All @@ -24,6 +26,7 @@
from matplotlib.tri import Triangulation
from PIL import Image
from ..bhom.analytics import bhom_analytics
from ..bhom.logging import CONSOLE_LOGGER

@bhom_analytics()
def average_color(colors: Any, keep_alpha: bool = False) -> str:
Expand Down Expand Up @@ -595,3 +598,100 @@ def format_polar_plot(ax: plt.Axes, yticklabels: bool = True) -> plt.Axes:
)
if not yticklabels:
ax.set_yticklabels([])

def process_polar_data(data:pd.DataFrame, values_column:str, directions_column:str, directions:int=36, value_bins:List[float]=None, density:bool=True):
"""Process data for a polar plot by grouping by value and direction bins, either as value counts, or sums (determined by density)

"""
if values_column not in data.columns:
raise ValueError(f"Values column `{values_column}` could not be found in the input dataframe.")

if directions_column not in data.columns:
raise ValueError(f"Directions column `{directions_column}` could not be found in the input dataframe")

if value_bins is None:
value_bins = np.linspace(min(data[values_column]), max(data[values_column]), 11)

direction_bins = np.unique(
((np.linspace(0, 360, directions + 1) - ((360 / directions) / 2)) % 360).tolist()
+ [0, 360]
)

values_ser = data[values_column].copy()
directions_ser = data[directions_column].copy()

categories = pd.cut(values_ser, bins=value_bins, include_lowest=False)
dir_categories = pd.cut(directions_ser, bins=direction_bins, include_lowest=True)
bin_tuples = [tuple([i.left, i.right]) for i in categories.cat.categories.tolist()]
dir_tuples = [tuple([i.left, i.right]) for i in dir_categories.cat.categories.tolist()][1:-1]
dir_tuples.append((dir_tuples[-1][1], dir_tuples[0][0]))

mapper = dict(
zip(
*[
categories.cat.categories.tolist(),
bin_tuples,
]
)
)
mapper[np.nan] = bin_tuples[0]

dir_mapper = dict(
zip(
*[
dir_categories.cat.categories.tolist(),
[dir_tuples[-1]] + dir_tuples,
]
)
)

categories = pd.Series(
[mapper[i] for i in categories],
index=categories.index,
name=categories.name,
)

dir_categories = pd.Series(
[dir_mapper[i] for i in dir_categories],
index=dir_categories.index,
name=dir_categories.name,
)

# pivot dataframe
if density:
df = pd.concat([dir_categories, categories], axis=1)
df = (
df.groupby([df.columns[0], df.columns[1]], observed=True)
.value_counts()
.unstack()
.fillna(0)
.astype(int)
)
else:
#This allows plots like radiation roses, where the sum of the radiation from that direction is more useful than the counts
df = pd.concat([dir_categories, categories, values_ser], axis=1)
df.columns = [df.columns[0], df.columns[1], "Value"]
df = (
df.groupby([df.columns[0], df.columns[1]], observed=True)
.sum()
.unstack()
.fillna(0)
.astype(float)
)
df.droplevel(level=0, axis=1)

for b in bin_tuples:
if b not in df.columns:
df[b] = 0
df.sort_index(axis=1, inplace=True)
df = df.T
for b in dir_tuples:
if b not in df.columns:
df[b] = 0
df.sort_index(axis=1, inplace=True)
df = df.T

if density:
df = df / df.values.sum() #show density values as a percentage of the total number of values.

return df
8 changes: 7 additions & 1 deletion Python_Engine/Python/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ def get_timeseries():
df = df.set_index(pd.to_datetime(df.index))
return pd.Series(df["Value"], index=df.index)

TIMESERIES_COLLECTION = get_timeseries()
def get_test_data():
df = pd.read_csv(Path(__file__).parent / "assets" / "example_timeseries.csv", index_col="Timestamp")
df = df.set_index(pd.to_datetime(df.index))
return df

TEST_DATA = get_test_data()
TIMESERIES_COLLECTION = pd.Series(TEST_DATA["Value"], index=TEST_DATA.index)

#use 'agg' for testing plot methods, as tkinter occasionally throws strange errors (missing component/library when component isn't missing) when the default backend is used only when using pytest
mpl.use("agg")
Loading