├── LICENSE ├── README.md ├── clock_plot ├── __init__.py └── clock.py ├── data ├── eden_2_houseid_324_combined_data.csv └── seasonal_gas_usage.png ├── examples.ipynb ├── pyproject.toml ├── release_notes.txt └── setup.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2022] [Energy Systems Catapult] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## clock_plot 2 | 3 | clock_plot provides a simple way to visualize timeseries data mapping 24 hours onto the 360 degrees of a polar plot. 4 | For usage, please see the [examples.ipynb](examples.ipynb) Jupyter notebook 5 | 6 | ![seasonal gas usage clock plot](/data/seasonal_gas_usage.png) 7 | 8 | ## Installation 9 | To install this package run: 10 | `pip install clock_plot` 11 | 12 | ## Available features 13 | Time features are automatically generated for your timeseries. These features include: 14 | | Feature | Type | Description | Example Values | 15 | | ---------- | ---- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | 16 | | year | int | Calendar year | 2022 | 17 | | month | str | Calendar month | "January" | 18 | | year_month | int | Calendar year and month in the format YYYYMM | 202201 | 19 | | day | int | Day of calendar year | 25 | 20 | | date | str | Expressed in the format YYYY-MM-DD | "2022-01-25" | 21 | | week | int | ISO week of the calendar year | 5 | 22 | | dayofweek | str | Short version of day of week | "Tue" | 23 | | weekend | str | Either "weekday" or "weekend", where "weekend" is Saturday and Sunday | "weekend" (Sat/Sun)
"weekday" (Mon-Fri) | 24 | | hour | int | Hour of the day in 24 clock | 14 | 25 | | minute | int | Minute of the hour | 42 | 26 | | degrees | int | Angle around 24 hour clock-face measured in degrees | 341 | 27 | | season | str | Season of the year defined based on month, with Winter being Dec-Feb | "Winter" (Dec-Feb)
"Spring" (Mar-May)
"Summer" (Jun-Aug)
"Autumn" (Sep-Nov) | 28 | 29 | These can be used to filter your data and format your plot. 30 | 31 | For example you could filter for a particular year, plot seasons with different colors and weekday vs weekend days with different line dashes. 32 | Examples of this are given in examples.ipynb 33 | 34 | ## When should you use these plots? 35 | Radar/polar plots (of which clock plots are a special case) are [much maligned by visualisation experts](https://www.data-to-viz.com/caveat/spider.html), and for good reason. Whilst some of the common limitations are overcome with clock plots, two key ones remain: 36 | 1. It is harder to read quantitative values than on a linear axis 37 | 2. Areas scale quadratically (with the square of the value) rather than linearly, which can lead to overestimation of differences 38 | 39 | Clock plots are therefore most suited for cases where understanding absolute values is less important and one or more of the following is true: 40 | - behaviour around midnight is particularly important 41 | - there are a 2-3 daily peaks and understanding at a glance when those are occurring is more important than their exact magnitude 42 | - you want a distinctive, eye-catching graphic to engage people with your work 43 | 44 | Note that they are particularly poorly suited to: 45 | - timeseries with negative values (the radial axis becomes very unintuitive) 46 | - timeseries with little within day variation (you just get circles!) 47 | 48 | If you're not sure which is best for a particular use case, you can quickly toggle between a clock plot and a linear plot by adding `mode="line"` to your clock_plot call. -------------------------------------------------------------------------------- /clock_plot/__init__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | CLOCK_PLOT_DIR = Path(__file__).parent.resolve() 4 | -------------------------------------------------------------------------------- /clock_plot/clock.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from typing import Union 3 | import pandas as pd 4 | import numpy as np 5 | import plotly.express as px 6 | import plotly.graph_objects as go 7 | import scipy 8 | 9 | 10 | def create_datetime_vars(data_in: pd.DataFrame, datetime_col: str, bins_per_day: int = 24) -> pd.DataFrame: 11 | """Create all the datetime-related columns that might be used for analysis/plotting 12 | columns and their possible values are: 13 | year (int): Calendar year e.g. 2022 14 | month (str): Calendar month e.g. "January" 15 | year_month (int): Calendar year and month in the format YYYYMM e.g. 202201 16 | day (int): Day of calendar year e.g. 25 17 | date (str): Expressed in the format YYYY-MM-DD e.g. 2022-01-25 18 | week (int): Week of the calendar year e.g. 5 19 | dayofweek (str): Short version of day of week e.g. Tue 20 | weekend (str): Either "weekday" or "weekend" where weekends are where dayofweek is either "Sat" or "Sun" 21 | hour (int): Hour of the day in 24 clock e.g. 14 22 | minute (int): Minute of the hour e.g. 42 23 | degrees (int): Angle around 24 hour clock-face measured in degrees, calculated using hours and minutes 24 | season (str): Season of the year defined as: 25 | "Winter" where month is either "December", "January" or "February" 26 | "Spring" where month is either "March", "April" or "May" 27 | "Summer" where month is either "June", "July" or "August" 28 | "Autumn" where month is either "September", "October" or "November" 29 | 30 | Args: 31 | data (pandas.DataFrame): The DataFrame involved 32 | datetime_col (str): The column containing the datetime 33 | bins_per_day (int): The number of bins into which data will be aggregated (over a day). 34 | This is useful when you have unequally spaced datetimes datatimes. (Defaults to 24) 35 | 36 | Returns: 37 | pandas.DataFrame: Copy of the input DataFrame with datetime-related columns added 38 | """ 39 | data = data_in.copy() 40 | data[datetime_col] = pd.to_datetime(data[datetime_col]) 41 | 42 | data["year"] = data[datetime_col].dt.year 43 | data["month"] = data[datetime_col].dt.strftime("%B") 44 | data["year_month"] = data[datetime_col].dt.strftime("%Y%m").astype(int) 45 | data["day"] = data[datetime_col].dt.day 46 | data["date"] = data[datetime_col].dt.strftime("%Y-%m-%d") 47 | data["week"] = data[datetime_col].dt.isocalendar().week 48 | data["dayofweek"] = data[datetime_col].dt.strftime("%a") 49 | data["weekend"] = "Weekday" 50 | data.loc[(data["dayofweek"] == "Sat") | (data["dayofweek"] == "Sun"), "weekend"] = "Weekend" 51 | data["hour"] = data[datetime_col].dt.hour 52 | data["minute"] = data[datetime_col].dt.minute 53 | data["degrees"] = 360 * data["hour"] / 24 54 | # Temporarily keep this methodology, in the long-term this needs fixing to use the binning method for all 55 | # circumstances 56 | if bins_per_day != 24: 57 | data["degrees"] = data["degrees"] + (360 * data["minute"] / (60 * 24)) 58 | data["degree_bins"] = pd.cut(data["degrees"], bins=bins_per_day) 59 | data["degrees"] = data["degree_bins"].map(lambda x: x.mid).astype(int) 60 | 61 | data["season"] = data["month"].map( 62 | { 63 | "December": "Winter", 64 | "January": "Winter", 65 | "February": "Winter", 66 | "March": "Spring", 67 | "April": "Spring", 68 | "May": "Spring", 69 | "June": "Summer", 70 | "July": "Summer", 71 | "August": "Summer", 72 | "September": "Autumn", 73 | "October": "Autumn", 74 | "November": "Autumn", 75 | } 76 | ) 77 | 78 | return data 79 | 80 | 81 | def default_category_orders() -> dict: 82 | """Returns the default dictionary of category orders""" 83 | day_order = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] 84 | weekend_order = ["Weekday", "Weekend"] 85 | season_order = ["Spring", "Summer", "Autumn", "Winter"] 86 | month_order = [ 87 | "January", 88 | "February", 89 | "March", 90 | "April", 91 | "May", 92 | "June", 93 | "July", 94 | "August", 95 | "September", 96 | "October", 97 | "November", 98 | "December", 99 | ] 100 | 101 | category_orders = { 102 | "dayofweek": day_order, 103 | "weekend": weekend_order, 104 | "season": season_order, 105 | "month": month_order, 106 | } 107 | 108 | return category_orders 109 | 110 | 111 | def create_title(title_start: str, filters: dict, line_group: str): 112 | """Automatically generates chart titles for the given filters and line groupings 113 | 114 | Args: 115 | title_start (str): The start of the title 116 | filters (dict): The filters applied to the data 117 | line_group (str): The lowest level of grouping applied to the data 118 | 119 | Returns: 120 | str: The title string 121 | """ 122 | # Try to tidy up the list of filter values ready for printing in the chart title. This will work 123 | # for straightforward cases (single values, non-nested lists) but will fail for nested lists, in 124 | # which case just give up and don't bother to include it in the title 125 | if filters: 126 | try: 127 | tidied_filter_vals = "" 128 | keys = list(filters.keys()) 129 | values = [] 130 | for key in keys: 131 | values.append(filters[key]) 132 | 133 | tidied_filter_vals = tidied_filter_vals + " & ".join([str(x) for x in values if type(x) is not list]) 134 | if any(isinstance(val, list) for val in values) & any(not isinstance(val, list) for val in values): 135 | tidied_filter_vals = tidied_filter_vals + " & " 136 | tidied_filter_vals = tidied_filter_vals + " & ".join( 137 | [str(y) for x in values if type(x) is list for y in x] 138 | ) 139 | except Exception: 140 | tidied_filter_vals = "" 141 | 142 | title = "by Hour of Day" 143 | if title_start != "": 144 | title = f"{title_start} {title}" 145 | if filters: 146 | title = title + f" for {tidied_filter_vals}" 147 | if line_group is not None: 148 | title = title + f"
(each line represents a single {line_group})" 149 | 150 | return title 151 | 152 | 153 | def filter_and_group_data( 154 | data: pd.DataFrame, 155 | datetime_col: str, 156 | value_col: str, 157 | filters: dict = {}, 158 | aggregate: dict = {None: "mean"}, 159 | line_group: str = None, 160 | color: str = None, 161 | line_dash: str = None, 162 | bins_per_day: int = None, 163 | ) -> Union[pd.DataFrame, pd.DataFrame]: 164 | """Filter and then group the data based on given parameters 165 | 166 | Args: 167 | data (pd.DataFrame): The data to filter and group 168 | datetime_col (str): The datetime column name 169 | value_col (str): The name of the column containing the values to be grouped 170 | filters (dict, optional): Dictionary fiters, key-value pairs are column names and values to keep respectively. 171 | Defaults to {}. 172 | aggregate (_type_, optional): Dictionary containing a single key-value pair used to specify an additional 173 | level of aggregation line. 174 | Key gives column name to aggregate and value gives the aggregation method. Defaults to {None: "mean"}. 175 | line_group (str, optional): Name of column to use a the lowest level of grouping when plotting. 176 | Defaults to None. 177 | color (str, optional): Name of column to use to group by color. Defaults to None. 178 | line_dash (str, optional): Name of column to use to group by line dash. Defaults to None. 179 | bins_per_day (int, optional): Number of bins to group data into for each hour. Defaults to None. 180 | 181 | Raises: 182 | Exception: When an expected column is not present in the given DataFrame 183 | Exception: When the given filters leave 0 rows of data remaining 184 | 185 | Returns: 186 | Union[pd.DataFrame, pd.DataFrame]: The filtered and grouped data 187 | """ 188 | agg_col = list(aggregate.keys())[0] 189 | agg_fn = list(aggregate.values())[0] 190 | relevant_cols = [col for col in [line_group, color, line_dash, "degrees", agg_col] + list(filters.keys()) if col] 191 | 192 | # If columns have been specified that don't exist (or bins_per_day is manually specified) then 193 | # generate the datatime vars to see if that helps 194 | if not set(relevant_cols).issubset(data.columns) or bins_per_day or len(relevant_cols) == 0: 195 | data = create_datetime_vars(data, datetime_col, bins_per_day) 196 | # If there are still missing columns raise an Exception 197 | if not set(relevant_cols).issubset(data.columns): 198 | missing_cols = [col for col in relevant_cols if col not in data.columns] 199 | raise KeyError(f"The following columns are missing from the supplied dataset: {missing_cols}") 200 | 201 | filtered_data = data 202 | if len(filters) > 0: 203 | # Apply all the specified filters 204 | for col, val in filters.items(): 205 | # Note that the check that col is in filtered_data.columns has already been done above 206 | if col is not None: 207 | if type(val) is list: 208 | filtered_data = filtered_data[filtered_data[col].isin(val)] 209 | else: 210 | filtered_data = filtered_data[filtered_data[col] == val] 211 | 212 | if len(filtered_data) == 0: 213 | raise Exception("Filtering data leaves 0 rows remaining. Check the filters that have been specified") 214 | 215 | # Group by the required columns (using the list comprehension to remove columns that are None) 216 | grouped_data = ( 217 | filtered_data.groupby([col for col in [line_group, color, line_dash, "degrees"] if col])[value_col] 218 | .agg(agg_fn) 219 | .reset_index() 220 | ) 221 | 222 | if (grouped_data[value_col] < 0).any(): 223 | warnings.warn( 224 | "Data contains negative values. A plot will be produced but they are often difficult to" " interpret" 225 | ) 226 | 227 | return filtered_data, grouped_data 228 | 229 | 230 | def spline_interp(grouped_data: pd.DataFrame, value_col: str, groups: list, grouped_columns: list) -> pd.DataFrame: 231 | """Perform spline interpolation on the given data 232 | 233 | Args: 234 | grouped_data (pd.DataFrame): The data to interpolate 235 | value_col (str): The column name holding the values to be interpolated 236 | groups (list): The groups of values on which the data will be separated for interpolartion 237 | grouped_columns (list): The column names that were used for grouping the data 238 | 239 | Returns: 240 | pd.DataFrame: The interpolated data 241 | """ 242 | interp_data = pd.DataFrame() 243 | for values in groups: 244 | filt = (grouped_data[grouped_columns] == values).all(axis=1) 245 | df = grouped_data[filt].copy() 246 | # It only makes sense to interpolate if we have enough data, here we choose 8 points (i.e. 3 hour intervals) 247 | if len(df) >= 8: 248 | # Want to put first values at end and last values at start to use for interpolation 249 | # We use 3 values as we are doing a cublic spline. This is the minimum needed for good interpolation 250 | # around 0-360 deg. 251 | start = range(0, 3) 252 | end = range(-3, 0) 253 | df = pd.concat([pd.DataFrame(df.iloc[end, :]), df, pd.DataFrame(df.iloc[start, :])], axis=0) 254 | df.iloc[end, df.columns == "degrees"] = df.iloc[end]["degrees"] + 360 255 | df.iloc[start, df.columns == "degrees"] = df.iloc[start]["degrees"] - 360 256 | # Reindex so we have rows for every degree value (plus the original rows) 257 | new_index = np.linspace(0, 360, 361) 258 | df = df.set_index("degrees") 259 | df = df.reindex(df.index.union(new_index)) 260 | df[grouped_columns] = values 261 | df[value_col] = df[value_col].astype(float).interpolate(method="cubicspline") 262 | df.reset_index(inplace=True) 263 | df = df.loc[(df["degrees"] >= 0) & (df["degrees"] < 360)] 264 | interp_data = pd.concat([interp_data, df]) 265 | 266 | return interp_data 267 | 268 | 269 | def plot_averages( 270 | fig: go.Figure, 271 | data: pd.DataFrame, 272 | value_col: str, 273 | aggregate: dict, 274 | color: str, 275 | line_shape: str = "spline", 276 | color_discrete_sequence: list = px.colors.qualitative.G10, 277 | category_orders: dict = default_category_orders(), 278 | mode: str = "polar", 279 | ): 280 | """Add traces for the aggregated data 281 | 282 | Args: 283 | fig (go.Figure): The existing figure to which the traces will be added 284 | data (pd.DataFrame): The data to aggregate 285 | value_col (str): Name of the column containing the value to aggregate 286 | aggregate (dict): Dictionary containing a single key-value pair. 287 | Key gives column name to aggregate and value gives the aggregation method. 288 | color (str): Name of column to use to group by color. 289 | line_shape (str, optional): Whether to smooth the lines, one of either 'linear' or 'spline'. 290 | Defaults to 'spline' 291 | color_discrete_sequence (list, optional): List of colors to use for the chart. 292 | Defaults to px.colors.qualitative.G10 293 | category_orders (dict, optional): Dictionary where the key-value pairs are column names and a list of values 294 | in the desired order. This is used to set relative ordering of categories 295 | and is important for fixing line colors and legend orders. 296 | Defaults to default_category_orders() 297 | mode (str, optional): Whether to plot "polar" or "flat" (cartesian). Defaults to "polar". 298 | 299 | Returns: 300 | go.Figure: The figure with added traces for aggregated data 301 | """ 302 | agg_col = list(aggregate.keys())[0] 303 | agg_fn = list(aggregate.values())[0] 304 | agg_data = data.groupby([agg_col, "degrees"])[value_col].agg(agg_fn).reset_index() 305 | # Want to change the data labels to reflect that it is aggregated ( This appears in the legend ) 306 | agg_data[agg_col] = agg_data[agg_col].map(("{} (" + agg_fn + ")").format) 307 | # Also need to add these categories to the category_orders dict ( So the color and legend order are consistent) 308 | if agg_col in category_orders: 309 | category_orders[agg_col] = category_orders[agg_col] + [ 310 | f"{value} ({agg_fn})" for value in category_orders[agg_col] 311 | ] 312 | 313 | if mode == "polar": 314 | agg_fig = px.line_polar( 315 | agg_data, 316 | theta="degrees", 317 | r=value_col, 318 | color=color, 319 | line_close=True, 320 | line_shape=line_shape, 321 | category_orders=category_orders, 322 | color_discrete_sequence=color_discrete_sequence, 323 | ) 324 | else: 325 | agg_fig = px.line( 326 | agg_data, 327 | x="degrees", 328 | y=value_col, 329 | color=color, 330 | line_shape=line_shape, 331 | category_orders=category_orders, 332 | color_discrete_sequence=color_discrete_sequence, 333 | ) 334 | agg_fig.update_traces(line_width=6) 335 | fig.add_traces(list(agg_fig.select_traces())) 336 | 337 | return fig 338 | 339 | 340 | def clock_plot( 341 | data: pd.DataFrame, 342 | datetime_col: str, 343 | value_col: str, 344 | filters: dict = {}, 345 | aggregate: dict = {None: "mean"}, 346 | line_group: str = None, 347 | color: str = None, 348 | line_dash: str = None, 349 | line_shape: str = "spline", 350 | title_start: str = "", 351 | title: str = None, 352 | bins_per_day: int = 24, 353 | show: bool = True, 354 | color_discrete_sequence: list = None, 355 | category_orders: dict = {}, 356 | text_noon: bool = True, 357 | mode: str = "polar", 358 | **kwargs, 359 | ): 360 | """Plot a polar chart showing value by hour of day 361 | 362 | Args: 363 | data (pandas.DataFrame): DataFrame containing the values to plot as a timeseries 364 | datetime_col (str): Name of the column containing the datetime 365 | value_col (str): Name of the column containing the value to plot 366 | filters (dict, optional): Dictionary filters, key-value pairs are column names and values to keep respectively. 367 | Defaults to {}. 368 | aggregate (_type_, optional): Dictionary containing a single key-value pair. 369 | Key gives column name to aggregate and value gives the aggregation method. 370 | Defaults to {None: "mean"}. 371 | line_group (str, optional): Name of column to use a the lowest level of grouping when plotting. 372 | Defaults to None. 373 | color (str, optional): Name of column to use to group by color. Defaults to None. 374 | line_dash (str, optional): Name of column to use to group by line dash. Defaults to None. 375 | line_shape (str, optional): Whether to smooth the lines, one of either 'linear' or 'spline'. 376 | Defaults to "spline". 377 | title_start (str, optional): A string to prefix to the automated chart title. Defaults to "". 378 | title (str, optional): Overrides the automated chart title. Defaults to None. 379 | bins_per_day (int, optional): Number of bins to group data into over a day. This is useful when using 380 | irregularly spaced datetimes. Defaults to 24. 381 | show (bool, optional): Whether to display the chart after it is generated. Defaults to True. 382 | color_discrete_sequence (list, optional): List of colors to use for the chart. Defaults to None. 383 | category_orders (dict, optional): Dictionary where the key-value pairs are column names and a list of values 384 | in the desired order. This is used to set relative ordering of categories 385 | and is important for fixing line colors and legend orders. Defaults to {}. 386 | text_noon (bool, optional): Whether to replace hours 0 and 12 with "Midnight" and "Noon" respectively. 387 | Defaults to True. 388 | mode (str, optional): Whether to plot "polar" or "line" (cartesian). Defaults to "polar". 389 | **kwargs: Accepts and passes on any arguments accepted by plotly express line_polar() 390 | 391 | Returns: 392 | (plotly.graph_objects.Figure): The generated plotly figure object containing the polar charts. 393 | """ 394 | filtered_data, grouped_data = filter_and_group_data( 395 | data, 396 | datetime_col, 397 | value_col, 398 | filters, 399 | aggregate, 400 | line_group, 401 | color, 402 | line_dash, 403 | bins_per_day, 404 | ) 405 | 406 | # The order in which categories are plotted determins their colour which we want to be consistent and correct 407 | def_category_orders = default_category_orders() 408 | def_category_orders.update(category_orders) 409 | category_orders = def_category_orders 410 | 411 | # Specify the color sequence to use (we use a custom one for season so that the colours are intuitive) 412 | # TODO: Allow continuous colour sequences to be passed 413 | if color_discrete_sequence is None: 414 | if color is not None and "season" in color: 415 | color_discrete_sequence = ["green", "red", "orange", "blue"] 416 | elif list(aggregate.keys())[0] is not None and "season" in list(aggregate.keys()): 417 | color_discrete_sequence = ["green", "red", "orange", "blue"] 418 | else: 419 | color_discrete_sequence = px.colors.qualitative.G10 420 | 421 | if title is None: 422 | title = create_title(title_start, filters, line_group) 423 | 424 | # Plotly struggles to do splines for more than 20 or so curves, so revert to line_shape = linear and do 425 | # interpolation before hand 426 | orig_line_shape = line_shape 427 | columns = [col for col in [color, line_group, line_dash] if col] 428 | if len(columns) > 0: 429 | groups = list(filtered_data.groupby(columns).groups.keys()) 430 | else: 431 | groups = [] 432 | grp_length = len(groups) 433 | if grp_length > 20 and line_shape == "spline": 434 | line_shape = "linear" 435 | grouped_data = spline_interp(grouped_data, value_col, groups, columns) 436 | 437 | tick_text = list(range(0, 24)) 438 | if text_noon: 439 | tick_text[0] = "Midnight" 440 | tick_text[12] = "Noon" 441 | 442 | if mode == "polar": 443 | # Default to 800x800 unless size is manually specified 444 | if "width" not in kwargs.keys() and "height" not in kwargs.keys(): 445 | kwargs["width"] = 800 446 | kwargs["height"] = 800 447 | 448 | fig = px.line_polar( 449 | grouped_data, 450 | theta="degrees", 451 | r=value_col, 452 | line_group=line_group, 453 | color=color, 454 | line_dash=line_dash, 455 | line_close=True, 456 | line_shape=line_shape, 457 | category_orders=category_orders, 458 | title=title, 459 | color_discrete_sequence=color_discrete_sequence, 460 | **kwargs, 461 | ) 462 | # Calculate the tickmarks for 24 hour clock 463 | fig.update_polars( 464 | angularaxis_tickvals=np.array(range(0, 360, int(360 / 24))), 465 | angularaxis_ticktext=tick_text, 466 | ) 467 | else: 468 | # Default to 800x600 unless size is manually specified 469 | if "width" not in kwargs.keys() and "height" not in kwargs.keys(): 470 | kwargs["width"] = 800 471 | kwargs["height"] = 600 472 | fig = px.line( 473 | grouped_data, 474 | x="degrees", 475 | y=value_col, 476 | line_group=line_group, 477 | color=color, 478 | line_dash=line_dash, 479 | line_shape=line_shape, 480 | category_orders=category_orders, 481 | title=title, 482 | color_discrete_sequence=color_discrete_sequence, 483 | **kwargs, 484 | ) 485 | # Calculate the tickmarks for 24 hour clock 486 | fig.update_layout( 487 | xaxis_tickvals=np.array(range(0, 360, int(360 / 24))), 488 | xaxis_ticktext=tick_text, 489 | ) 490 | 491 | # If there are a lot of lines, make them smaller and slightly transparent 492 | if grp_length > 12: 493 | fig.update_traces(line_width=0.5) 494 | fig.update_traces(opacity=0.7) 495 | elif grp_length <= 8: 496 | fig.update_traces(line_width=3) 497 | 498 | # Plot the averages (if required) 499 | if list(aggregate.keys())[0] is not None: 500 | fig = plot_averages( 501 | fig, 502 | filtered_data, 503 | value_col, 504 | aggregate, 505 | color, 506 | orig_line_shape, 507 | color_discrete_sequence, 508 | category_orders, 509 | mode, 510 | ) 511 | 512 | if show: 513 | fig.show() 514 | return fig 515 | -------------------------------------------------------------------------------- /data/seasonal_gas_usage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ES-Catapult/clock_plot/7284e0bfe908ae0484557486496b8a399a349d53/data/seasonal_gas_usage.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42"] 3 | build-backend = "setuptools.build_meta" -------------------------------------------------------------------------------- /release_notes.txt: -------------------------------------------------------------------------------- 1 | 0.2.1 - Add the ability to easily toggle back to an ordinary line plot 2 | 3 | 0.2 - Default to showing 0 and 12 as "Midnight" and "Noon". Can revert to numbers with text_noon=False 4 | 5 | 0.1.4 - Fix bug where plotting using just datetime_col and value_col didn't work. 6 | 7 | 0.1.3 - Initial release -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r", encoding="utf-8") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="clock_plot", 8 | version="0.2.1", 9 | author="Samuel Young", 10 | author_email="samuel.young.work@gmail.com", 11 | description="This package provides a simple way to visualize patterns in timeseries data mapping 24 hours onto a polar plot", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/esc-data-science/clock_plot", 15 | project_urls={ 16 | "Bug Tracker": "https://github.com/esc-data-science/clock_plot/issues", 17 | }, 18 | classifiers=[ 19 | "Programming Language :: Python :: 3", 20 | "License :: OSI Approved :: MIT License", 21 | "Operating System :: OS Independent", 22 | ], 23 | packages=setuptools.find_packages(), 24 | python_requires=">=3.7", 25 | install_requires=["numpy>=1.20.3", "pandas>=1.3.4", "plotly>=4.0.0", "scipy>=1.7.1"], 26 | ) 27 | --------------------------------------------------------------------------------