Friday, August 31, 2012

Tkinter Pendulum Demo

This code is based on the Tcl pendulum.tcl demo. It presents a simple, rotational, pendulum; sets it in motion and draws the pendulum's phase space. The user can change the position of the pendulum by clicking in the Pendulum Simulation panel.



# File: pendulum.py
#    http://infohost.nmt.edu/tcc/help/pubs/tkinter//canvas.html
# Other Reference:
#    http://rosettacode.org/wiki/Animate_a_pendulum#Python

import math

from tkinter import *
from tkinter import ttk
from demopanels import MsgPanel, SeeDismissPanel

class PendulumDemo(ttk.Frame):
    
    def __init__(self, isapp=True, name='pendulumdemo'):
        ttk.Frame.__init__(self, name=name)
        self.pack(expand=Y, fill=BOTH)
        self.master.title('Pendulum Demo')
        self.isapp = isapp
        self._create_widgets()
        
    def _create_widgets(self):
        if self.isapp:
            MsgPanel(self, 
                     ["This demonstration shows how Tkinter can be used to carry out animations ",
                      "that are linked to simulations of physical systems. In the left canvas ",
                      "is a graphical representation of the physical system itself, a simple ",
                      "pendulum, and in the right canvas is a graph of the phase space of the ",
                      "system, which is a plot of the angle (relative to the vertical) against ",
                      "the angular velocity. The pendulum bob may be repositioned by clicking ",
                      "and dragging anywhere on the left canvas."])
            
            SeeDismissPanel(self)
        
        self._create_demo_panel()
        
    def _create_demo_panel(self):
        demoPanel = ttk.Frame(self, name='demo')
        demoPanel.pack(side=TOP, fill=BOTH, expand=Y)
        
        # variables used by the widgets
        self.__theta = 45.0 # pendulum angle
        self.__dtheta = 0.0 # new pendulum angle
        self.__length = 150 # rod length
        self.__home = 160   # rod 'attach' position
        
        self.__points = []   # coords describing phase motion
        self.__psw = 320/2   # phase space panel width
        self.__psh = 200/2   # phase space panel height
        
        # tag and colour names for the phase space oval which
        #  is drawn in segments to allow colour variations
        # (in a dict to avoid building/discarding strings during animation)
        self.__graph = {0: ('graph0','grey0'), 10: ('graph10','grey10'),
                        20: ('graph20', 'grey20'), 30: ('graph30','grey30'), 
                        40: ('graph40','grey40'), 50: ('graph50','grey50'),
                        60: ('graph60','grey60'), 70: ('graph70','grey70'),
                        80: ('graph80','grey80'), 90: ('graph90','grey90')}
                
        # build panels                
        pw = ttk.Panedwindow(demoPanel, orient=HORIZONTAL)
        pw.pack(side=TOP, fill=BOTH, expand=Y)

        self.__pen = self._create_pendulum_panel(pw)
        self.__phase = self._create_phase_panel(pw)
        self._show_pendulum()
        
        # add bindings
        self._create_bindings()
        
        # start animations after slight pause
        self.after(500, self._repeat)   

    # =====================================================================================
    # Animation handler
    # =====================================================================================
    def _repeat(self):
        self._recompute_angle() # define pendulum position and swing parameters
        self._show_pendulum()   # display pendulum
        self._show_phase()      # display phase space
        
        self.after(15, self._repeat)    # repeat animation

    # =====================================================================================
    # Bindings and bound methods
    # =====================================================================================
    
    def _create_bindings(self):
        # allow user to re-position pendulum with Left Mouse button click
        self.__pen.bind('<1>', self._change_pendulum)
        self.__pen.bind('<B1-Motion>', self._change_pendulum)
        self.__pen.bind('<ButtonRelease-1>', lambda e, repeat=True: self._change_pendulum(e, repeat))

        # capture window resize and resize phase space panel
        self.__phase.bind('<Configure>', self._phase_configure)

    def _change_pendulum(self, evt, repeat=False):
        # triggered when user clicks in 'Pendulum Simulation' panel
        # re-positions the pendulum bob and resizes the rod
        self._show_pendulum(evt.x, evt.y)
        
        if repeat:
            self.after(15, self._repeat)

    def _phase_configure(self, evt):
        # triggered when the user resizes the window
        # resizes the phase space panel
        width = self.__phase.winfo_width()
        height = self.__phase.winfo_height()
        self.__psh = height / 2
        self.__psw = width / 2
        self.__phase.coords('x_axis', 2, self.__psh, width-2, self.__psh )
        self.__phase.coords('y_axis', self.__psw, height-2, self.__psw, 2)
        self.__phase.coords('label_dtheta', self.__psw-4, 6)
        self.__phase.coords('label_theta', width-6, self.__psh+4)
                
    # =====================================================================================
    # Routines to build and control the pendulum
    # =====================================================================================                
    def _create_pendulum_panel(self, parent):
        
        left = ttk.Labelframe(parent, text="Pendulum Simulation")
        parent.add(left)
        
        # Create the canvas containing the graphical representation of the
        # simulated system

        c = Canvas(left, width=320, height=200, background='white',
                   bd=2, relief=SUNKEN, name='pen_canvas')
        c.create_text(5, 5, anchor='nw', text='Click to Adjust Bob Start Position',
                      state='disabled', disabledfill='grey50')
        c.create_line(0,25,320,25, tags='plate', fill='grey50', width=2)
        c.create_oval(155,20,165,30, tags='pivot', fill='grey50')
        c.create_line(1,1,1,1, tags='rod', fill='black', width=3)
        c.create_oval(1,1,2,2, tags='bob', fill='yellow', outline='black')
        c.pack(fill=BOTH,  expand=Y)
                
        return c
                
    def _show_pendulum(self, x=None, y=None):
        # display the pendulum
        if (x and y) and (x != self.__home or y != 25):
            self.__dtheta = 0.0
            x2 = x - self.__home
            y2 = y - 25
            self.__length = math.hypot(x2,y2)
            self.__theta = math.atan2(x2, y2) * 180/math.pi
        else:
            angle = math.radians(self.__theta)
            x = self.__home + self.__length * math.sin(angle)
            y = 25 + self.__length * math.cos(angle)
        
        self.__pen.coords('rod', self.__home, 25, x, y)
        self.__pen.coords('bob', x-15, y-15, x+15, y+15)


    def _recompute_angle(self):
        # core animation routine for a simple rotational
        # pendulum; recomputes the angle parameters
        # (the original Tcl file says there is a better
        #  way to do this but it requires a better knowledge
        #  of derivatives)
        
        scaling = 3000.0/self.__length/self.__length
        
        # first estimate
        firstDDtheta = -math.sin(math.radians(self.__theta)) * scaling
        midDtheta = self.__dtheta + firstDDtheta
        midtheta = self.__theta + (self.__dtheta + midDtheta)/2.0
 
        # second estimate
        midDDtheta = -math.sin(math.radians(midtheta)) * scaling
        midDtheta = self.__dtheta + (firstDDtheta + midDDtheta)/2.0
        midtheta = self.__theta + (self.__dtheta + midDtheta)/2.0

        # first double estimate
        midDDtheta = -math.sin(math.radians(midtheta)) * scaling
        lastDtheta = midDtheta + midDDtheta
        lasttheta = midtheta + (midDtheta + lastDtheta)/2.0
 
        # second double estimate
        lastDDtheta = -math.sin(math.radians(lasttheta)) * scaling
        lastDtheta = midDtheta + (midDDtheta + lastDDtheta)/2.0
        lasttheta = midtheta + (midDtheta + lastDtheta)/2.0                
        
        self.__theta = lasttheta
        self.__dtheta = lastDtheta
        
    # =====================================================================================
    # Routines to build and control the phase space
    # =====================================================================================                
    def _create_phase_panel(self, parent):
        
        right = ttk.Labelframe(parent, text="Phase Space")
        parent.add(right)
        
        # Create the canvas containing the phase space graph; this consists of
        # a line that gets gradually paler as it ages, which is an effective
        # visual trick.

        c = Canvas(right, width=320, height=200, background='white',
                   bd=2, relief=SUNKEN, name='phase_canvas')
        c.create_line(160,200,160,0, fill='grey75', arrow='last', tags='y_axis')
        c.create_line(0,100,320,100, fill='grey75', arrow='last', tags='x_axis')
        
        for i in range(90, -1, -10):
            c.create_line(0,0,1,1, smooth='true', tags=self.__graph[i][0], 
                          fill=self.__graph[i][1])
        
        c.create_text(0,0, anchor='ne', text='q', font=('Symbol', 8), 
                      tags='label_theta')
        c.create_text(0,0, anchor='ne', text='dq', font=('Symbol', 8), 
                      tags='label_dtheta')
        c.pack(fill=BOTH, expand=Y)
        
        return c

    def _show_phase(self):
        # Update the phase-space graph according to the current angle and the
        # rate at which the angle is changing (the first derivative with
        # respect to time.)

        self.__points.extend([self.__theta+self.__psw,
                              -20 * self.__dtheta+self.__psh])
    
        end = len(self.__points)
        if end > 100:   # start over
            self.__points = []

        pts = []
        for i in range(0,100,10):
            pts = self.__points[end-i:end-i-12]
            if len(pts) >= 4:  # coords for affected portion of the line
                self.__phase.coords(self.__graph[i][0], *pts)
                

if __name__ == '__main__':
    PendulumDemo().mainloop()