Pynamical: animated 3D phase diagrams of the logistic map

Citation info: Boeing, G. 2016. "Visual Analysis of Nonlinear Dynamical Systems: Chaos, Fractals, Self-Similarity and the Limits of Prediction." Systems, 4 (4), 37. doi:10.3390/systems4040037.

Pynamical documentation: http://pynamical.readthedocs.org

This notebook demonstrates how to make animated GIFs that pan and zoom around 3-D phase diagrams to visualize fractal data sets, strange attractors, and chaos.


In [1]:
import pynamical
from pynamical import simulate, phase_diagram_3d
import pandas as pd, numpy as np, matplotlib.pyplot as plt, random, glob, os, IPython.display as IPdisplay
from PIL import Image
%matplotlib inline

In [2]:
title_font = pynamical.get_title_font()
label_font = pynamical.get_label_font()

In [3]:
save_folder = 'images/phase-animate'

Create a 3-D phase diagram as an animated gif that pans, rotates, and zooms. This demonstrates how the viewing perspective is composed of an elevation, a distance, and an azimuth.


In [4]:
# set a filename, run the logistic model, and create the plot
gif_filename = '01-pan-rotate-zoom-demo'
working_folder = '{}/{}'.format(save_folder, gif_filename)
if not os.path.exists(working_folder):
    os.makedirs(working_folder)
    
pops = simulate(num_gens=1000, rate_min=3.99, num_rates=1)
fig, ax = phase_diagram_3d(pops, remove_ticks=False, show=False, save=False)

# create 36 frames for the animated gif
steps = 36

# a viewing perspective is composed of an elevation, distance, and azimuth
# define the range of values we'll cycle through for the distance of the viewing perspective
min_dist = 7.
max_dist = 10.
dist_range = np.arange(min_dist, max_dist, (max_dist-min_dist)/steps)

# define the range of values we'll cycle through for the elevation of the viewing perspective
min_elev = 10.
max_elev = 60.
elev_range = np.arange(max_elev, min_elev, (min_elev-max_elev)/steps)

# now create the individual frames that will be combined later into the animation
for azimuth in range(0, 360, int(360/steps)):
    
    # pan down, rotate around, and zoom out
    ax.azim = float(azimuth/3.)
    ax.elev = elev_range[int(azimuth/(360./steps))]
    ax.dist = dist_range[int(azimuth/(360./steps))]
    
    # set the figure title to the viewing perspective, and save each figure as a .png
    fig.suptitle('elev={:.1f}, azim={:.1f}, dist={:.1f}'.format(ax.elev, ax.azim, ax.dist))
    plt.savefig('{}/{}/img{:03d}.png'.format(save_folder, gif_filename, azimuth))
    
# don't display the static plot...
plt.close()

# load all the static images into a list then save as an animated gif
gif_filepath = '{}/{}.gif'.format(save_folder, gif_filename)
images = [Image.open(image) for image in glob.glob('{}/*.png'.format(working_folder))]
gif = images[0]
gif.info['duration'] = 75 #milliseconds per frame
gif.info['loop'] = 0 #how many times to loop (0=infinite)
gif.save(fp=gif_filepath, format='gif', save_all=True, append_images=images[1:])
IPdisplay.Image(url=gif_filepath)


Out[4]:

Create a 3-D phase diagram as an animated gif starts by looking straight down at the x-y plane (this is what a 2-D plot would look like), then panning and rotating around to show the 3-D structure


In [5]:
# set a filename, run the logistic model, and create the plot
gif_filename = '02-pan-rotate-logistic-phase-diagram'
working_folder = '{}/{}'.format(save_folder, gif_filename)
if not os.path.exists(working_folder):
    os.makedirs(working_folder)
    
pops = simulate(num_gens=1000, rate_min=3.99, num_rates=1)
fig, ax = phase_diagram_3d(pops, color='#003399', xlabel='Population (t)', ylabel='Population (t + 1)', zlabel='', 
                           show=False, save=False)

# look straight down at the x-y plane to start off
ax.elev = 89.9
ax.azim = 270.1
ax.dist = 11.0

# sweep the perspective down and rotate to reveal the 3-D structure of the strange attractor
for n in range(0, 100):
    if n > 19 and n < 23:
        ax.set_xlabel('')
        ax.set_ylabel('') #don't show axis labels while we move around, it looks weird
        ax.elev = ax.elev-0.5 #start by panning down slowly
    if n > 22 and n < 37:
        ax.elev = ax.elev-1.0 #pan down faster
    if n > 36 and n < 61:
        ax.elev = ax.elev-1.5
        ax.azim = ax.azim+1.1 #pan down faster and start to rotate
    if n > 60 and n < 65:
        ax.elev = ax.elev-1.0
        ax.azim = ax.azim+1.1 #pan down slower and rotate same speed
    if n > 64 and n < 74:
        ax.elev = ax.elev-0.5
        ax.azim = ax.azim+1.1 #pan down slowly and rotate same speed
    if n > 73 and n < 77:
        ax.elev = ax.elev-0.2
        ax.azim = ax.azim+0.5 #end by panning/rotating slowly to stopping position   
    if n > 76: #add axis labels at the end, when the plot isn't moving around
        ax.set_xlabel('Population (t)')
        ax.set_ylabel('Population (t + 1)')
        ax.set_zlabel('Population (t + 2)')
        
    # add a figure title to each plot then save the figure to the disk
    fig.suptitle('Logistic Map, r=3.99', fontsize=16, x=0.5, y=0.85)
    plt.savefig('{}/{}/img{:03d}.png'.format(save_folder, gif_filename, n), bbox_inches='tight')

