Phenological Shifts in the Ruby-Throated Hummingbird and the Cardinal Flower

GEO511 Final Project

Author

Aubrey Monaco

Published

October 29, 2025

Introduction

With warming temperatures due to anthropogenic climate change, bird migration times are shifting, and they are tending to migrate back to their breeding grounds earlier each year. Conversely, flowers that certain migrating birds, like ruby-throated hummingbirds, may feed on may not be changing the time of their first bloom. Therefore, when birds that rely on flowers as food sources migrate back earlier, they may not have enough food to support them when they arrive. I want to investigate how a pollinating bird species, the Ruby-throated Hummingbird (Archilochus colubris), has been shifting its migration patterns and how the appearances of a species it prefers to feed on, the cardinal flower (Lobelia cardinalis), has changed (or not changed) over the past 15 years. I predict that ruby-throated hummingbirds are migrating back earlier each year, and they are arriving at increasing latitudes earlier than lower latitudes. Additionally, I suppose that the first sightings of cardinal flowers in the months that ruby-throated hummingbirds migrate north will be earlier, but not to the same extent as the bird migrations.

Materials and methods

Narrative: Clear narrative description of the data sources and methods. Includes data from at least two sources that were integrated / merged in R.

Code: The code associated with the project is well organized and easy to follow. Demonstrates mastery of R graphics and functions.

Data: The underlying data are publicly accessible via the web and downloaded/accessed within the Rmd script. If you want to use your own data, you must make it available on a website (e.g. Figshare) so that others are able to re-run your code.

  1. I loaded my necessary libraries
#install.packages("leaflet")
library(leaflet)
library(ggplot2)
library(dplyr)
library(sf)
library(spData)
library(lubridate)
  1. I loaded my data from iNaturalist and the US Phenology Network, renamed desired columns for easier use, and joined my data into one set.
cardinal_flower1 <- read.csv("cardinal_flower_nat.csv")
cardinal_flower2 <- read.csv("cardinal_flower.csv")
cardinal_flower2 <- cardinal_flower2 %>%
  rename(latitude = Latitude, longitude = Longitude)

cardinal_flower <- cardinal_flower1 %>% full_join(cardinal_flower2)
  1. I used the Lubridate package to standardize the format all of the dates in my cardinal flower data. Then, I used this standardized data to create a year column, where I pulled the years from the dates, and a year_day column, to transform the date into a day out of 365, and a month column to pull the month from the dates.
cardinal_flower <- cardinal_flower %>%
  mutate(observed_on = parse_date_time(observed_on, orders = c("mdy", "ydm", "dmy"))) %>%
  mutate(year = year(observed_on)) %>%
  mutate(year_day = yday(observed_on)) %>%
  mutate(month = month(observed_on))
  1. I found the mean date of first sightings of cardinal flowers from 2010 to 2014 and from January to May (when ruby-throats migrate north) to compare data from the last 10 years to. The mean date of cardinal flower sighting was March 10th (the 69th day of the year).
cfmean1014 <- cardinal_flower %>%
  filter(year %in% c(2010, 2011, 2012, 2013, 2014) & month %in% c(1, 2, 3, 4, 5)) %>%
  mutate(mean1014 = mean(year_day, na.rm = TRUE))

cardinal_flower <- cardinal_flower %>% full_join(cfmean1014)
cardinal_flower <- cardinal_flower %>%
  mutate(mean1014 = ifelse(is.na(mean1014), 69.30986, mean1014))
  1. I created the dataset to see trends between 2015 and 2025, and in months Janurary to May. I separated sightings into 5 bands of latitude (in increments of approxiamtely 3, excepting 27 to 33, since there were so few observations). I rejoined all of these bands into one dataset to plot on a map. I also created a difference column, showing the difference in days of arrival between the new dataset and the 2010 to 2014 average using mean - mean1014.
cardinal_flower_new <- cardinal_flower %>%
  filter(year %in% c(2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025) & month %in% c(1, 2, 3, 4, 5))

card_fl_27 <- cardinal_flower_new %>%
  filter(latitude >= 27.91748 & latitude < 33) %>%
  mutate(mean = mean(year_day, na.rm = TRUE)) %>%
  mutate(difference = mean - mean1014)

card_fl_33 <- cardinal_flower_new %>%
  filter(latitude > 33 & latitude < 36) %>%
  mutate(mean = mean(year_day, na.rm = TRUE)) %>%
  mutate(difference = mean - mean1014)

card_fl_36 <- cardinal_flower_new %>%
  filter(latitude > 36 & latitude < 39) %>%
  mutate(mean = mean(year_day, na.rm = TRUE)) %>%
  mutate(difference = mean - mean1014)

card_fl_39 <- cardinal_flower_new %>%
  filter(latitude > 39 & latitude <= 42) %>%
  mutate(mean = mean(year_day, na.rm = TRUE)) %>%
  mutate(difference = mean - mean1014)

card_fl_42 <- cardinal_flower_new %>%
  filter(latitude > 42 & latitude <= 47.65210) %>%
  mutate(mean = mean(year_day, na.rm = TRUE)) %>%
  mutate(difference = mean - mean1014)

card_1 <- card_fl_27 %>% full_join(card_fl_33)
card_2 <- card_fl_36 %>% full_join(card_fl_39)
card_3 <- card_fl_42 %>% full_join(card_2)

card_4 <- card_1 %>% full_join(card_2)

card_fl_all <- card_3 %>%
  full_join(card_4)
  1. I followed the same steps to find data for ruby-throated hummingbird migration and found their average arrival date between 2010 and 2014 (May 1st, the 121st day of the year).
rth_1995_2021 <- read.csv("RTH_1995_2021.csv")
rth_2022_2025 <- read.csv("RTH_2022_2025.csv")

rth_all <- rth_1995_2021 %>% full_join(rth_2022_2025)

ruby_throat <- rth_all %>%
  mutate(Date = parse_date_time(Date, orders = c("mdy", "ydm", "dmy"))) %>%
  mutate(Year = year(Date)) %>%
  mutate(year_day = yday(Date))

rth_early <- ruby_throat %>%
  filter(Year %in% c(2010, 2011, 2012, 2013, 2014) & Month %in% c(1, 2, 3, 4, 5))

rthmean1014 <- ruby_throat %>%
  filter(Year %in% c(2010, 2011, 2012, 2013, 2014) & Month %in% c(1, 2, 3, 4, 5)) %>%
  mutate(mean1014 = mean(year_day, na.rm = TRUE))

ruby_throat <- ruby_throat %>% full_join(rthmean1014)
ruby_throat <- ruby_throat %>%
  mutate(mean1014 = ifelse(is.na(mean1014), 121.0787, mean1014))
  1. I then similarly created the runy-throat dataset for 2015-2025, but I added 3 more bands of latitude to account for the larger number of observations recorded at higher and lower latitudes. I again combined these bands into one dataset, and created a difference column, showing the difference in days of arrival between the new dataset and the 2010 to 2014 average using mean - mean1014.
rth_all <- ruby_throat %>%
  filter(Year %in% c(2015, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2025) & Month %in% c(1, 2, 3, 4, 5, 6))

rth_24 <- rth_all %>%
  filter(Latitude >= 24.54630 & Latitude < 27) %>%
  mutate(mean = mean(year_day, na.rm = TRUE)) %>%
  mutate(difference = mean - mean1014)

rth_27 <- rth_all %>%
  filter(Latitude > 27 & Latitude < 30) %>%
  mutate(mean = mean(year_day, na.rm = TRUE)) %>%
  mutate(difference = mean - mean1014)

rth_30 <- rth_all %>%
  filter(Latitude > 30 & Latitude < 33) %>%
  mutate(mean = mean(year_day, na.rm = TRUE)) %>%
  mutate(difference = mean - mean1014)

rth_33 <- rth_all %>%
  filter(Latitude > 33 & Latitude < 36) %>%
  mutate(mean = mean(year_day, na.rm = TRUE)) %>%
  mutate(difference = mean - mean1014)

rth_36 <- rth_all %>%
  filter(Latitude > 36 & Latitude < 39) %>%
  mutate(mean = mean(year_day, na.rm = TRUE)) %>%
  mutate(difference = mean - mean1014)

rth_39 <- rth_all %>%
  filter(Latitude > 39 & Latitude < 42) %>%
  mutate(mean = mean(year_day, na.rm = TRUE)) %>%
  mutate(difference = mean - mean1014)

rth_42 <- rth_all %>%
  filter(Latitude > 42 & Latitude < 45) %>%
  mutate(mean = mean(year_day, na.rm = TRUE)) %>%
  mutate(difference = mean - mean1014)

rth_45 <- rth_all %>%
  filter(Latitude > 45 & Latitude <= 58.19338) %>%
  mutate(mean = mean(year_day, na.rm = TRUE)) %>%
  mutate(difference = mean - mean1014)

rth_1 <- rth_24 %>% full_join(rth_27)
rth_2 <- rth_30 %>% full_join(rth_33)
rth_3 <- rth_36 %>% full_join(rth_39)
rth_4 <- rth_42 %>% full_join(rth_45)

rth1 <- rth_1 %>% full_join(rth_2)
rth2 <- rth_3 %>% full_join(rth_4)

rubythroat_all <- rth1 %>% full_join(rth2)
  1. I then created color palettes using viridis scales and colorNumeric from the Leaflet package for my two maps, specifying to use the difference column in each dataset to map the colors to.
cpal <- colorNumeric(palette = "rocket", domain = card_fl_all$difference, na.color = NA)

rpal <- colorNumeric(palette = "mako", domain = rubythroat_all$difference, na.color = NA)

Download and clean all required data

Add any additional processing steps here. Hi

Results

[~200 words]

Tables and figures (maps and other graphics) are carefully planned to convey the results of your analysis. Intense exploration and evidence of many trials and failures. The author looked at the data in many different ways before coming to the final presentation of the data.

Show tables, plots, etc. and describe them.r

Conclusions

[~200 words]

Clear summary adequately describing the results and putting them in context. Discussion of further questions and ways to continue investigation.

References

All sources are cited in a consistent manner

Thanks for checking out my web site!