HZK
@HZK (Ritoku Sakamae)

Are you sure you want to delete the question?

Leaving a resolved question undeleted may help others!

Python:アナログ時計の連続秒針は、累積演算子では無理?

解決したいこと

tkinterでアナログ時計の勉強をしています。
datetimeクラスで現在の時刻を入手し、それを角度に変換してアナログ時計にすることは成功しました。ただし、秒針においては1秒毎のステップ秒針でした。

連続秒針に改良するにあたり、まずは、microsecondを含めての角度計算で連続秒針にすることはできました。そこでより円滑な動きを目指し、秒針の角速度を累積演算子で処理することで、実現できないか試してみました。

発生している問題・エラー

正確に動作せず、時間経過とともに暴走します。
print()で確認しましたが、ラジアンの累積は正しいようです。10進数や、float()の計算誤差から生じているのか、πが無限数であることに起因する問題なのか、原因が理解できません。また、この方法では連続秒針の実現は無理なのでしょうか。
原因・解決策など、教えて頂ければ助かります。

該当するソースコード

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import tkinter
import math
from datetime import datetime, timedelta, timezone

# キャンバスのサイズの設定
CANVAS_WIDTH = 400
CANVAS_HEIGHT = CANVAS_WIDTH
CANVAS_SIZE = CANVAS_WIDTH
L_HAND = CANVAS_SIZE / 2 * 0.8
BG_COLOR = "white"
FG_COLOR = "gray"

# 時計の盤面を表す円の半径の設定
CLOCK_OVAL_RADIUS = CANVAS_SIZE / 2
# 時計の数字の位置の設定(中心からの距離)
DISTANCE_NUMBER = CANVAS_SIZE / 2 * 0.9
# キャンバスの中心座標
center_x = CANVAS_WIDTH / 2
center_y = CANVAS_HEIGHT / 2
x0, y0 = center_x, center_y

# 秒点を再配置する時間の間隔
TIME_INTERVAL = 300
# 秒点を再配置する角度の間隔
RAD_INTERVAL = (1/100)*math.pi
rad = 0

class Timer:
    '''時刻を取得するクラス'''
    def __init__(self):
        # タイムゾーンの設定
        self.JST = timezone(timedelta(hours=9))

    def time(self):
        # 時刻の取得
        now = datetime.now(tz=self.JST)
        # 時・分・秒にわけて返却
        return now.hour, now.minute, now.second

class Setup:
    def __init__(self, master):
        # 各種設定を行なった後に時計の盤面を描画
        self.initSetting(master)
        self.setClock()

    def initSetting(self, master):
        '''時計描画に必要な設定を行う'''
        self.master = master
        self.llength =  L_HAND
       
    def setClock(self):
        # キャンバスを作成して配置する
        self.canvas = tkinter.Canvas(
            self.master,
            width=CANVAS_WIDTH,
            height=CANVAS_HEIGHT,
            highlightthickness=0,
        )
        self.canvas.pack()

        # 時計の盤面を表す円を描画する
        x1 = x0 - CLOCK_OVAL_RADIUS
        y1 = y0 - CLOCK_OVAL_RADIUS
        x2 = x0 + CLOCK_OVAL_RADIUS
        y2 = y0 + CLOCK_OVAL_RADIUS

        self.canvas.create_oval(
            x1, y1, x2, y2,
            fill=BG_COLOR,
            width=2,
            outline=FG_COLOR)

        # 時計の盤面上に、数字の代わりに点を描画する 
        for hour in range(1, 13):
            # 角度を計算
            angle = (hour * 360)/12 - 90
            # 描画位置を計算
            dx = DISTANCE_NUMBER * math.cos(math.radians(angle))
            dy = DISTANCE_NUMBER * math.sin(math.radians(angle))
            x2 = x0 + dx
            y2 = y0 + dy
            self.canvas.create_oval(x2, y2, x2+10, y2+10, fill="#0fffff")

    def setHands(self, second):
        '''針を表現する点を描画する'''
        global rad
        ang_sec = (second * 360)/60 - 90      
        rad = math.radians(ang_sec)
        x2 = x0 + self.llength * math.cos(rad)
        y2 = y0 + self.llength * math.sin(rad)  
        self.canvas.create_oval(x2, y2, x2+10, y2+10, fill="red", tag="point1")

    def rotateHands(self):
        global rad
        x2 = x0 + self.llength * math.cos(rad)
        y2 = y0 + self.llength * math.sin(rad)
        
        # coordsメソッドにより描画済みの線の座標を変更する
        self.canvas.coords("point1", x2, y2, x2+10, y2+10)
        # 描画する角度を増加
        rad += RAD_INTERVAL
        self.canvas.after(TIME_INTERVAL, self.rotateHands)        
class AnalogClock:
    def __init__(self, master):
        # after実行用にウィジェットのインスタンスを保持
        self.master = master

        # 各種クラスのオブジェクトを生成
        self.timer = Timer()
        self.setup = Setup(master)
        # 針を描画
        self.draw()
        # 1秒後に針を進めるループを開始
        self.master.after(1000, self.update)

    def draw(self):
        '''時計の針を描画する'''
        # 時刻を取得し、その時刻に合わせて針を描画する
        hour, minute, second = self.timer.time()
        self.setup.setHands(second)

    def update(self):
        '''時計の針を進める'''
        self.setup.rotateHands()
        self.master.after(TIME_INTERVAL, self.update)
 
if __name__ == "__main__":
    app = tkinter.Tk()
    AnalogClock(app)
    app.mainloop()
0

1Answer

大きな値になるのが原因ならば、剰余を取って値の範囲を制限してはいかがですか?

        rad = (rad + RAD_INTERVAL) % (360 * math.pi)