# don't display the static plot
plt.close()

# load all the static images into a list then save as an animated gif
gif_filepath = '{}/{}.gif'.format(save_folder, gif_filename)
images = [Image.open(image) for image in glob.glob('{}/*.png'.format(working_folder))]
gif = images[0]
gif.info['duration'] = 10 #milliseconds per frame
gif.info['loop'] = 0 #how many times to loop (0=infinite)
gif.save(fp=gif_filepath, format='gif', save_all=True, append_images=images[1:])
IPdisplay.Image(url=gif_filepath)


Out[5]:

Do the same thing again, but this time plot both the chaotic logistic model output and random noise


In [6]:
# run the logistic model and create random noise
chaos_pops = simulate(num_gens=1000, rate_min=3.99, num_rates=1)
random_pops = pd.DataFrame([random.random() for _ in range(0, 1000)], columns=['value'])
pops = pd.concat([chaos_pops, random_pops], axis=1)
pops.columns = ['chaos', 'random']
pops.tail()


Out[6]:
chaos random
995 0.247214 0.792142
996 0.742535 0.822224
997 0.762795 0.235023
998 0.721947 0.837248
999 0.800951 0.977423

In [7]:
# set a filename and then create the plot
gif_filename = '03-pan-rotate-logistic-random'
working_folder = '{}/{}'.format(save_folder, gif_filename)
if not os.path.exists(working_folder):
    os.makedirs(working_folder)
    
fig, ax = phase_diagram_3d(pops, color=['#003399','#cc0000'], xlabel='Population (t)', ylabel='Population (t + 1)', 
                           zlabel='', legend=True, legend_bbox_to_anchor=(0.94, 0.96), show=False, save=False)

# configure the initial viewing perspective to look straight down at the x-y plane
ax.elev = 89.9
ax.azim = 270.1
ax.dist = 11.0

# sweep the perspective down and rotate to reveal the 3-D structure of the strange attractor
for n in range(0, 100):
    if n >= 20 and n <= 22:
        ax.set_xlabel('')
        ax.set_ylabel('') #don't show axis labels while we move around, it looks weird
        ax.elev = ax.elev-0.5 #start by panning down slowly
    if n >= 23 and n <= 36:
        ax.elev = ax.elev-1.0 #pan down faster
    if n >= 37 and n <= 60:
        ax.elev = ax.elev-1.5
        ax.azim = ax.azim+1.1 #pan down faster and start to rotate
    if n >= 61 and n <= 64:
        ax.elev = ax.elev-1.0
        ax.azim = ax.azim+1.1 #pan down slower and rotate same speed
    if n >= 65 and n <= 73:
        ax.elev = ax.elev-0.5
        ax.azim = ax.azim+1.1 #pan down slowly and rotate same speed
    if n >= 74 and n <= 76:
        ax.elev = ax.elev-0.2
        ax.azim = ax.azim+0.5 #end by panning/rotating slowly to stopping position   
    if n == 77: #add axis labels at the end, when the plot isn't moving around
        ax.set_xlabel('Population (t)')
        ax.set_ylabel('Population (t + 1)')
        ax.set_zlabel('Population (t + 2)')
        
    # add a figure title to each plot then save the figure to the disk
    fig.suptitle(u'3-D phase diagram, chaos vs random', fontsize=16, x=0.5, y=0.85)
    plt.savefig('{}/{}/img{:03d}.png'.format(save_folder, gif_filename, n), bbox_inches='tight')

# don't display the static plot
plt.close()

# load all the static images into a list then save as an animated gif
gif_filepath = '{}/{}.gif'.format(save_folder, gif_filename)
images = [Image.open(image) for image in glob.glob('{}/*.png'.format(working_folder))]
gif = images[0]
gif.info['duration'] = 10 #milliseconds per frame
gif.info['loop'] = 0 #how many times to loop (0=infinite)
gif.save(fp=gif_filepath, format='gif', save_all=True, append_images=images[1:])
IPdisplay.Image(url=gif_filepath)


Out[7]:

Create a 3-D phase diagram to show the logistic map's strange attractors across the chaotic regime (from r=3.6 to r=4.0), twisting and curling around their state space in three dimensions. Animated it by panning and rotating to reveal the structure and its odd folds.


In [8]:
# run the model for 2,000 generations for 50 growth rate parameters between 3.6 and 4.0
pops = simulate(num_gens=2000, rate_min=3.6, rate_max=4.0, num_rates=50)

