American White Pelican (Pelecanus erythrorhynchos) Migration in 2023¶

Species Description¶

The American White Pelican, Pelecanus erythrorhynchos, is a large bird that is mostly white in color with some brown on its wings and an orange bill. This pelican has a couple of different aspects to its migration. Some groups will spend their winters along the Gulf of Mexico. These groups generally breed east of the Rocky Mountains. Flocks that breed west of the Rocky Mountains will migrate to the Pacific coast. American White Pelicans need wetlands, whether that is freshwater lakes, saline lakes, or the oceans. I chose this species because I am always surprised to see pelicans in Colorado. I grew up thinking they were just a coastal bird so I wanted to see just how extensive their habitat and migration is.¶

Citations:

  • https://www.borealbirds.org/bird/american-white-pelican#:~:text=Range%2FMigration,mountains%20to%20the%20Pacific%20coast
  • https://www.audubon.org/field-guide/bird/american-white-pelican
  • https://calmzoo.org/blog/animals/american-white-pelican/#:~:text=All%20four%20toes%20are%20connected,mouth%20before%20swallowing%20their%20food

Data Description¶

The species data comes from the Global Biodiversity Information Facility (GBIF). GBIF gathers and stores data from sources such as sightings logged on iNaturalist apps on phones or tablets and museum specimens. These data points are organized into species occurrence records which can be downloaded and analyzed. The specific data downloaded for this American White Pelican migration analysis was just from 2023 and involved 6 datasets, 6 publishers, and 4 publishing countries. The majority of the pelican data came from three datasets: EOD - eBird Observation Dataset, iNaturalist Research-grade Observations, and Birda - Global Observation Dataset.¶
The ecoregions data contain boundaries on Earth. The boundaries divide Earth into areas with specific geographies, vegetation, and natural communities. There are 846 ecoregions. First created in 2001, the ecoregion map was updated in 2017. The ecoregions dataset is available as a shapefile and Google Engine dataset; the shapefile is used in this analysis. Each ecoregion has a name, biome, realm, geometry, as well as other characteristics.¶

Citations:

  • Dinerstein, E., Olson, D., Joshi, A., Vynne, C., Burgess, N. D., Wikramanayake, E., … & Saleem, M. (2017). An ecoregion-based approach to protecting half the terrestrial realm. BioScience, 67(6), 534-545. https://doi.org/10.1093/biosci/bix014
  • GBIF.org (26 October 2024) GBIF Occurrence Download https://doi.org/10.15468/dl.xfb9w2

Methods Description¶

To perform this migration analysis, I downloaded two different datasets: the American White Pelican occurrence data from 2023 from GBIF and then the ecoregion data. These datasets were then combined to see which ecoregions the pelican occurrences were in during 2023. This combined dataset was then normalized by ecoregion and month. It is important to normalize to get rid of any rare occurrences and account for any big differences in sampling effort. Normalizing helps even out areas where there are a ton of occurrences just because there's a large sampling effort with areas where there are just as many occurrences but the sampling effort is less. So even though there aren't less occurrences in the second scenario, it could seem like there is without the normalization. After the data is normalized, the interactive plot can be created.¶

1. Import necessary Python packages and libraries¶

In [1]:
import pandas as pd
import geopandas as gpd
import os
import pathlib

import time
import zipfile
from getpass import getpass
from glob import glob

import pygbif.occurrences as occ
import pygbif.species as species

# Get month names
import calendar

# Libraries for Dynamic mapping
import cartopy.crs as ccrs
import hvplot.pandas
import panel as pn

2. Create a folder for the data¶

In [2]:
# Create directory path in the home folder
data_dir = os.path.join(
    # Home directory
    pathlib.Path.home(),
    # Earth analytics data directory
    'earth-analytics',
    'data',
    # Project directory
    'pe_migration',
)

# Make the directory
os.makedirs(data_dir, exist_ok=True)
In [3]:
# Define the directory name for the pelican GBIF data, create directory
gbif_dir = os.path.join(data_dir, 'pe-gbif')

os.makedirs(gbif_dir, exist_ok=True)

gbif_path = os.path.join(gbif_dir, 'pe.csv')
Check to make sure the file got set up correctly¶
In [5]:
%%bash
find ~/earth-analytics/data/pe_migration/
/home/jovyan/earth-analytics/data/pe_migration/
/home/jovyan/earth-analytics/data/pe_migration/0005554-241024112534372.zip
/home/jovyan/earth-analytics/data/pe_migration/pe-gbif
/home/jovyan/earth-analytics/data/pe_migration/pe-gbif/0005554-241024112534372.csv
/home/jovyan/earth-analytics/data/pe_migration/resolve_ecoregions_dir
/home/jovyan/earth-analytics/data/pe_migration/resolve_ecoregions_dir/ecoregions.cpg
/home/jovyan/earth-analytics/data/pe_migration/resolve_ecoregions_dir/ecoregions.dbf
/home/jovyan/earth-analytics/data/pe_migration/resolve_ecoregions_dir/ecoregions.shp
/home/jovyan/earth-analytics/data/pe_migration/resolve_ecoregions_dir/ecoregions.shx
/home/jovyan/earth-analytics/data/pe_migration/resolve_ecoregions_dir/ecoregions.prj
/home/jovyan/earth-analytics/data/pe_migration/0005554-241024112534372.zip
/home/jovyan/earth-analytics/data/pe_migration/pe-gbif
/home/jovyan/earth-analytics/data/pe_migration/pe-gbif/0005554-241024112534372.csv
/home/jovyan/earth-analytics/data/pe_migration/resolve_ecoregions_dir
/home/jovyan/earth-analytics/data/pe_migration/resolve_ecoregions_dir/ecoregions.cpg
/home/jovyan/earth-analytics/data/pe_migration/resolve_ecoregions_dir/ecoregions.dbf
/home/jovyan/earth-analytics/data/pe_migration/resolve_ecoregions_dir/ecoregions.shp
/home/jovyan/earth-analytics/data/pe_migration/resolve_ecoregions_dir/ecoregions.shx
/home/jovyan/earth-analytics/data/pe_migration/resolve_ecoregions_dir/ecoregions.prj

3. Log into GBIF¶

In [6]:
reset_credentials = False
# GBIF needs a username, password, and email
credentials = dict(
    GBIF_USER=(input, 'username'),
    GBIF_PWD=(getpass, 'password'),
    GBIF_EMAIL=(input, 'email'),
)
for env_variable, (prompt_func, prompt_text) in credentials.items():
    # Delete credential from environment if requested
    if reset_credentials and (env_variable in os.environ):
        os.environ.pop(env_variable)
    # Ask for credential and save to environment
    if not env_variable in os.environ:
        os.environ[env_variable] = prompt_func(prompt_text)

4. Get American White Pelican, Pelecanus erythrorhynchos, Species Key¶

In [7]:
# Query American White Pelican species, Pelecanus erythrorhynchos
species_info = species.name_lookup('Pelecanus erythrorhynchos', rank='species')

# Get the first result
first_result = species_info['results'][0]
first_result
Out[7]:
{'key': 123210336,
 'nameKey': 8345644,
 'datasetKey': 'a5dd063e-f45b-4a54-8b94-8fa3adf7f1e1',
 'nubKey': 5229155,
 'parentKey': 167183767,
 'parent': 'Pelecanidae',
 'kingdom': 'Animalia',
 'order': 'Pelecaniformes',
 'family': 'Pelecanidae',
 'species': 'Pelecanus erythrorhynchos',
 'kingdomKey': 167183684,
 'orderKey': 167183765,
 'familyKey': 167183767,
 'speciesKey': 123210336,
 'scientificName': 'Pelecanus erythrorhynchos',
 'canonicalName': 'Pelecanus erythrorhynchos',
 'authorship': '',
 'nameType': 'SCIENTIFIC',
 'taxonomicStatus': 'ACCEPTED',
 'rank': 'SPECIES',
 'origin': 'SOURCE',
 'numDescendants': 0,
 'numOccurrences': 0,
 'taxonID': 'Pelecanus erythrorhynchos',
 'habitats': [],
 'nomenclaturalStatus': [],
 'threatStatuses': [],
 'descriptions': [],
 'vernacularNames': [{'vernacularName': 'American White Pelican',
   'language': 'eng'},
  {'vernacularName': 'Amerikansk Hvid Pelikan', 'language': 'dan'},
  {'vernacularName': 'Amerikapelikan', 'language': 'nor'},
  {'vernacularName': 'Nashornpelikan', 'language': 'deu'},
  {'vernacularName': 'Pellicano americano', 'language': 'ita'},
  {'vernacularName': 'Pelícano norteamericano', 'language': 'spa'},
  {'vernacularName': "Pélican d'Amérique", 'language': 'fra'},
  {'vernacularName': 'Witte Pelikaan', 'language': 'nld'},
  {'vernacularName': 'amerikanpelikaani', 'language': 'fin'},
  {'vernacularName': 'baltasis pelikanas', 'language': 'lit'},
  {'vernacularName': 'hornpelikan', 'language': 'swe'},
  {'vernacularName': 'orrszarvú pelikán', 'language': 'hun'},
  {'vernacularName': 'paugurknābja pelikāns', 'language': 'lav'},
  {'vernacularName': 'pelicano-de-bico-amarelo', 'language': 'por'},
  {'vernacularName': 'pelicà blanc americà', 'language': 'cat'},
  {'vernacularName': 'pelikan dzioborogi', 'language': 'pol'},
  {'vernacularName': 'pelikán biely', 'language': 'slk'},
  {'vernacularName': 'pelikán severoamerický', 'language': 'ces'},
  {'vernacularName': 'sarvnokk-pelikan', 'language': 'est'},
  {'vernacularName': 'Американский белый пеликан', 'language': 'rus'},
  {'vernacularName': 'アメリカシロペリカン', 'language': 'jpn'},
  {'vernacularName': '紅嘴鵜鶘', 'language': 'zho'},
  {'vernacularName': '美洲鹈鹕', 'language': 'zho'}],
 'higherClassificationMap': {'167183684': 'Animalia',
  '167183765': 'Pelecaniformes',
  '167183767': 'Pelecanidae'},
 'synonym': False}
In [8]:
# Get the Pelecanus erythrorhynchos species key (nubKey)
species_key = first_result['nubKey']
species_key
Out[8]:
5229155
In [9]:
# Check the result
first_result['species'], species_key
Out[9]:
('Pelecanus erythrorhynchos', 5229155)

5. Download American White Pelican Data from GBIF¶

In [10]:
# Get the GBIF download key from the "Downloads" page of your GBIF account.
os.environ['GBIF_DOWNLOAD_KEY'] = '0005554-241024112534372'

# Only download pelican data once
pe_gbif_pattern = os.path.join(gbif_dir, '*.csv')
if not glob(pe_gbif_pattern):
    # Submit query to GBIF
    # Only download once
    if not 'GBIF_DOWNLOAD_KEY' in os.environ:
        pe_gbif_query = occ.download([
            "speciesKey = 5229155",
            "year = 2023",
            "hasCoordinate = TRUE",
        ])
        download_key = pe_gbif_query[0]
        os.environ['GBIF_DOWNLOAD_KEY'] = pe_gbif_query[0]
    else: 
        download_key = os.environ['GBIF_DOWNLOAD_KEY']

    # Wait for the download to build
    wait = occ.download_meta(download_key)['status']
    while not wait=='SUCCEEDED':
        wait = occ.download_meta(download_key)['status']
        time.sleep(5)

    # Download GBIF data
    download_info = occ.download_get(
        os.environ['GBIF_DOWNLOAD_KEY'], 
        path=data_dir)

    # Unzip GBIF data
    with zipfile.ZipFile(download_info['path']) as download_zip:
        download_zip.extractall(path=gbif_dir)

# Find the extracted .csv file path
pe_gbif_path = glob(pe_gbif_pattern)[0]
In [11]:
# Load the pelican GBIF data
pe_gbif_df = pd.read_csv(
    pe_gbif_path, 
    delimiter='\t',
    index_col='gbifID',
    usecols=['gbifID', 'decimalLatitude', 'decimalLongitude', 'month']
)
pe_gbif_df.head()
Out[11]:
decimalLatitude decimalLongitude month
gbifID
4067258696 34.175541 -118.472306 1
4165710430 50.445147 -104.618894 7
4091905456 44.324291 -98.212212 4
4091416462 46.391038 -117.044856 4
4430556770 38.638609 -90.273179 9

6. Convert the pe_gbif_df to a GeoDataFrame¶

In [12]:
# Convert the pe_gbif_df into a GeoDataFrame.
# Only show the month and geomtery columns of the pe_gbif_gdf.
pe_gbif_gdf = (
    gpd.GeoDataFrame(
        pe_gbif_df,
        geometry=gpd.points_from_xy(
            pe_gbif_df.decimalLongitude,
            pe_gbif_df.decimalLatitude),
        crs='EPSG:4326')
        [['month', 'geometry']]
        )

pe_gbif_gdf
Out[12]:
month geometry
gbifID
4067258696 1 POINT (-118.47231 34.17554)
4165710430 7 POINT (-104.61889 50.44515)
4091905456 4 POINT (-98.21221 44.32429)
4091416462 4 POINT (-117.04486 46.39104)
4430556770 9 POINT (-90.27318 38.63861)
... ... ...
4784036286 3 POINT (-110.92708 29.0717)
4838977397 4 POINT (-112.025 41.51127)
4695466150 6 POINT (-105.0372 40.1818)
4684018625 5 POINT (-83.19189 41.62771)
4642444333 8 POINT (-111.27846 53.95174)

237797 rows × 2 columns

7. Download the ecoregion data¶

In [13]:
# Set up the ecoregion boundary URL
ecoregions_url = ("https://storage.googleapis.com"
                  "/teow2016/Ecoregions2017.zip"
                  )

# Set up a path to save the data on your machine
ecoregions_dir = os.path.join(data_dir, 'resolve_ecoregions_dir')

# Make the ecoregions directory
os.makedirs(ecoregions_dir, exist_ok=True)

# Join ecoregions shapefile path
ecoregions_path = os.path.join(ecoregions_dir, 'ecoregions.shp')

# Only download once
if not os.path.exists(ecoregions_path):
    ecoregions_gdf = gpd.read_file(ecoregions_url)
    ecoregions_gdf.to_file(ecoregions_path)

Check to make sure the ecoregions shape file, .shp, downloaded and saved:

In [14]:
%%bash
find ~/earth-analytics/data/pe_migration/ -name '*.shp'
/home/jovyan/earth-analytics/data/pe_migration/resolve_ecoregions_dir/ecoregions.shp
In [15]:
# Open up the ecoregions boundaries
ecoregions_gdf = gpd.read_file(ecoregions_path)

# Plot the ecoregions to check download.
ecoregions_gdf.plot()
Out[15]:
<Axes: >
No description has been provided for this image
In [16]:
# View the ecoregions_gdf
ecoregions_gdf.head(2)
Out[16]:
OBJECTID ECO_NAME BIOME_NUM BIOME_NAME REALM ECO_BIOME_ NNH ECO_ID SHAPE_LENG SHAPE_AREA NNH_NAME COLOR COLOR_BIO COLOR_NNH LICENSE geometry
0 1.0 Adelie Land tundra 11.0 Tundra Antarctica AN11 1 117 9.749780 0.038948 Half Protected #63CFAB #9ED7C2 #257339 CC-BY 4.0 MULTIPOLYGON (((158.7141 -69.60657, 158.71264 ...
1 2.0 Admiralty Islands lowland rain forests 1.0 Tropical & Subtropical Moist Broadleaf Forests Australasia AU01 2 135 4.800349 0.170599 Nature Could Reach Half Protected #70A800 #38A700 #7BC141 CC-BY 4.0 MULTIPOLYGON (((147.28819 -2.57589, 147.2715 -...
In [17]:
# Make the ecoregions_gdf easier to work with by only viewing 3 columns:
# OBJECTID, ECO_NAME, and geometry.
ecoregions_clean_gdf = ecoregions_gdf[['OBJECTID', 'ECO_NAME', 'geometry']]

ecoregions_clean_gdf.head()
Out[17]:
OBJECTID ECO_NAME geometry
0 1.0 Adelie Land tundra MULTIPOLYGON (((158.7141 -69.60657, 158.71264 ...
1 2.0 Admiralty Islands lowland rain forests MULTIPOLYGON (((147.28819 -2.57589, 147.2715 -...
2 3.0 Aegean and Western Turkey sclerophyllous and m... MULTIPOLYGON (((26.88659 35.32161, 26.88297 35...
3 4.0 Afghan Mountains semi-desert MULTIPOLYGON (((65.48655 34.71401, 65.52872 34...
4 5.0 Ahklun and Kilbuck Upland Tundra MULTIPOLYGON (((-160.26404 58.64097, -160.2673...

8. Combine the ecoregions data with the pelicans data so we can see which ecoregion each of the pelican observations was in.¶

In [18]:
# Identify the ecoregion for each pelican observation noted in the pe_gbif_gdf with .sjoin
pe_gbif_ecoregion_gdf = (
    ecoregions_clean_gdf
    .to_crs(pe_gbif_gdf.crs)
    .sjoin(
        pe_gbif_gdf,
        how='inner',
        predicate='contains')
    [['gbifID', 'OBJECTID','month', 'ECO_NAME']]
    .rename(columns={'OBJECTID': 'eco_id',
                     'ECO_NAME': 'eco_name',
                     'gbifID': 'observation_id'})
    )

pe_gbif_ecoregion_gdf
Out[18]:
observation_id eco_id month eco_name
12 4613928921 13.0 7 Alberta-British Columbia foothills forests
12 4769150413 13.0 7 Alberta-British Columbia foothills forests
12 4686862268 13.0 7 Alberta-British Columbia foothills forests
12 4696887312 13.0 7 Alberta-British Columbia foothills forests
12 4716518628 13.0 10 Alberta-British Columbia foothills forests
... ... ... ... ...
833 4657665547 839.0 10 Northern Rockies conifer forests
833 4761850507 839.0 6 Northern Rockies conifer forests
833 4820906046 839.0 6 Northern Rockies conifer forests
833 4116310145 839.0 5 Northern Rockies conifer forests
833 4614234862 839.0 7 Northern Rockies conifer forests

228771 rows × 4 columns

9. Normalize the Data¶

In [19]:
#Count the observations in each ecoregion each month
pe_month_occ_df = (
    pe_gbif_ecoregion_gdf
    .groupby(['eco_id', 'month'])
    .agg(occurrences=('observation_id', 'count'))
)

pe_month_occ_df
Out[19]:
occurrences
eco_id month
13.0 4 1
6 8
7 29
8 6
9 2
... ... ...
839.0 6 122
7 73
8 91
9 109
10 41

982 rows × 1 columns

In [20]:
# Get rid of rare observations by only indluding occurrences greater than 1.
# Editing the occurrences column of the pe_month_occ_df.

pe_month_occ_df = pe_month_occ_df[pe_month_occ_df.occurrences>1]

pe_month_occ_df
Out[20]:
occurrences
eco_id month
13.0 6 8
7 29
8 6
9 2
33.0 1 134
... ... ...
839.0 6 122
7 73
8 91
9 109
10 41

905 rows × 1 columns

In [21]:
# Find the mean number of occurences for ecoregion

pe_mean_occ_ecoregion = (
    pe_month_occ_df
    .groupby('eco_id')
    .mean()
)

pe_mean_occ_ecoregion
Out[21]:
occurrences
eco_id
13.0 11.250000
33.0 63.000000
34.0 18.750000
35.0 20.333333
44.0 24.500000
... ...
802.0 116.000000
809.0 39.888889
810.0 4.000000
838.0 10.000000
839.0 85.285714

108 rows × 1 columns

In [22]:
# Find the mean number of occurrences for each month
pe_mean_occ_month = (
    pe_month_occ_df
    .groupby('month')
    .mean()
)

pe_mean_occ_month
Out[22]:
occurrences
month
1 290.797101
2 249.385714
3 212.837500
4 340.689655
5 383.679012
6 213.835616
7 209.585714
8 237.921875
9 244.863014
10 198.444444
11 204.707317
12 229.120000
In [23]:
# Normalize by space and time for sampling effort. 
# Creating a new column callend norm_occurrences.

pe_month_occ_df['norm_occurrences'] = (
    pe_month_occ_df
    /pe_mean_occ_ecoregion
    /pe_mean_occ_month
)

pe_month_occ_df
/tmp/ipykernel_1055/825661723.py:4: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
  pe_month_occ_df['norm_occurrences'] = (
Out[23]:
occurrences norm_occurrences
eco_id month
13.0 6 8 0.003326
7 29 0.012299
8 6 0.002242
9 2 0.000726
33.0 1 134 0.007314
... ... ... ...
839.0 6 122 0.006690
7 73 0.004084
8 91 0.004485
9 109 0.005219
10 41 0.002423

905 rows × 2 columns

10. Create the migration plot.¶

In [24]:
# Simplify ecoregions_clean_gdf geometry to speed up processing
ecoregions_clean_gdf.geometry = ecoregions_clean_gdf.simplify(
    .1,
    preserve_topology=False
)

# Change the CRS to Mercator for mapping
ecoregions_clean_gdf = ecoregions_clean_gdf.to_crs(ccrs.Mercator())

# Plot the simplified ecoregions_clean_gdf to check that simplifcations work.
ecoregions_clean_gdf.hvplot(geo=True, crs=ccrs.Mercator())
/opt/conda/lib/python3.11/site-packages/geopandas/geodataframe.py:1819: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
  super().__setitem__(key, value)
/opt/conda/lib/python3.11/site-packages/dask/dataframe/__init__.py:42: FutureWarning: 
Dask dataframe query planning is disabled because dask-expr is not installed.

You can install it with `pip install dask[dataframe]` or `conda install dask`.
This will raise in a future version.

  warnings.warn(msg, FutureWarning)
Out[24]:

10a. Before we can join the pe_month_occ_df with the ecoregions_clean_gdf, we need to rename the ecoregions_clean_gdf 'OBJECTID' column to 'eco_id' as we did when creating the pe_gbif_ecoregion_gdf. We also need to set the ecoregions_clean_gdf index to the 'eco_id' column so that the pe_month_occ_df and ecoregions_clean_df have an index in common.¶

In [25]:
ecoregions_clean_gdf = ecoregions_clean_gdf.rename(
    columns={'OBJECTID': 'eco_id'})

ecoregions_clean_gdf.set_index('eco_id', inplace=True)

ecoregions_clean_gdf.head(5)
Out[25]:
ECO_NAME geometry
eco_id
1.0 Adelie Land tundra MULTIPOLYGON EMPTY
2.0 Admiralty Islands lowland rain forests POLYGON ((16411777.375 -229101.376, 16384825.7...
3.0 Aegean and Western Turkey sclerophyllous and m... MULTIPOLYGON (((3391149.749 4336064.109, 33846...
4.0 Afghan Mountains semi-desert MULTIPOLYGON (((7369001.698 4093509.259, 73168...
5.0 Ahklun and Kilbuck Upland Tundra MULTIPOLYGON (((-17930832.005 8046779.358, -17...
In [26]:
#Join the occurrences DataFrame with the ecoregions GeoDataFrame
pe_occ_gdf = ecoregions_clean_gdf.join(pe_month_occ_df)

pe_occ_gdf.head(4)
Out[26]:
ECO_NAME geometry occurrences norm_occurrences
eco_id month
13.0 6 Alberta-British Columbia foothills forests MULTIPOLYGON (((-13307108.288 7486619.094, -13... 8 0.003326
7 Alberta-British Columbia foothills forests MULTIPOLYGON (((-13307108.288 7486619.094, -13... 29 0.012299
8 Alberta-British Columbia foothills forests MULTIPOLYGON (((-13307108.288 7486619.094, -13... 6 0.002242
9 Alberta-British Columbia foothills forests MULTIPOLYGON (((-13307108.288 7486619.094, -13... 2 0.000726
In [27]:
# Get the plot bounds from the pe_gbif_gdf so they don't change with the slider
xmin, ymin, xmax, ymax = (pe_gbif_gdf
                          .to_crs(ccrs.Mercator())
                          .total_bounds
                          )

# Create discrete slider
slider = pn.widgets.DiscreteSlider(
    options = {
        calendar.month_name[month_num]: month_num
        for month_num in range(1, 13)
    }
    )

# Plot occurrence by ecoregion and month
pe_migration_plot = (
    pe_occ_gdf
    .hvplot(
        #rasterize=True,
         c='norm_occurrences',
         groupby='month',
         #geo=True, crs=ccrs.Mercator(),
         tiles='CartoLight',
         title="Migration of the American White Pelican (Pelecanus erythrorhynchos) in 2023",
         xlabel="x",
         ylabel="y",
         clabel="Number of Normalized Occurrences",
         xlim=(xmin, xmax), ylim=(ymin, ymax),
         frame_height=600,
         frame_width=800,
         widgets={'month': slider},
         widget_location='bottom',
    )
)

pe_migration_plot
Out[27]:
BokehModel(combine_events=True, render_bundle={'docs_json': {'252cc35c-9a69-4078-877b-2f5a51130a4e': {'version…

The American White Pelican migrates throughout Central and North America over the course of a year.¶

The plot above shows a few different noteable things. First, there is a presence of American White Pelicans in Florida throughout the whole year. Although, there is a stronger presence from December to March. This makes sense as the Gulf of Mexico is a popular spot for pelicans to winter in. Second, you can see the pelicans' migration demonstrated from March to June as the large amounts of occurrences shift from southern America and Central America up toward northern America and southern Canada. In June, the pelicans had an especially strong precense in northern America and southern Canada while the coasts of central America had much fewer populations.In July, you can see the populations begin to shift back toward southern and Central America.¶

11. Save the migration plot as a .html file.¶

In [28]:
pe_migration_plot.save('pe_migration_plot.html', embed=True)
  0%|          | 0/12 [00:00<?, ?it/s]
                                               
WARNING:W-1005 (FIXED_SIZING_MODE): 'fixed' sizing mode requires width and height to be set: figure(id='p12272', ...)