6
\$\begingroup\$

I made a python analog clock program with turtle. The problem is, it takes about 1.4 seconds to loop, so it only moves the second hand every 1.4 seconds. Its probably cuz my computer is slow...

from turtle import *
import time
from datetime import datetime

screen = Screen()
screen.title("Clock")
screen.setup(425, 425)

second_hand = Turtle()
minute_hand = Turtle()
hour_hand = Turtle()

second_hand.pensize(1)
second_hand.hideturtle()
second_hand.speed(10)
second_hand.color("red")
second_hand.left(90)

minute_hand.pensize(2)
minute_hand.hideturtle()
minute_hand.speed(10)
minute_hand.left(90)

hour_hand.pensize(4)
hour_hand.hideturtle()
hour_hand.speed(10)
hour_hand.left(90)

while True:
    start = datetime.now()

    second_hand.right(6)
    second_hand.forward(200)
    second_hand.backward(200)
    
    minute_hand.right(.06)
    minute_hand.forward(190)
    minute_hand.backward(190)
    
    hour_hand.right(.006)
    hour_hand.forward(135)
    hour_hand.backward(135)
    
    time.sleep(1)
    
    second_hand.clear()
    minute_hand.clear()
    hour_hand.clear()
    
    end = datetime.now()
    
    total = end - start
    
    print (total.total_seconds())
\$\endgroup\$
2
  • 1
    \$\begingroup\$ I'm not familiar with turtle, but what's the point of going forward and backward the same amount? \$\endgroup\$
    – pinegulf
    Commented Apr 19, 2022 at 8:17
  • 1
    \$\begingroup\$ @pinegulf cuz all 3 lines have to start from the same point. \$\endgroup\$
    – Kovy Jacob
    Commented Apr 19, 2022 at 9:24

3 Answers 3

6
\$\begingroup\$

Avoid import *; there's only a few symbols you need from turtle.

There's a lot of repetition in your hand logic. Consider factoring out a class that does the common operations, and derive from this class for varying behaviours and attributes in each of your hands.

Do not speed(10). Under realistic (i.e. non-beginner, non-debug) circumstances, the speed should be fastest, and tracing should be disabled so that updates are required to be explicit and flicker is removed.

Do not left(90), for several reasons. First, this is a relative rotation when you should start with an absolute setheading. Also, this assumes the use of the default coordinate system, which does not suit your application. You should set up a different world coordinate system where "zero degrees" points north, and positive rotation is clockwise. It would be nice for this coordinate system to be normalised - coordinates on the order of 1 - independent of the window pixel dimensions.

Do not right(.06). This assumes that the hand had been pointing at the correct direction in the previous frame, and also assumes that the exact correct amount of time has elapsed. Instead, do an absolute setheading call based on the current time.

Similarly, do not backward(). Just return to the origin with goto(0, 0). Do this at the beginning of your draw routine rather than at the end: if, instead of hiding the cursor you decide to show the cursor, your hand ends will change from bare:

bare hands

to decorated:

decorated hands

This is not possible if you return to the origin between drawing and updating.

Do not time.sleep. turtle has its own event scheduler that you should use.

Suggested

from turtle import Turtle, Screen, ontimer, mainloop
from datetime import datetime, time


class Hand:
    def __init__(self, pensize: int, color: str, degrees: float, length: float) -> None:
        t = Turtle(undobuffersize=0, visible=False)
        t.speed('fastest')
        t.color(color)
        t.pensize(pensize)
        t.degrees(degrees)
        self.turtle = t
        self.length = length

    def draw(self, now: time) -> None:
        t = self.turtle
        t.clear()
        t.penup()
        t.goto(0, 0)
        t.pendown()
        t.setheading(self.angle_from_time(now))
        t.forward(self.length)

    @staticmethod
    def angle_from_time(now: time) -> float:
        raise NotImplementedError()


class SecondHand(Hand):
    def __init__(self) -> None:
        super().__init__(pensize=1, color='red', degrees=60, length=0.94)

    @staticmethod
    def angle_from_time(now: time) -> float:
        return now.second + now.microsecond / 1e6


class MinuteHand(Hand):
    def __init__(self) -> None:
        super().__init__(pensize=2, color='black', degrees=60, length=0.89)

    @staticmethod
    def angle_from_time(now: time) -> float:
        return now.minute


class HourHand(Hand):
    def __init__(self) -> None:
        super().__init__(pensize=4, color='black', degrees=12, length=0.64)

    @staticmethod
    def angle_from_time(now: time) -> float:
        return now.hour


class Clock:
    def __init__(self, width: int = 425, update_ms: int = 200) -> None:
        s = Screen()
        s.setup(width, width)

        # 0 degrees points north; positive rotation is clockwise
        s.setworldcoordinates(llx=-1, lly=1, urx=1, ury=-1)

        s.title('Clock')
        s.tracer(n=0)
        self.screen = s
        self.update_ms = update_ms
        self.hands = (SecondHand(), MinuteHand(), HourHand())

    def update(self) -> None:
        now = datetime.now().time()
        for hand in self.hands:
            hand.draw(now)

        self.screen.update()
        ontimer(self.update, t=self.update_ms)


