Skip to content

🔥 show Aggregation bin-size in legend text + dynamic callback support #35

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

Merged
merged 17 commits into from
Mar 11, 2022
Merged
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
13 changes: 9 additions & 4 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
# plotly-resampler examples

This directory withholds several examples, indicating the applicability of plotly-resampler in various use cases.
This directory withholds several examples, indicating the applicability of
plotly-resampler in various use cases.

## 0. basic example

The testing CI/CD of plotly resampler uses _selenium_ and _selenium-wire_ to test the interactiveness of various figures. All these figures are shown in the [basic-example notebook](basic_example.ipynb)
The testing CI/CD of plotly resampler uses _selenium_ and _selenium-wire_ to test the
interactiveness of various figures. All these figures are shown in
the [basic-example notebook](basic_example.ipynb)

## 1. Dash apps
The [dash_apps](dash_apps/dash_app.py) folder contains example dash apps in which `plotly-resampler` is integrated

The [dash_apps](dash_apps/dash_app.py) folder contains example dash apps in
which `plotly-resampler` is integrated

| app-name | description |
| --- | --- |
| [file visualization](dash_apps/dash_app.py) | load and visualize multiple `.parquet` files with plotly-resampler |
| [file visualization](dash_apps/dash_app.py) | load and visualize multiple `.parquet` files with plotly-resampler |
| [dynamic sine generator](dash_apps/construct_dynamic_figures.py) | expeonential sine generator which uses [pattern matching callbacks](https://dash.plotly.com/pattern-matching-callbacks) to remove and construct plotly-resampler graphs dynamically |
161 changes: 161 additions & 0 deletions examples/dash_apps/construct_dynamic_figures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
from typing import Dict
from uuid import uuid4

import numpy as np
import plotly.graph_objects as go

import dash
import dash_bootstrap_components as dbc
from dash import Dash, dcc, html, Input, Output, State, MATCH
from dash.exceptions import PreventUpdate

from trace_updater import TraceUpdater
from plotly_resampler import FigureResampler

# The global variables
graph_dict: Dict[str, FigureResampler] = {}
app = Dash(
__name__, suppress_callback_exceptions=True, external_stylesheets=[dbc.themes.LUX]
)

# ---------------------------- Construct the app layout ----------------------------
app.layout = html.Div(
[
html.Div(html.H1("Exponential sine generator"), style={"textAlign": "center"}),
html.Hr(),
dbc.Row(
[
dbc.Col(
dbc.Form(
[
dbc.Label("#datapoints:", style={"margin-left": "10px"}),
html.Br(),
dcc.Input(
id="nbr-datapoints",
placeholder="n",
type="number",
style={"margin-left": "10px"},
),
*([html.Br()] * 2),
dbc.Label("exponent:", style={"margin-left": "10px"}),
html.Br(),
dcc.Input(
id="expansion-factor",
placeholder="pow",
type="number",
min=0.95,
max=1.00001,
style={"margin-left": "10px"},
),
*([html.Br()] * 2),
dbc.Button(
"Create new graph",
id="add-graph-btn",
color="primary",
style={
"textalign": "center",
"width": "max-content",
"margin-left": "10px",
},
),
*([html.Br()] * 2),
dbc.Button(
"Remove last graph",
id="remove-graph-btn",
color="danger",
style={
"textalign": "center",
"width": "max-content",
"margin-left": "10px",
},
),
],
),
style={"align": "top"},
md=2,
),
dbc.Col(html.Div(id="graph-container"), md=10),
],
),
]
)


# -------------------------------- Callbacks ---------------------------------------
@app.callback(
Output("graph-container", "children"),
Input("add-graph-btn", "n_clicks"),
Input("remove-graph-btn", "n_clicks"),
[
State("nbr-datapoints", "value"),
State("expansion-factor", "value"),
State("graph-container", "children"),
],
prevent_initial_call=True,
)
def add_or_remove_graph(add_graph, remove_graph, n, exp, gc):
if (add_graph is None or n is None or exp is None) and (remove_graph is None):
raise PreventUpdate()

# Transform the graph data to a figure
gc = [] if gc is None else gc # list of existing Graphs and their TraceUpdaters
if len(gc):
_gc = []
for i in range(len(gc) // 2):
_gc.append(dcc.Graph(**gc[i * 2]["props"]))
_gc.append(
TraceUpdater(**{k: gc[i * 2 + 1]["props"][k] for k in ["id", "gdID"]})
)
gc = _gc

# Check if we need to remove a graph
clicked_btns = [p["prop_id"] for p in dash.callback_context.triggered]
if any("remove-graph" in btn_name for btn_name in clicked_btns):
if not len(gc):
raise PreventUpdate()

graph_dict.pop(gc[-1].__getattribute__("gdID"))
return [*gc[:-2]]

# No graph needs to be removed -> create a new graph
x = np.arange(n)
expansion_scaling = exp ** x
y = np.sin(x / 10) * expansion_scaling + np.random.randn(n) / 10 * expansion_scaling

fr = FigureResampler(go.Figure(), verbose=True)
fr.add_trace(go.Scattergl(name="sin"), hf_x=x, hf_y=y)
fr.update_layout(
height=350,
showlegend=True,
legend=dict(orientation="h", y=1.12, xanchor="right", x=1),
template="plotly_white",
title=f"graph {len(graph_dict) + 1} - n={n:,} pow={exp}",
title_x=0.5,
)

# Create a uuid for the graph and add it to the global graph dict,
uid = str(uuid4())
graph_dict[uid] = fr

# Add the graph to the existing output
return [
*gc, # the existing Graphs and their TraceUpdaters
dcc.Graph(figure=fr, id={"type": "dynamic-graph", "index": uid}),
TraceUpdater(id={"type": "dynamic-updater", "index": uid}, gdID=uid),
]


# The generic resampling callback
# see: https://dash.plotly.com/pattern-matching-callbacks for more info
@app.callback(
Output({"type": "dynamic-updater", "index": MATCH}, "updateData"),
Input({"type": "dynamic-graph", "index": MATCH}, "relayoutData"),
State({"type": "dynamic-graph", "index": MATCH}, "id"),
prevent_initial_call=True,
)
def update_figure(relayoutdata: dict, graph_id_dict: dict):
return graph_dict.get(graph_id_dict.get("index"))._update_graph(relayoutdata)


if __name__ == "__main__":
app.run_server(debug=True)
64 changes: 42 additions & 22 deletions plotly_resampler/downsamplers/downsampling_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import re
from abc import ABC, abstractmethod
from typing import List
from typing import List, Optional

import pandas as pd

Expand All @@ -13,23 +13,34 @@ class AbstractSeriesDownsampler(ABC):
""""""

def __init__(
self, interleave_gaps: bool = True, dtype_regex_list: List[str] = None
self,
interleave_gaps: bool = True,
dtype_regex_list: List[str] = None,
max_gap_detection_data_size: int = 25_000,
):
"""Constructor of AbstractSeriesDownsampler.

Parameters
----------
interleave_gaps: bool, optional
Whether None values should be added when there are gaps / irregularly
sampled data. A quantile based approach is used to determine the gaps /
Whether None values should be added when there are gaps / irregularly
sampled data. A quantile-based approach is used to determine the gaps /
irregularly sampled data. By default, True.
dtype_regex_list: List[str], optional
List containing the regex matching the supported datatypes, by default None.

max_gap_detection_data_size: int, optional
The maximum raw-data size on which gap detection is performed. If the
raw data size exceeds this value, gap detection will be performed on
the aggregated (a.k.a. downsampled) series.

.. note::
This parameter only has an effect if ``interleave_gaps`` is set to True.

"""
self.interleave_gaps = interleave_gaps
self.dtype_regex_list = dtype_regex_list
self.max_gap_q = 0.95
self.max_gap_q = 0.975
self.max_gap_data_size = max_gap_detection_data_size
super().__init__()

@abstractmethod
Expand All @@ -49,30 +60,24 @@ def _supports_dtype(self, s: pd.Series):
f"{s.dtype} doesn't match with any regex in {self.dtype_regex_list}"
)

def _interleave_gaps_none(self, s: pd.Series):
def _get_gap_df(self, s: pd.Series) -> Optional[pd.Series]:
# ------- add None where there are gaps / irregularly sampled data
if isinstance(s.index, pd.DatetimeIndex):
series_index_diff = s.index.to_series().diff().dt.total_seconds()
else:
series_index_diff = s.index.to_series().diff()

# use a quantile based approach
med_gap_s, max_q_gap_s = series_index_diff.quantile(q=[0.55, self.max_gap_q])
# use a quantile-based approach
med_gap_s, max_q_gap_s = series_index_diff.quantile(q=[0.5, self.max_gap_q])

# add None data-points in between the gaps
if med_gap_s is not None and max_q_gap_s is not None:
max_q_gap_s = max(4 * med_gap_s, max_q_gap_s)
df_res_gap = s.loc[series_index_diff > max_q_gap_s ].copy()
max_q_gap_s = max(2 * med_gap_s, max_q_gap_s)
df_res_gap = s.loc[series_index_diff > max_q_gap_s].copy()
if len(df_res_gap):
df_res_gap.loc[:] = None
# Note:
# * the order of pd.concat is important for correct visualization
# * we also need a stable algorithm for sorting, i.e., the equal-index
# data-entries their order will be maintained.
return pd.concat([df_res_gap, s], ignore_index=False).sort_index(
kind="mergesort"
)
return s
return df_res_gap
return None

def downsample(self, s: pd.Series, n_out: int) -> pd.Series:
# base case: the passed series is empty
Expand All @@ -85,10 +90,25 @@ def downsample(self, s: pd.Series, n_out: int) -> pd.Series:
if str(s.dtype) == "bool":
s = s.astype("uint8")

gaps = None
raw_slice_size = s.shape[0]
if self.interleave_gaps and raw_slice_size < self.max_gap_data_size:
# if the raw-data slice is not too large -> gaps are detected on the raw
# data
gaps = self._get_gap_df(s)

if len(s) > n_out:
s = self._downsample(s, n_out=n_out)

if self.interleave_gaps:
s = self._interleave_gaps_none(s)

if self.interleave_gaps and raw_slice_size >= self.max_gap_data_size:
# if the raw-data slice is too large -> gaps are detected on the
# downsampled data
gaps = self._get_gap_df(s)

if gaps is not None:
# Note:
# * the order of pd.concat is important for correct visualization
# * we also need a stable algorithm for sorting, i.e., the equal-index
# data-entries their order will be maintained.
return pd.concat([gaps, s], ignore_index=False).sort_index(kind="mergesort")
return s
Loading