Search code examples
pythonbuttoneventsbreak

Kill a loop with Button Jupyter Notebook?


I want to:

  • Read from serial port (infinite loop)
  • when "STOP" button pressed --> Stop reading and plot data

From How to kill a while loop with a keystroke? I have taken the example to interrupt using Keyboard Interrupt, This works, but i would like to use a button.

EXAMPLE WITH KEYBOARD INTERRUPT

weights = []
times = [] 
#open port 
ser = serial.Serial('COM3', 9600)
try:
   while True: # read infinite loop
       #DO STUFF
       line = ser.readline()   # read a byte string
       if line:
           weight_ = float(line.decode())  # convert the byte string to a unicode string
           time_ = time.time()
           weights.append(weight_)
           times.append(time_)
           print (weight_)
#STOP it by keyboard interup and continue with program 
except KeyboardInterrupt:
   pass
#Continue with plotting

However I would like to do it with a displayed button (easier for people to use). I have tried making a button (in Jupiter Notebook) that when pressed break_cicle=False, but the loop doesn't break when button pressed:

 #make a button for stopping the while loop 
button = widgets.Button(description="STOP!") #STOP WHEN THIS BUTTON IS PRESSED
output = widgets.Output()
display(button, output)
break_cicle=True


def on_button_clicked(b):
    with output:
        break_cicle = False # Change break_cicle to False
        print(break_cicle)
        
ser.close()   
button.on_click(on_button_clicked)
ser = serial.Serial('COM3', 9600)
try:
    while break_cicle:

        print (break_cicle)
        line = ser.readline()   # read a byte string
        if line:
            weight_ = float(line.decode())  # convert the byte string to a unicode string
            time_ = time.time()
            weights.append(weight_)
            times.append(time_)
            print (weight_)
except :
    pass

ser.close()    

EXAMPLE WITH GLOBAL NOT WORKING

from IPython.display import display
import ipywidgets as widgets

button = widgets.Button(description="STOP!") #STOP WHEN THIS BUTTON IS PRESSED
output = widgets.Output()
display(button, output)
break_cicle=True

def on_button_clicked():
    global break_cicle #added global
    with output:
        
        break_cicle = False # Change break_cicle to False
        print ("Button pressed inside break_cicle", break_cicle)
    
    
button.on_click(on_button_clicked)
try:
    while break_cicle:
        print ("While loop break_cicle:", break_cicle)
        time.sleep(1)
except :
    pass
print ("done")

Despite me pressing the button a few times,from the following image you can see that it never prints "Button pressed inside break_cicle".

enter image description here


Solution

  • I think problem is like in all Python scripts with long-running code - it runs all code in one thread and when it runs while True loop (long-running code) then it can't run other functions at the same time.

    You may have to run your function in separated thread - and then main thread can execute on_button_clicked

    This version works for me:

    from IPython.display import display
    import ipywidgets as widgets
    import time
    import threading
    
    button = widgets.Button(description="STOP!") 
    output = widgets.Output()
    
    display(button, output)
    
    break_cicle = True
    
    def on_button_clicked(event):
        global break_cicle
        
        break_cicle = False
    
        print("Button pressed: break_cicle:", break_cicle)
        
    button.on_click(on_button_clicked)
    
    def function():
        while break_cicle:
            print("While loop: break_cicle:", break_cicle)
            time.sleep(1)
        print("Done")
        
    threading.Thread(target=function).start()
    

    Maybe Jupyter has some other method for this problem - ie. when you write functions with async then you can use asyncio.sleep() which lets Python to run other function when this function is sleeping.


    EDIT:

    Digging in internet (using Google) I found post on Jyputer forum

    Interactive widgets while executing long-running cell - JupyterLab - Jupyter Community Forum

    and there is link to module jupyter-ui-poll which shows similar example (while-loop + Button) and it uses events for this. When function pull() is executed (in every loop) then Jupyter can send events to widgets and it has time to execute on_click().

    import time
    from ipywidgets import Button
    from jupyter_ui_poll import ui_events
    
    # Set up simple GUI, button with on_click callback
    # that sets ui_done=True and changes button text
    ui_done = False
    def on_click(btn):
        global ui_done
        ui_done = True
        btn.description = '👍'
    
    btn = Button(description='Click Me')
    btn.on_click(on_click)
    display(btn)
    
    # Wait for user to press the button
    with ui_events() as poll:
        while ui_done is False:
            poll(10)          # React to UI events (upto 10 at a time)
            print('.', end='')
            time.sleep(0.1)
    print('done')
    

    In source code I can see it uses asyncio for this.


    EDIT:

    Version with multiprocessing

    Processes don't share variables so it needs Queue to send information from one process to another.

    Example sends message from button to function. If you would like to send message from function to button then better use second queue.

    from IPython.display import display
    import ipywidgets as widgets
    import time
    import multiprocessing
    
    button = widgets.Button(description="STOP!") 
    output = widgets.Output()
    
    display(button, output)
    
    queue = multiprocessing.Queue()
    
    def on_button_clicked(event):
        queue.put('stop')
        print("Button pressed")
        
    button.on_click(on_button_clicked)
    
    def function(queue):
        
        while True:
            print("While loop")
            time.sleep(1)
            
            if not queue.empty():
                msg = queue.get()
                if msg == 'stop':
                    break
                #if msg == 'other text':             
                #    ...other code...
                
        print("Done")
        
    multiprocessing.Process(target=function, args=(queue,)).start()
    

    or more similar to previous

    def function(queue):
    
        break_cicle = True
        
        while break_cicle:
            print("While loop: break_cicle:", break_cicle)
            time.sleep(1)
            
            if (not queue.empty()) and (queue.get() == 'stop'):
                break_cicle = False
            
        print("Done")
    

    EDIT:

    Version with asyncio

    Jupyter already is running asynio event loop and I add async function to this loop. And function uses await functions like asyncio.sleep so asynio event loop has time to run other functions - but if function could run only standard (not async) functions then it wouldn't work.

    from IPython.display import display
    import ipywidgets as widgets
    import asyncio
    
    button = widgets.Button(description="STOP!") 
    output = widgets.Output()
    
    display(button, output)
    
    break_cicle = True
    
    def on_button_clicked(event):
        global break_cicle
        
        break_cicle = False
    
        print("Button pressed: break_cicle:", break_cicle)
        
    button.on_click(on_button_clicked)
    
    async def function():   # it has to be `async`
        while break_cicle:
            print("While loop: break_cicle:", break_cicle)
            await asyncio.sleep(1)   # it needs some `await` functions
        print("Done")
        
    loop = asyncio.get_event_loop()    
    t = loop.create_task(function())  # assign to variable if you don't want to see `<Task ...>` in output