pythonpandasmatplotlibgeopandasmatplotlib-animation

Animating Yearly Data from Pandas in GeoPandas with Matplotlib FuncAnimation


Using this dataset of % change by state, I have merged it with a cartographic boundary map of US states from the Census department: https://www2.census.gov/geo/tiger/GENZ2018/shp/cb_2018_us_state_500k.zip

df.head()

Year        2017    2018    2019    2020    2021    2022    2023
State                           
Alabama     0.00    0.00     0.00   0.00    0.00    0.00    0.00
Arizona     0.24    0.00     0.03  -0.15    0.56   -0.36    0.21
Arkansas    0.35   -0.06    -0.03   0.03   -0.00   -0.13   -0.02
California  0.13    0.07    -0.03   0.04    0.21   -0.10    0.03
Colorado    0.81   -0.18    -0.01  -0.05    0.10   -0.03   -0.51

figures from column (year) 2017 shown on map

I would like to cycle through the columns (years) in a FuncAnimation after the boundaries have been plotted, and I am not quite sure how to go about it. The lifecycle of a plot in official reference manual cites relevant examples, but all deal with built-in figures, and not shape files.

Here is a related answer that seems exactly like what I'm missing, but deals with only (x, y) line graph: How to keep shifting the X axis and show the more recent data using matplotlib.animation in Python?

How do I extrapolate column outside of calling shape.plot()?

code:

shape = gpd.read_file(shapefile)
years = dfc.columns  # dfc = % change df
tspan = len(dfc.columns)


""" merge map with dataframe on state name column """ 
shape = pd.merge(
    left=shape,
    right=dfc,
    left_on='NAME',
    right_on='State',
    how='right'
)
""" init pyplot 'OO method' """
fig, ax = plt.subplots(figsize=(10, 5))

""" draw shape boundary """
ax = shape.boundary.plot(
    ax=ax,
    edgecolor='black', 
    linewidth=0.3, 
    )

""" plot shape """
ax = shape.plot(
    ax=ax,
    column=year, # what I need access to
    legend=True, cmap='RdBu_r', 
    legend_kwds={'shrink': 0.3, 'orientation': 'horizontal', 'format': '%.0f'})

""" cycle through columns -- not operable yet """ 
def animate(year):
    ax.clear()
    ax.shape.column(year)

animation = FuncAnimation(states, animate, frames=(dfc.columns[0], dfc.columns[tspan] + 1, 1), repeat=True, interval=1000)

I really haven't found anything online dealing with these cartographic boundary maps specifically

I have tried the most obvious things I could think of:
Putting the entire shape.plot() method into animate()

I tried a for loop cycling the years, which resulted in 7 distinct maps. Each iteration lost the attributes I set in shape.boundary.plot()

Edit:

Since I've converted the original procedural example into the OO format, I am starting to have new questions about what might be done.

If ax = shape.plot(ax=ax), is there some kind of getter/setter, for previously defined attributes? e.g. ax.set_attr = column=year (will scour manual immediately after I finish this)

Is there a way to define the map's boundary lines, shown here with shape.plot() and shape.boundary.plot(), using the fig, instead of ax (ax = shape.plot())?

Barring that, could we have shape.plot() and shape.boundary.plot() persist to the first subplot axs[0] and have columns of data shown using subsequent overlapping subplots axs[n == year]?

Any iterative process I've seen so far has lost the boundary attributes, so that's been a big sticking point for me.


Solution

  • In the following animation, only states in data are plotted since how='right' is used for pd.merge.

    Tested in python v3.12.3, geopandas v0.14.4, matplotlib v3.8.4.

    import geopandas as gpd
    import pandas as pd
    import matplotlib.pyplot as plt
    from matplotlib.animation import FuncAnimation, PillowWriter
    
    # Sample data
    data = {
        'State': ['Alabama', 'Arizona', 'Arkansas', 'California', 'Colorado'],
        '2017': [0.00, 0.24, 0.35, 0.13, 0.81],
        '2018': [0.00, 0.00, -0.06, 0.07, -0.18],
        '2019': [0.00, 0.03, -0.03, -0.03, -0.01],
        '2020': [0.00, -0.15, 0.03, 0.04, -0.05],
        '2021': [0.00, 0.56, -0.00, 0.21, 0.10],
        '2022': [0.00, -0.36, -0.13, -0.10, -0.03],
        '2023': [0.00, 0.21, -0.02, 0.03, -0.51],
    }
    df = pd.DataFrame(data)
    
    # Load the shapefile
    shape = gpd.read_file('cb_2018_us_state_500k.shp')
    
    # Merge the shape data with the dataframe
    shape = pd.merge(
        left=shape,
        right=df,
        left_on='NAME',
        right_on='State',
        how='right'
    )
    
    # Initialize the plot
    fig, ax = plt.subplots(figsize=(10, 5))
    
    # Set fixed axis limits
    xlim = (shape.total_bounds[0], shape.total_bounds[2])
    ylim = (shape.total_bounds[1], shape.total_bounds[3])
    ax.set_xlim(xlim)
    ax.set_ylim(ylim)
    
    # Plot initial boundaries
    boundary = shape.boundary.plot(ax=ax, edgecolor='black', linewidth=0.3)
    
    # Initialize the colorbar variable with a fixed normalization
    norm = plt.Normalize(vmin=df.iloc[:, 1:].min().min(), vmax=df.iloc[:, 1:].max().max())
    sm = plt.cm.ScalarMappable(cmap='RdBu_r', norm=norm)
    sm.set_array([])  # Only needed for adding the colorbar
    colorbar = fig.colorbar(sm, ax=ax, orientation='horizontal', shrink=0.5, format='%.2f')
    
    # Function to update the plot for each year
    def animate(year):
        ax.clear()
    
        # Set the fixed axis limits
        ax.set_xlim(xlim)
        ax.set_ylim(ylim)
    
        # Plot initial boundaries
        boundary = shape.boundary.plot(ax=ax, edgecolor='black', linewidth=0.3)
        
        # Plot the data for the current year
        shape.plot(
            ax=ax, column=year, legend=False, cmap='RdBu_r', norm=norm
        )
    
        # Add year annotation at the top
        ax.annotate(f'Year: {year}', xy=(0.5, 1.05), xycoords='axes fraction', fontsize=12, ha='center')
    
    # Create the animation
    years = df.columns[1:]  # Skip the 'State' column
    animation = FuncAnimation(fig, animate, frames=years, repeat=False, interval=1000)
    
    # Save the animation as a GIF
    writer = PillowWriter(fps=1)
    animation.save('us_states_animation.gif', writer=writer)
    
    # Show the plot
    plt.show()
    

    enter image description here

    Note: Segmentation of the colorbar is an artifact of the .gif format and is not present when running the animation.


    Save the file as a .mp4, which doesn't display segmentation in the colorbar. Download FFmped from FFmpeg download page, extract the archive, and add the bin folder path to the Path variable in 'System Variables'.

    from matplotlib.animation import FuncAnimation, FFMpegWriter
    import matplotlib as mpl
    
    # Set the path to the ffmpeg executable
    mpl.rcParams['animation.ffmpeg_path'] = r'C:\FFmpeg\bin\ffmpeg.exe'  # Replace this with the correct path to your ffmpeg executable
    
    ...
    
    # Save the animation as an MP4
    writer = FFMpegWriter(fps=1, metadata=dict(artist='Me'), bitrate=1800)
    animation.save('us_states_animation.mp4', writer=writer)
    
    # Show the plot
    plt.show()