2

I started using a Python script for a stopwatch today and noticed a significant slow down in all the other things I have opened (Firefox, Sublime Text, Terminal). System Monitor is telling me my stopwatch script is using about 24% of my CPU. Seems odd that something so trivial uses that much resource.

Can I please get some pointers on how to improve this? I'd really like to run it in the background and keep track of my time spent on various things.

Here is the scripts:

#! /usr/bin/env python3
import tkinter
import time
import datetime
import numpy as np 
import subprocess

class StopWatch(tkinter.Frame):

    @classmethod
    def main(cls):
        tkinter.NoDefaultRoot()
        root = tkinter.Tk()
        root.title('Stop Watch')
        root.resizable(False, False)
        root.grid_columnconfigure(0, weight=1)
        root.geometry("200x235")
        padding = dict(padx=5, pady=5)
        widget = StopWatch(root, **padding)
        widget.grid(sticky=tkinter.NSEW, **padding)
        icon = tkinter.PhotoImage(file='stopwatch.ico')
        root.tk.call('wm', 'iconphoto', root._w, icon)
        root.mainloop()

    def __init__(self, master=None, cnf={}, **kw):
        padding = dict(padx=kw.pop('padx', 5), pady=kw.pop('pady', 5))
        super().__init__(master, cnf, **kw)

        self.grid_columnconfigure(0,weight=1)

        self.__total = 0
        self.start_time=datetime.datetime.now().strftime("%H:%M")
        self.start_date=datetime.datetime.now().strftime("%m/%d/%Y")
        self.start_dt=tkinter.StringVar(self, self.start_time+" "+self.start_date)

        self.__label = tkinter.Label(self, text='Session Time:')
        self.__time = tkinter.StringVar(self, '00:00')
        self.__display = tkinter.Label(self, textvariable=self.__time,font=(None, 26),height=2)
        self.__button = tkinter.Button(self, text='Start', relief=tkinter.RAISED, bg='#008000', activebackground="#329932", command=self.__click)
        self.__record = tkinter.Button(self, text='Record', relief=tkinter.RAISED, command=self.__save)
        self.__startdt = tkinter.Label(self, textvariable=self.start_dt)

        self.__label.grid   (row=0, column=0, sticky=tkinter.NSEW, **padding)
        self.__display.grid (row=1, column=0, sticky=tkinter.NSEW, **padding)
        self.__button.grid  (row=2, column=0, sticky=tkinter.NSEW, **padding)
        self.__record.grid  (row=3, column=0, sticky=tkinter.NSEW, **padding)
        self.__startdt.grid (row=4, column=0, sticky=tkinter.N, **padding)

    def __click(self):
        if self.__total==0:
            self.start_time=datetime.datetime.now().strftime("%H:%M")
            self.start_date=datetime.datetime.now().strftime("%m/%d/%Y")
            self.__time.set(self.start_time+" "+self.start_date)
        if self.__button['text'] == 'Start':
            self.__button['text'] = 'Stop'
            self.__button['bg']='#ff0000'
            self.__button['activebackground']='#ff3232'
            self.__record['text']='Record'
            self.__record['state']='disabled'
            self.__record['relief']=tkinter.SUNKEN
            self.__start = time.clock()
            self.__counter = self.after_idle(self.__update)
        else:
            self.__button['text'] = 'Start'
            self.__button['bg']='#008000'
            self.__button['activebackground']='#329932'
            self.__record['state']='normal'
            self.__record['relief']=tkinter.RAISED
            self.after_cancel(self.__counter)

    def __save(self):
        duration = int(self.__total//60)
        if duration > 0:
            subprocess.call("cp test_data.dat ./backup", shell=True)
            data = np.loadtxt('test_data.dat', dtype="str")

            time_data = data[:, 0]
            date_data = data[:, 1]
            duration_data = data[:, 2]

            time_data=np.append(time_data,self.start_time)
            date_data=np.append(date_data,self.start_date)
            duration_data=np.append(duration_data,str(duration))

            new_data=np.column_stack((time_data,date_data,duration_data))
            np.savetxt('test_data.dat', new_data, header="*Time* | *Date* | *Duration*", fmt="%s")

            self.__record['text']='Saved'
        else:
            self.__record['text']='Not Saved'

        self.start_time=datetime.datetime.now().strftime("%H:%M")
        self.start_date=datetime.datetime.now().strftime("%m/%d/%Y")
        self.__time.set(self.start_time+" "+self.start_date)
        self.__total=0
        self.__time.set('00:00')

        self.__record['state']='disabled'
        self.__record['relief']=tkinter.SUNKEN


    def __update(self):
        now = time.clock()
        diff = now - self.__start
        self.__start = now
        self.__total += diff
        mins,secs=divmod(self.__total,60)
        self.__time.set('{:02.0f}:{:02.0f}'.format(mins,secs))
        self.start_dt.set(datetime.datetime.now().strftime("%H:%M %m/%d/%Y"))
        self.__counter = self.after_idle(self.__update)

if __name__ == '__main__':
    StopWatch.main()

Minh Tran
  • 55
  • 6

2 Answers2

6

How to prevent the processor from going nuts on polling time

In your snippet:

def __update(self):
    now = time.clock()
    diff = now - self.__start
    self.__start = now
    self.__total += diff
    mins,secs=divmod(self.__total,60)
    self.__time.set('{:02.0f}:{:02.0f}'.format(mins,secs))
    self.start_dt.set(datetime.datetime.now().strftime("%H:%M %m/%d/%Y"))
    self.__counter = self.after_idle(self.__update)

You have the function rerun itself on idle without any limitation. That means your processor will spend each and every moment on idle to update the time. This will lead to a processor load of nearly 100%. Since it uses only one out of four cores, you'll see your (nearly) 25%.

Simply use a "smart", variable while loop; the principle

If we'd use time.sleep(), since we are not using real processor clock time, we would have a slight deviation. The processor always needs a little time to process the command, so

time.sleep(1)

will actually be something like

time.sleep(1.003)

This would, without further actions, lead to accumulating deviation, however:

We can make the process smart. What I always do in desktop applications is to calibrate the sleep() after each second or minute, depending on the required precision. What a cycle uses as time to process is retracted from the next cycle, so there is never an accumulation of deviation.

In principle:

import time

seconds = 0 # starttime (displayed)
startt = time.time() # real starttime
print("seconds:", seconds)

wait = 1

while True:
    time.sleep(wait)
    seconds = seconds + 1 # displayed time (seconds)
    target = startt + seconds # the targeted time
    real = time.time() # the "real" time
    calibration = real - target # now fix the difference between real and targeted
    nextwait = 1 - calibration # ...and retract that from the sleep of 1 sec
    wait = nextwait if nextwait >= 0 else 1  # prevent errors in extreme situation
    print("correction:", calibration)
    print("seconds:", seconds)

Since you are using seconds as a unit, this seems sufficient. The additional burden: unmeasureable.

Running this snippet in terminal, you'll see both the displayed time and the fixed deviation:

seconds: 0
correction: 0.02682352066040039
seconds: 1
correction: 0.036485910415649414
seconds: 2
correction: 0.06434035301208496
seconds: 3
correction: 0.07763338088989258
seconds: 4
correction: 0.037987709045410156
seconds: 5
correction: 0.03364992141723633
seconds: 6
correction: 0.07647705078125
seconds: 7

Using after() instead of while?

Likewise, you can use Tkinters after() method, as described here, using the same trick with variable time to calibrate.


EDIT

On request: example using Tkinter's after() method

If you use a fixed looptime, you are:

  1. unavoidably waisting resources, since your loop time (time resolution) needs to be a small fraction of the displayed time unit.
  2. Even if you do, like your 200 ms, the displayed time will at times show a difference with real time of (nearly) 200ms, subsequently followed by a much too short jump to the next displayed second.

If you use after(), and want to use a variable time cycle, like in the non-gui example above, below an example, offering the exact same options as the snippet in your answer:

enter image description here

#!/usr/bin/env python3
from tkinter import *
import time

class TestWhile:

    def __init__(self):

        # state on startup, run or not, initial wait etc
        self.run = False
        self.showtime = 0
        self.wait = 1000
        # window stuff
        self.window = Tk()
        shape = Canvas(width=200, height=0).grid(column=0, row=0)
        self.showtext = Label(text="00:00:00", font=(None, 26))
        self.showtext.grid(column=0, row=1)
        self.window.minsize(width=200, height=50)
        self.window.title("Test 123(4)")
        # toggle button Run/Stop
        self.togglebutton = Button(text="Start", command = self.toggle_run)
        self.togglebutton.grid(column=0, row=2, sticky=NSEW, padx=5, pady=5)
        self.resetbutton = Button(text="reset", command = self.reset)
        self.resetbutton.grid(column=0, row=3, sticky=NSEW, padx=5, pady=5)
        self.window.mainloop()

    def format_seconds(self, seconds):
        mins, secs = divmod(seconds, 60)
        hrs, mins = divmod(mins, 60)
        return '{:02d}:{:02d}:{:02d}'.format(hrs, mins, secs)

    def reset(self):
        self.showtime = 0
        self.showtext.configure(text="00:00:00")

    def toggle_run(self):
        # toggle run
        if self.run:
            self.run = False
            self.togglebutton.configure(text="Run")
            self.showtime = self.showtime - 1
            self.resetbutton.configure(state=NORMAL)
        else:
            self.run = True
            self.togglebutton.configure(text="Stop")
            self.resetbutton.configure(state=DISABLED)
            # prepare loop, set values etc
            self.showtext.configure(text=self.format_seconds(self.showtime))
            self.fix = self.showtime
            self.starttime = time.time()
            # Let's set the first cycle to one second
            self.window.after(self.wait, self.fakewhile)

    def update(self):
        self.window.after(self.wait, self.fakewhile)
        self.showtext.configure(text=str(self.format_seconds(self.showtime)))
        self.targeted_time = self.starttime + self.showtime
        self.realtime = time.time() + self.fix
        diff = self.realtime - self.targeted_time
        self.wait = int((1 - diff) * 1000)
        print("next update after:", self.wait, "ms")

    def fakewhile(self):
        self.showtime = self.showtime + 1
        if self.run:
            self.update()


TestWhile()

Note

...that if you are updating GUI from a second thread in e.g. a Gtk application, you'd always need to update from idle.

Jacob Vlijm
  • 85,475
0

Thanks Jacob Vlijm for the guidance.

I tried to incorporate time.sleep() method into the previous code snippet. It didn't work at all. So I turned to tkinter after() method and re-wrote the code entirely. I'm going to leave the core of it here for whoever comes after and stumble upon this thread.

Using after() method and let script waits for 200ms before calling the function again frees up my CPU and still allows for decently smooth Stopwatch.

EDIT: remove redundant bad codes. See Jacob's comment above if you are on the same quest for working timer scripts with tkinter.

Minh Tran
  • 55
  • 6