それと、グローバル変数ではなく、インスタンス変数 self.head_rad などにしてはいかがですか?

追記:
Setup.rotateHands(self) にある self.canvas.after(TIME_INTERVAL, self.rotateHands) を削除したらうまく動作しました。

追記:
正確な秒針位置を描画するならマイクロ秒を使って描画してはいかがでしょう?

#!/usr/bin/env python3

import tkinter
import math
from datetime import datetime, timedelta, timezone


class Clock:  # Model

    def __init__(self, tz=timezone(timedelta(hours=9))):
        self.tz = tz

    def now(self):
        now = datetime.now(tz=self.tz)
        return now.hour, now.minute, now.second

    def now_micro(self):
        now = datetime.now(tz=self.tz)
        return now.hour, now.minute, now.second, now.microsecond


class AnalogClockView:  # View
    BG_COLOR = "white"
    FG_COLOR = "gray"

    def __init__(self, master, size=400):
        """各種設定を行なった後に時計の盤面を描画"""
        self._size = size
        self._x0 = size / 2
        self._y0 = size / 2
        self._x1 = 0
        self._y1 = 0
        self._x2 = size
        self._y2 = size
        self._distance_second = size / 2 * 0.8
        self._distance_number = size / 2 * 0.9
        self._init_canvas(master)
        self._init_frame()
        self._init_numbers()
        self._init_handles()

    def _init_canvas(self, master):
        """キャンバスを作成して配置する"""
        self.canvas = tkinter.Canvas(
            master,
            width=self._size,
            height=self._size,
            highlightthickness=0,
        )
        self.canvas.pack()

    def _init_frame(self):
        """時計の盤面を表す円を描画する"""
        self.canvas.create_oval(
            self._x1, self._y1, self._x2, self._y2,
            fill=self.BG_COLOR,
            width=2,
            outline=self.FG_COLOR)

    def _init_numbers(self):
        """時計の盤面上に、数字の代わりに点を描画する"""
        for hour in range(1, 13):
            angle = math.radians(hour * 360 / 12 - 90)
            x = self._x0 + self._distance_number * math.cos(angle)
            y = self._y0 + self._distance_number * math.sin(angle)
            self.canvas.create_oval(x-5, y-5, x+5, y+5, fill="#0fffff")

    def _init_handles(self, hour=0, minute=0, second=0):
        """針を表現する点を描画する"""
        angle = math.radians(second * 360 / 60 - 90)
        x = self._x0 + self._distance_second * math.cos(angle)
        y = self._y0 + self._distance_second * math.sin(angle)
        self.canvas.create_oval(x-5, y-5, x+5, y+5, fill="red", tag="second")

    def draw(self, hour, minute, second, microsecond=0):
        """針を移動する"""
        angle = math.radians((second + microsecond / 1000000) * 360 / 60 - 90)
        x = self._x0 + self._distance_second * math.cos(angle)
        y = self._y0 + self._distance_second * math.sin(angle)
        self.canvas.coords("second", x-5, y-5, x+5, y+5)


def main(interval=50):  # Controller
    app = tkinter.Tk()
    clock = Clock()
    view = AnalogClockView(app)
    def update():
        view.draw(*clock.now_micro())  # or view.draw(*clock.now())
        app.after(interval, update)
    update()
    app.mainloop()


if __name__ == "__main__":
    main()
0Like

Comments

  1. @HZK

    Questioner

    助言を頂きありがとうございます。
    (1)剰余をとって値を制限する方法を、下記コードと理解して試しましたがダメでした。
    rad = (rad + RAD_INTERVAL) % (2 * math.pi)
    やはりうまくいきません。誤差が出るとしても、最初の1分で1周ぐらいはしたいです。
    (2)インスタンス変数にする件は、その意味が理解できませんでした。申し訳ありません。
    (3)計算の根拠は、1周60秒=60000msec :2 pi(rad) だから、300msec:0.01pi(rad)ずつの更新で正しいと思うのですが、どこにミスがあるのかが分からないです。
  2. % (360 * math.pi) では グルグル回って見えましたよ。
    回転が徐々に速くなってそのうち逆回転になります。
    もしかして、そういう動作にしたいわけではないのかな?
  3. 累積演算では正確な秒針位置を描画できないので、マイクロ秒を使って描画するコードを追記しました。
  4. @HZK

    Questioner

    さらに参考コードも示して頂き感謝します。やはりdatetime()クラスのマイクロ秒を使うしかないみたいですね。
     円運動を表現するだけなら累積演算を使う方が簡単で動きも円滑なので、この方式をそのままアナログ時計にも流用できないかと軽く考えたのがハマりでした。
     時計は12、24、60進法、そしてマイクロ秒で10進法が混ざっていて、カウントが増えるにつれてPC上で誤差が拡大する現象は理解していたつもりでした。
     しかし、1分も保たずに暴走する理由や制御法が分かれば、プログラムをより深く理解できるきっかけにと思って、質問させて頂きました。m(__)m
  5. よく見ると、AnalogClock.update() で self.setup.rotateHands() を呼び出してますが、updateでも rotateHandsでも self.master.after() を呼び出していますね。
    これにより、afterによる再描画依頼が徐々に増えて、radの値が加速度的に増えています。
    rotateHands()のafter呼び出しを削除してみたところ期待通りに描画しているようです。
  6. @HZK

    Questioner

    まったくご指摘の通りでした。「プログラミングのエラーは100%人間が原因」との格言を地でいってしまいました。恥ずかしい限りです。また、改めて見直したところ、setHands()にreturn radが抜けており、最初の秒点の位置が同期できていませんでした。何度も助言を頂き、本当にありがとうございました。

Your answer might help someone💌