MeadoWatch: Wildflower Phenology in Mount Rainier National Park¶

Category: Phenology · Size: 6.9 MB · Format: CSV, XLSX License: CC0-1.0 · Zenodo record · Data sheet on the CSDH

Long-term database (2013-2019) with >42,000 phenological observations of 17 wildflower species across 28 plots, collected by 500+ volunteers.

The data is mounted read-only at /srv/data/meadowatch-phenology/. Save anything you produce in your personal folder (~/).

What's in the dataset¶

MeadoWatch volunteers hiked two trails in Mount Rainier National Park (Reflection Lakes and Glacier Basin) and, at fixed plots, recorded the phenophase of 17 wildflower species. The archive holds several linked tables:

  • MW_PhenoDat_2013_2019_anonymized.csv - the raw survey: one row per species × plot × visit, with presence/absence of Snow, Bud, Flower, Fruit, Disperse.
  • MW_Phenocurves.csv - the processed result: for each year × plot × species the authors fitted a flowering curve and extracted the peak-flowering day of year (peak) and the snow-disappearance date (SDD).
  • MW_SiteInfo_2013_2020.csv - plot coordinates and elevation.
  • MW_metadata.xlsx - the data dictionary and the species 4-letter-code → botanical-name table.

Our scientific question: do wildflowers flower earlier in years when the snow melts earlier? The Phenocurves table already gives us the two ingredients we need.

In [1]:
from pathlib import Path
import pandas as pd

DATA = Path('/srv/data/meadowatch-phenology')

for f in sorted(DATA.iterdir()):
    print(f"{f.name:<45}{f.stat().st_size/1e6:>8.2f} MB")

# Species 4-letter code -> full botanical name (from the metadata workbook)
species_names = (pd.read_excel(DATA / 'MW_metadata.xlsx',
                               sheet_name='Species 4 letters code to full')
                   .set_index('4LetCod')['Fullname'].to_dict())
print(f"\n{len(species_names)} species, e.g. LUAR = {species_names['LUAR']}")
MW_PhenoDat_2013_2019_anonymized.csv             6.79 MB
MW_Phenocurves.csv                               0.06 MB
MW_SDDall.csv                                    0.02 MB
MW_SiteInfo_2013_2020.csv                        0.00 MB
MW_Volunteer_info_2013_2019_anonymized.csv       0.01 MB
MW_metadata.xlsx                                 0.02 MB

17 species, e.g. LUAR = Lupinus arcticus

The processed flowering curves¶

Load MW_Phenocurves.csv. The key columns are:

  • SDD - snow-disappearance date, as a day of year (e.g. 179 = 28 June). Lower = snow melted earlier = an early/warm year.
  • peak - the fitted peak-flowering day of year. Lower = flowered earlier.
  • year, transect, site_code, species, plus curve-shape fields (duration, max).

Each row is one species at one plot in one year, so we can ask how peak moves with SDD.

In [2]:
pc = pd.read_csv(DATA / 'MW_Phenocurves.csv')
pc['name'] = pc['species'].map(species_names)

print('shape:', pc.shape, '| years:', sorted(pc.year.unique()))
print('records per species:')
print(pc.species.value_counts())
pc.head()
shape:
 (602, 10) | years: [np.int64(2013), np.int64(2014), np.int64(2015), np.int64(2016), np.int64(2017), np.int64(2018), np.int64(2019)]
records per species:
species
LUAR    111
VASI     76
POBI     58
CAPA     49
PEBR     43
ERPE     37
LIGR     35
ASLE     29
MEPA     28
ANOC     25
ARLA     23
CAMI     20
MIAL     20
ERMO     19
ANAR     12
ERGR     10
LICA      7
Name: count, dtype: int64
Out[2]:
year transect site_code plot SDD species peak duration max name
0 2013 Reflection Lakes RL1_13 RL1 179 ERPE 216.014400 -0.026337 3.207911 Erigenon peregrinus
1 2013 Reflection Lakes RL1_13 RL1 179 LIGR 228.177999 -0.012396 1.986757 Ligusticum grayi
2 2013 Reflection Lakes RL1_13 RL1 179 LUAR 209.814515 -1.754527 135.626422 Lupinus arcticus
3 2013 Reflection Lakes RL2_13 RL2 182 CAPA 222.894965 -0.002931 1.503143 Castilleja parviflora
4 2013 Reflection Lakes RL2_13 RL2 182 LUAR 216.188234 -0.018704 2.160246 Lupinus arcticus

