Cascading reactivity across parameterized classes?

Hi!, I’m trying to create parameterized classes that filter rows from a DataFrame and will update the filtering in a cascading way. If there are two filters and the first one changes, then the second one will update as well and will now use the data remaining after the first filter as the starting point for its own filtering.

I have the following code right now, but the connection between the ix_before and ix_after parameters of two instances of FilterIrradiance created when calling Filters.add_filter is broken as soon as the first filter is updated.

Is there a way to make this work or should I look at other approaches to solve this?

Thanks!

# create synthetic data
x = np.arange(0, 1200, 10)
y = x
y2 = x + 50
df = pd.DataFrame({'poa':x, 'power':y})

class FilterIrradiance(param.Parameterized):
    irr_range = param.Range(default=(0,1200), bounds=(0, 1200))
    ix_before = param.Array(precedence=-1)
    ix_after = param.Array(precedence=-1)
    ix_dropped = param.Array(precedence=-1)

    def __init__(self, df, column_name, **param):
        super().__init__(**param)
        self.df = df
        self.column_name = column_name
    
    @param.depends('ix_before', 'irr_range', watch=True)
    def __call__(self):
        if self.ix_before is None:
            starting_df = self.df
        else:
            starting_df = self.df.loc[self.ix_before, :]
        ix = starting_df[(starting_df[self.column_name] >= self.irr_range[0]) & (starting_df[self.column_name] <= self.irr_range[1])].index
        self.ix_after = ix.to_numpy()
        self.ix_dropped = self.df.index.difference(ix).to_numpy()
        
    @param.depends('ix_before', 'irr_range')
    def plot(self):
        self.__call__()
        return df.loc[self.ix_after, :].hvplot(kind='scatter', x='poa', y='power').opts(xlim=(0, 1250))

class Filters(param.Parameterized):
    filters = param.List()
    
    def add_filter(self, new_filter):
        if len(self.filters) == 0:
            self.filters.append(new_filter)
        else:
            last_filter = self.filters[-1]
            self.filters.append(new_filter)
            last_filter()
            self.filters[-1].ix_before = last_filter.ix_after
            
def olay(filters):
    widgets = pn.Column(*[a.param for a in filters.filters])
    overlay = hv.Overlay([hv.DynamicMap(flt.plot) for flt in filters.filters]).collate()
    return pn.Row(widgets, overlay)
filters = Filters()
filters.add_filter(FilterIrradiance(df, 'poa', irr_range=(40, 800)))
filters.add_filter(FilterIrradiance(df, 'poa', irr_range=(100, 500)))
olay(filters)

In this example, I’d like for the orange trend, which is the second filter to reflect changes in the first filter, so if you changed the minimum value of the first filter to 200 the orange plot would react to show that.

Hi @bt-1

How to solve this depends on your use case. For example do you need a solution for a specific problem or some general solution?

A start of a general solution is shown below. Here the filters depend on each other

base = FilterBase(value=df)
filter1 = Filter(input=base, range=(40,800))
filter2 = Filter(input=filter1, range=(100, 500))

import panel as pn
import numpy as np
import param
import pandas as pd
import holoviews as hv
import hvplot.pandas

pn.extension(sizing_mode="stretch_width")

# create synthetic data
x = np.arange(0, 1200, 10)
y = x
df = pd.DataFrame({'poa':x, 'power':y})

class FilterBase(param.Parameterized):
    value = param.DataFrame(precedence=-1)

class Filter(FilterBase):
    input = param.ClassSelector(class_=FilterBase, constant=True, )

    value = param.DataFrame()

    x_column = param.String('poa')
    y_column = param.String('power')

    range = param.Range(default=(0,1200), bounds=(0, 1200))

    def __init__(self, **params):
        super().__init__(**params)
        self._update_value()

    @param.depends('input.value', 'x_column', 'y_column', 'range', watch=True)
    def _update_value(self):
        input = self.input.value
        if input is None or input.empty:
            self.value = pd.DataFrame({self.x_column: [], self.y_column: []})
        else:
            min = self.range[0]
            max = self.range[1]
            x_column = self.x_column
            filter = (input[x_column] >= min) & (input[x_column] <= max)
            self.value = input[filter]

    @param.depends('value')
    def plot(self):
        return self.value.hvplot(kind='scatter', x=self.x_column, y=self.y_column).opts(xlim=self.param.range.bounds).opts(height=500, width=1000)

def olay(filters):
    widgets = pn.Column("## Filters", *[a.param.range for a in filters], width=300, sizing_mode="fixed")
    overlay = pn.panel(hv.Overlay([hv.DynamicMap(flt.plot) for flt in filters]).collate(), sizing_mode="fixed", width=1000)
    return pn.Row(widgets, overlay)

base = FilterBase(value=df)
filter1 = Filter(input=base, range=(40,800))
filter2 = Filter(input=filter1, range=(100, 500))

filters=[filter1, filter2]
pn.template.FastListTemplate(
    site="Awesome Panel",
    title="Multi Filter",
    main=[olay(filters)],
).servable()

@Marc, thanks for pointing me in the right direction on this! I am looking for a more general solution. I have an existing project that has about 14 different types of filters, which are currently methods of an unwieldy large class and I’m starting to explore re-writing that code to change each filter to a parametrized class. I’m hoping then I can build a nice interactive dashboard with Panel on top.

1 Like

Good luck. I hope you will post some more questions or showcase the result. It helps build the knowledge base of the community :+1: