Implement a "reset" button when using @interact decorator in Jupyter

Viewed 649

I'm trying to do a simple button to "reset" widgets to certain default values. I'm using the @interact decorator in Jupyter Lab environment. The problem is that the widgets identifiers have their values copied to the same identifiers used as float variables inside the function and therefore I cannot access them anymore within this new scope. Here is a short example (not working):

import numpy as np
import matplotlib.pyplot as plt
from ipywidgets import interact, Button

@interact(starts_at=(0, np.pi*0.9, np.pi*0.1), ends_at=(np.pi, 2*np.pi, np.pi*0.1))
def plot_graph(starts_at=0, ends_at=2*np.pi):
    
    def on_button_clicked(_):
        # instructions when clicking the button (this cannot work)
        starts_at = 0
        ends_at = 2*np.pi
        
    button = Button(description="Reset")
    button.on_click(on_button_clicked)
    display(button)
    
    f = lambda x : sum(1/a*np.sin(a*x + np.pi/a) for a in range(1,6))
    x = np.linspace(0, 2*np.pi, 1000)
    plt.plot(x, f(x))
    plt.xlim([starts_at, ends_at])

Does anybody know how to send to the scope of the decorated function a reference to the original widget objects? I'll be accepting also simple ways of implementing a button to reset those sliders.

:-D

Edit: corrected text flow

3 Answers

To accomplish this you'll have to use the more manual interactive_output function. That function allows you to pre-create the widgets and then pass them in:

import ipywidgets as widgets
import numpy as np
import matplotlib.pyplot as plt

start_slider = widgets.FloatSlider(
                            val = 0,
                            min = 0,
                            max = np.pi*0.9,
                            step = np.pi*0.1,
                            description = 'Starts at'
                            )
end_slider = widgets.FloatSlider(
                            val = np.pi,
                            min = np.pi,
                            max = 2*np.pi,
                            step = np.pi*0.1,
                            description = 'Ends at'
                            )
def on_button_clicked(_):
    start_slider.value = 0
    end_slider.value = 2*np.pi

button = Button(description="Reset")
button.on_click(on_button_clicked)
def plot_graph(starts_at=0, ends_at=2*np.pi):
    f = lambda x : sum(1/a*np.sin(a*x + np.pi/a) for a in range(1,6))
    x = np.linspace(0, 2*np.pi, 1000)
    plt.plot(x, f(x))
    plt.xlim([starts_at, ends_at])

display(widgets.VBox([start_slider, end_slider, button]))
widgets.interactive_output(plot_graph, {'starts_at': start_slider, 'ends_at':end_slider})

However, this will regenerate the plot entirely everytime you update it which can lead to a choppy experience. So you can also re-write this to use the matplotlib methods like .set_data if you use an interactive matplotlib backend in the notebook. So if you were to use ipympl you could follow the examples in this example notebook.

Via another library

I wrote a library mpl-interactions to make it easier to control matplotlib plots using ipywidgets sliders. It provides a function analogous to ipywidgets.interact in that it handles creating the widgets for you, but it has the advantage of being matplotlib focused so all you need to provide is the data. More about the differences to ipywidgets here

%matplotlib ipympl
import mpl_interactions.ipyplot as iplt
import matplotlib.pyplot as plt
import numpy as np
import ipywidgets as widgets

def plot_graph(starts_at=0, ends_at=2*np.pi):
    x = np.linspace(starts_at, ends_at, 1000)
    f = lambda x : sum(1/a*np.sin(a*x + np.pi/a) for a in range(1,6))
    return np.array([x, f(x)]).T


fig, ax = plt.subplots()
button = widgets.Button(description = 'reset')
display(button)
controls = iplt.plot(plot_graph, starts_at = (0, np.pi), ends_at = (np.pi, 2*np.pi), xlim='auto', parametric=True)
def on_click(event):
    for hbox in controls.controls.values():
        slider = hbox.children[0]
        slider.value = slider.min
button.on_click(on_click)

I could figure it out without using the @interact decorator (now it is working), but I'm not happy as a final result. So, I'm still willing to give the right answer status for someone that could make a clear/easier pythonic version of this.

Anyway, here is the working code:

import numpy as np
import matplotlib.pyplot as plt
from ipywidgets import interact, Button, FloatSlider

def plot_graph(starts_at, ends_at):
    f = lambda x : sum(1/a*np.sin(a*x + np.pi/a) for a in range(1,6))
    x = np.linspace(0, 2*np.pi, 1000)
    plt.plot(x, f(x))
    plt.xlim([starts_at, ends_at])
    
starts_at = FloatSlider(min=0, max=np.pi*0.9, value=0, step=np.pi*0.1)
ends_at = FloatSlider(min=np.pi, max=2*np.pi, value=2*np.pi, step=np.pi*0.1)

def on_button_clicked(_):
    starts_at.value = 0
    ends_at.value = 2*np.pi

button = Button(description="Reset")
button.on_click(on_button_clicked)
display(button)
_ = interact(plot_graph, starts_at=starts_at, ends_at=ends_at)

EDIT: NEW APPROACH FROM THIS POINT ==============================

I'm choosing @Ianhi answer as the correct because he pointed out the issues to be considered when in the context of my problem. Thanks!

Anyway, I'm posting here the final scheme I'm using which is simple enough for my needs and I can reuse my reset button in all my interacts:

# Preamble ----
import numpy as np
import matplotlib.pyplot as plt
from ipywidgets import interact, Button, FloatSlider

def reset_button(defaults={}):
    def on_button_clicked(_):
        for k, v in defaults.items(): 
            k.value = v
    button = Button(description='Reset')
    button.on_click(on_button_clicked)
    display(button)

# Code ----
slider1 = FloatSlider(min=0, max=np.pi*0.9, value=0, step=np.pi*0.1)
slider2 = FloatSlider(min=np.pi, max=2*np.pi, value=2*np.pi, step=np.pi*0.1)
reset_button(defaults={slider1: 0, slider2: 2*np.pi})

@interact(starts_at=slider1, ends_at=slider2)
def plot_graph(starts_at, ends_at):
    f = lambda x : sum(1/a*np.sin(a*x + np.pi/a) for a in range(1,6))
    x = np.linspace(0, 2*np.pi, 1000)
    plt.plot(x, f(x))
    plt.xlim([starts_at, ends_at])

Building from @iperetta's answer you can create a simple decorator that will add a reset button for each use:

Reset button as per above answer:

def reset_button(defaults={}):
    def on_button_clicked(_):
        for k, v in defaults.items(): 
            k.value = v
    button = widgets.Button(description='Reset')
    button.on_click(on_button_clicked)
    display(button)

Decorator:

def interact_plus_reset(**_widgets):
  default_vals = {wid:wid._trait_values['value'] for k, wid in _widgets.items()}
  reset_button(defaults=default_vals)
  def wrap(func):
    @wraps(func)
    @widgets.interact(**_widgets)
    def inner(*args, **kwargs):
      return func(*args, **kwargs)
    return inner
  return wrap

Then use it as below:

@interact_plus_reset(
  a = widgets.FloatSlider(min=1, max=2000, value=35, step=1), 
  b = widgets.FloatSlider(min=1, max=2000, value=1000, step=1), 
)
def run(a, b):
  print(a + b)
Related