Where does "peak flowering" come from?¶

Before trusting the fitted peak, let's see the raw signal it summarises. For one common species (Lupinus arcticus, code LUAR) we take every survey, and for each day of year compute the fraction of plots where the species was flowering. Doing this year by year shows the flowering pulse - and how its timing slides between an early-snow year and a late-snow year.

In [3]:
import matplotlib.pyplot as plt
import numpy as np

raw = pd.read_csv(DATA / 'MW_PhenoDat_2013_2019_anonymized.csv', low_memory=False)
raw['Date'] = pd.to_datetime(raw['Date'], format='%m/%d/%y', errors='coerce')
raw['DOY'] = raw['Date'].dt.dayofyear
raw['Flower'] = pd.to_numeric(raw['Flower'], errors='coerce')

luar = raw[(raw.Species == 'LUAR') & raw.Flower.notna()]

fig, ax = plt.subplots(figsize=(9, 5))
cmap = plt.cm.viridis(np.linspace(0, 1, luar.Year.nunique()))
for c, (yr, g) in zip(cmap, luar.groupby('Year')):
    curve = g.groupby('DOY').Flower.mean()
    curve = curve[curve.index.to_series().between(150, 260)]
    # light smoothing so the pulse is readable
    curve = curve.rolling(5, center=True, min_periods=1).mean()
    ax.plot(curve.index, curve.values, color=c, label=str(yr), lw=2)

ax.set(xlabel='Day of year', ylabel='Fraction of plots in flower',
       title='Lupinus arcticus (LUAR): flowering pulse shifts year to year')
ax.legend(title='Year', ncol=2)
plt.tight_layout()
plt.show()
No description has been provided for this image

The pulse has a clear peak each year, and that peak lands on a different day depending on the year - exactly the number the Phenocurves table captures as peak. Now we can test what drives it.

Does earlier snowmelt mean earlier flowering?¶

Plot the fitted peak-flowering date against the snow-disappearance date for every record, and fit an ordinary-least-squares line. The slope tells us how many days flowering shifts for each extra day of snow, and the p-value tells us whether the shift is real.

In [4]:
import statsmodels.formula.api as smf

model = smf.ols('peak ~ SDD', data=pc).fit()
slope = model.params['SDD']
r2, pval = model.rsquared, model.pvalues['SDD']

fig, ax = plt.subplots(figsize=(8, 6))
colors = {'Reflection Lakes': '#1f77b4', 'Glacier Basin': '#d62728'}
for t, g in pc.groupby('transect'):
    ax.scatter(g.SDD, g.peak, s=18, alpha=0.6, color=colors[t], label=t)

xs = np.array([pc.SDD.min(), pc.SDD.max()])
ax.plot(xs, model.params['Intercept'] + slope * xs, 'k--', lw=2,
        label=f'OLS: peak = {model.params["Intercept"]:.0f} + {slope:.2f}·SDD')
ax.set(xlabel='Snow-disappearance date (day of year)',
       ylabel='Peak-flowering date (day of year)',
       title='Earlier snowmelt → earlier flowering across Mount Rainier')
ax.legend()
ax.text(0.03, 0.97, f'$R^2$ = {r2:.2f}\np = {pval:.1e}\nn = {len(pc)}',
        transform=ax.transAxes, va='top',
        bbox=dict(boxstyle='round', fc='white', alpha=0.8))
plt.tight_layout()
plt.show()

print(f'Slope = {slope:.2f} days of flowering delay per day of later snowmelt')
No description has been provided for this image
Slope = 0.45 days of flowering delay per day of later snowmelt

The relationship is strong and highly significant: plots where the snow lingers flower later, roughly half a day of flowering delay for every extra day of snow. Snowmelt timing - not the calendar - sets the phenological clock in these meadows.