In [9]:
# set a filename and create the plot
gif_filename = '04-pan-rotate-chaotic-regime'
working_folder = '{}/{}'.format(save_folder, gif_filename)
if not os.path.exists(working_folder):
    os.makedirs(working_folder)
    
fig, ax = phase_diagram_3d(pops, color='viridis', color_reverse=False,
                           xlabel='Population (t)', ylabel='Population (t + 1)', zlabel='', 
                           show=False, save=False)

# configure the initial viewing perspective to look straight down at the x-y plane
ax.elev = 89.9
ax.azim = 270.1
ax.dist = 11.0

# sweep the perspective down and rotate to reveal the 3-D structure of the strange attractor
for n in range(0, 100):
    if n > 19 and n < 23:
        ax.set_xlabel('')
        ax.set_ylabel('') #don't show axis labels while we move around, it looks weird
        ax.elev = ax.elev-0.5 #start by panning down slowly
    if n > 22 and n < 37:
        ax.elev = ax.elev-1.0 #pan down faster
    if n > 36 and n < 61:
        ax.elev = ax.elev-1.5
        ax.azim = ax.azim+1.1 #pan down faster and start to rotate
    if n > 60 and n < 65:
        ax.elev = ax.elev-1.0
        ax.azim = ax.azim+1.1 #pan down slower and rotate same speed
    if n > 64 and n < 74:
        ax.elev = ax.elev-0.5
        ax.azim = ax.azim+1.1 #pan down slowly and rotate same speed
    if n > 73 and n < 77:
        ax.elev = ax.elev-0.2
        ax.azim = ax.azim+0.5 #end by panning/rotating slowly to stopping position
    
    if n > 76: #add axis labels at the end, when the plot isn't moving around
        ax.set_xlabel('Population (t)')
        ax.set_ylabel('Population (t + 1)')
        ax.set_zlabel('Population (t + 2)')
    
    # add a figure title to each plot then save the figure to the disk
    fig.suptitle('Logistic Map, r=3.6 to r=4.0', fontsize=16, x=0.5, y=0.85)
    plt.savefig('{}/{}/img{:03d}.png'.format(save_folder, gif_filename, n), bbox_inches='tight')

# don't display the static plot
plt.close()

# load all the static images into a list then save as an animated gif
gif_filepath = '{}/{}.gif'.format(save_folder, gif_filename)
images = [Image.open(image) for image in glob.glob('{}/*.png'.format(working_folder))]
gif = images[0]
gif.info['duration'] = 10 #milliseconds per frame
gif.info['loop'] = 0 #how many times to loop (0=infinite)
gif.save(fp=gif_filepath, format='gif', save_all=True, append_images=images[1:])
IPdisplay.Image(url=gif_filepath)


Out[9]:

Now zoom into the 3D plot


In [10]:
# run the model for 4,000 generations for 50 growth rate parameters between 3.6 and 4.0
pops = simulate(num_gens=4000, rate_min=3.6, rate_max=4.0, num_rates=50)

In [11]:
# set a filename and create the plot
gif_filename = '05-logistic-3d-phase-diagram-chaotic-regime'
working_folder = '{}/{}'.format(save_folder, gif_filename)
if not os.path.exists(working_folder):
    os.makedirs(working_folder)
    
fig, ax = phase_diagram_3d(pops, color='viridis', color_reverse=False, show=False, save=False)

# configure the initial viewing perspective
ax.elev = 25.
ax.azim = 321.
ax.dist = 11.0

# zoom in to reveal the 3-D structure of the strange attractor
for n in range(0, 100):
    if n <= 18:
        ax.azim = ax.azim-0.2 #begin by rotating very slowly
    if n >= 19 and n <= 29:
        ax.azim = ax.azim-10
        ax.dist = ax.dist-0.05
        ax.elev = ax.elev-2 #quickly whip around to the other side
    if n >= 33 and n <= 49:
        ax.azim = ax.azim+3
        ax.dist = ax.dist-0.55
        ax.elev = ax.elev+1.4 #zoom into the center
    if n >= 61 and n <= 79:
        ax.azim = ax.azim-2
        ax.elev = ax.elev-2
        ax.dist = ax.dist+0.2 #pull back and pan up
    if n >= 80:
        ax.azim = ax.azim-0.2 #end by rotating very slowly
    
    # add a figure title to each plot then save the figure to the disk
    fig.suptitle('Logistic Map, r=3.6 to r=4.0', fontsize=16, x=0.5, y=0.85)
    plt.savefig('{}/{}/img{:03d}.png'.format(save_folder, gif_filename, n), bbox_inches='tight')

# don't display the static plot
plt.close()

# load all the static images into a list then save as an animated gif
gif_filepath = '{}/{}.gif'.format(save_folder, gif_filename)
images = [Image.open(image) for image in glob.glob('{}/*.png'.format(working_folder))]
gif = images[0]
gif.info['duration'] = 10 #milliseconds per frame
gif.info['loop'] = 0 #how many times to loop (0=infinite)
gif.save(fp=gif_filepath, format='gif', save_all=True, append_images=images[1:])
IPdisplay.Image(url=gif_filepath)


Out[11]: