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.
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.
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
| 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.
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()
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.
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')
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.
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)
| 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.ipynbnotebook). - 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.
# ⚠️ 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"