Which species respond most?¶

Not every wildflower reacts equally. For each well-sampled species we fit its own peak ~ SDD line and read off the slope (its snowmelt sensitivity). A slope near 1 means the species tracks snowmelt almost day-for-day; a smaller slope means it is more anchored to the calendar.

In [5]:
rows = []
for sp, g in pc.groupby('species'):
    if len(g) < 20:               # need enough plot-years for a stable fit
        continue
    m = smf.ols('peak ~ SDD', data=g).fit()
    rows.append({'species': sp, 'name': species_names[sp], 'n': len(g),
                 'slope': m.params['SDD'], 'se': m.bse['SDD']})

sens = pd.DataFrame(rows).sort_values('slope')

fig, ax = plt.subplots(figsize=(8, 6))
ax.errorbar(sens.slope, range(len(sens)), xerr=1.96 * sens.se,
            fmt='o', color='#2c7fb8', ecolor='gray', capsize=3)
ax.axvline(1, ls=':', color='green', label='tracks snowmelt day-for-day')
ax.axvline(0, ls='--', color='red', label='no response')
ax.set_yticks(range(len(sens)))
ax.set_yticklabels([f'{r.name} ({r.species})' for r in sens.itertuples()])
ax.set(xlabel='Snowmelt sensitivity  (Δ peak-flowering days per Δ snowmelt day)',
       title='Every species flowers earlier after early snowmelt — but by different amounts')
ax.legend(loc='lower right')
plt.tight_layout()
plt.show()

sens.round(2)
No description has been provided for this image
Out[5]:
species name n slope se
1 ARLA Arnica latifolia 23 0.19 0.16
8 MEPA Mertensia paniculata 28 0.44 0.15
2 ASLE Aster ledophyllus 29 0.48 0.14
7 LUAR Lupinus arcticus 111 0.54 0.04
3 CAMI Castilleja miniata 20 0.54 0.11
10 PEBR Pedicularis bracteosa 43 0.61 0.05
12 VASI Valeriana sitchensis 76 0.66 0.04
9 MIAL Microseris alpestris 20 0.73 0.06
6 LIGR Ligusticum grayi 35 0.80 0.07
11 POBI Polygonum bistortoides 58 0.80 0.05
4 CAPA Castilleja parviflora 49 0.82 0.07
0 ANOC Anemone occidentalis 25 0.86 0.07
5 ERPE Erigenon peregrinus 37 0.93 0.05

Takeaway. Across 2013-2019, MeadoWatch's volunteer observations show that Mount Rainier's wildflowers are governed by snowmelt: every well-sampled species flowers earlier when the snow disappears earlier, and the whole meadow community shifts by roughly a day of flowering per day of snowmelt. In a warming climate, where snow melts sooner, these results predict progressively earlier wildflower seasons - the kind of shift the SDD column lets you quantify directly. Try relating SDD to elevation from MW_SiteInfo_2013_2020.csv, or repeating the sensitivity analysis for fruiting instead of flowering.

Your turn¶

This is just the starting point. Some ideas:

  • Check the dataset challenge on its CSDH data sheet.
  • Work on a copy: right-click the file → Duplicate (or Save Notebook As…). Your changes only live in your Hub space — they're never pushed to GitHub.
  • Edited this notebook and want the original back? Use the Restore cell below (or the restore.ipynb notebook).
  • Questions and results: on the platform forum.

Attribution: data from MeadoWatch: Wildflower Phenology in Mount Rainier National Park, license CC0-1.0. Notebook from the Citizen Science Data Hub (CSDH) — Fundación Ibercivis.

In [6]:
# ⚠️ RESTORE: this DISCARDS YOUR CHANGES to this notebook and resets it to the original.
# 1. Uncomment the line below (remove the #)   2. Run this cell
# 3. Then: menu File → Reload Notebook from Disk

# !git -C ~/citizen-science-data fetch -q origin && git -C ~/citizen-science-data checkout origin/main -- meadowatch-phenology.ipynb && echo "Restored. Now: File → Reload Notebook from Disk"