if __name__ == '__main__':
    Clock().update()
    mainloop()
\$\endgroup\$
3
\$\begingroup\$

I found the correct way to optimize your code: the drawing code takes some time, so you should not wait 1 full second, but only wait until the time elapsed from the start of the loop is one second:

while (datetime.now() - start) <= timedelta(seconds=1):
    time.sleep(0.001)

This will sleep until one second has passed also taking into account the time needed to draw.


The above is "correct" but consumes too much cpu, a better solution is fixing this inefficiency as suggested by @Reinderien:

this_delta = timedelta(seconds=1) - (datetime.now() - start)
time.sleep(this_delta.microseconds / 10**6)

This still ensures a precise timing (about 1/1000 fluctuation) but sleeps more time continuously thus being more efficient on the cpu.

Here is the final code:

from turtle import *
import time
from datetime import datetime
from datetime import timedelta

screen = Screen()
screen.title("Clock")
screen.setup(425, 425)

second_hand = Turtle()
minute_hand = Turtle()
hour_hand = Turtle()

second_hand.pensize(1)
second_hand.hideturtle()
second_hand.speed(10)
second_hand.color("red")
second_hand.left(90)

minute_hand.pensize(2)
minute_hand.hideturtle()
minute_hand.speed(10)
minute_hand.left(90)

hour_hand.pensize(4)
hour_hand.hideturtle()
hour_hand.speed(10)
hour_hand.left(90)

while True:
    start = datetime.now()

    second_hand.clear()
    minute_hand.clear()
    hour_hand.clear()
    
    second_hand.right(6)
    second_hand.forward(200)
    second_hand.backward(200)
    
    minute_hand.right(.06)
    minute_hand.forward(190)
    minute_hand.backward(190)
    
    hour_hand.right(.006)
    hour_hand.forward(135)
    hour_hand.backward(135)
    
    

   

    this_delta = timedelta(seconds=1) - (datetime.now() - start)
    time.sleep(this_delta.microseconds / 10**6)    


    end = datetime.now()
    
    total = end - start
    
    print (total.total_seconds())

With output:

1.000664
1.000044
1.000586
1.001077
1.000444
1.00022
1.000629
1.000259
1.000187
1.000076
1.000346
1.000663
1.000321
1.001181
1.000134
...
\$\endgroup\$
4
  • 1
    \$\begingroup\$ @Reinderien thanks for your input, I wanted to try to improve the precision of the timing. Another solution would be to wait the remainder of the time left \$\endgroup\$
    – Caridorc
    Commented Apr 19, 2022 at 15:17
  • 1
    \$\begingroup\$ @Reinderien I updated the answer to contain a more efficient solution \$\endgroup\$
    – Caridorc
    Commented Apr 19, 2022 at 15:24
  • 2
    \$\begingroup\$ This is still not very good. You could have perfect timing! Why limit yourself to 99.9% accuracy? Instead of trying to sleep 1 second every tick, you should try to sleep until N seconds on the Nth tick. So on the first tick you calculate how long to sleep to make it 1 second since the start of the program. On the second tick you calculate how long to sleep to make it 2 seconds since the start of the program (which should be 1 second after the first tick), and so on. This way, the inaccuracy in the program cancels out instead of getting bigger. \$\endgroup\$ Commented Apr 19, 2022 at 18:56
  • \$\begingroup\$ @user253751 Cool idea, you should post it in your own answer \$\endgroup\$
    – Caridorc
    Commented Apr 21, 2022 at 22:55
1
\$\begingroup\$

Waiting 1 second at a time will never work well. The time in between sleep calls is not counted, so every tick will be slightly longer than 1 second, no matter what you do.

Instead you should track when the next tick is supposed to happen, and sleep until that time. Then, inaccuracy will cancel out. Your program will always average exactly 1 second per tick (as measured by your computer clock).

Something like this:

# before the loop
next_tick_time = datetime.now()

# in the loop. Not sure if this exact line works
next_tick_time += timedelta(seconds=1)
sleep_seconds = (next_time - datetime.now()).total_seconds()
if sleep_seconds > 0:
    os.sleep(sleep_seconds)

Notice how we don't sleep 1 second. Instead we calculate how long until the next tick, and then sleep that long. And the ticks are always 1 second apart. Even if the program sleeps slightly too long on one tick, it will compensate by sleeping less on the next tick.

Note that this won't work properly if the user changes the computer's time while the program is running. Solving that is left as an exercise for the reader - you will need to count ticks in monotonic time, but still display the regular, non-monotonic time.

\$\endgroup\$

Not the answer you're looking for? Browse other questions tagged or ask your own question.