Migratory Connectivity in Wetland Birds (Citizen Science + Isotopes)¶

Category: Ornithology · Size: 1.7 GB · Format: ZIP License: CC0-1.0 · Zenodo record · Data sheet on the CSDH

Citizen monitoring and distribution data for three wetland bird species (Sora, Virginia Rails, Yellow Rails) combined with stable isotopes to trace migratory routes across the USA.

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

⚠️ Large dataset (1.7 GB). Your session has 4 GB RAM and your home folder is shared — don't extract the whole archive. Read the entries you need straight from inside the ZIP (see below); if you must extract, take only specific files, not everything.

What's in the archive¶

The 1.7 GB dataset is split across several ZIPs. Most of the volume is raster layers (.asc) and species-distribution-model rasters used to build maps — heavy and not needed to answer the migration question. The tabular science lives in data_files.zip, and the single most useful table is stable_isotopes.csv: the feather deuterium measurements this study is built on. We read the CSVs straight from inside the ZIP (no extraction).

In [1]:
from pathlib import Path
import zipfile, io
import pandas as pd, numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.stats import pearsonr, linregress

sns.set_theme(style="whitegrid", context="notebook")
DATA = Path('/srv/data/wetland-birds-migration')
ARCHIVE = DATA / 'data_files.zip'

# List just the CSV tables inside data_files.zip (skip the huge rasters)
with zipfile.ZipFile(ARCHIVE) as zf:
    for i in zf.infolist():
        if i.filename.lower().endswith('.csv'):
            print(f"{i.file_size/1e3:8.1f} kB  {i.filename}")
     4.2 kB  2015_banding.csv
     7.8 kB  20151211_chapter3_nau_isotope_1.csv
     5.5 kB  20160225_chapter3_nau_isotope_2.csv
  3056.9 kB  bsc_gl_rails.csv
   936.7 kB  bsc_pp_rails.csv
  1719.0 kB  bsc_usfws_combined_master.csv
     6.6 kB  jpe12723-sup-0001-AppendixS1.csv
     4.6 kB  nau_round_2.csv
   737.9 kB  sdmdata_sora.csv
   155.6 kB  sdmdata_vira.csv
   490.8 kB  sdmdata_yera.csv
     9.3 kB  sk_banding.csv
     1.5 kB  sk_rails_2014.csv
    21.2 kB  stable_isotopes.csv

The idea: feathers as latitude recorders¶

Rainfall carries a stable-hydrogen isotope signature (expressed as δD, or dD) that gets steadily more negative toward the north. A bird locks that signature into a feather when it grows it on the breeding grounds. Months later a volunteer may catch the same bird far to the south on migration or in winter — but the feather still "remembers" the latitude where it was grown.

So by reading dD from feathers of birds caught at southern sites, we can ask where those birds came from, and whether different capture sites draw from the same or different northern origins — the essence of migratory connectivity.

We start with stable_isotopes.csv and clean up the species labels.

In [2]:
with zipfile.ZipFile(ARCHIVE) as zf:
    iso = pd.read_csv(zf.open('stable_isotopes.csv'))

print("raw species labels:", iso['species'].value_counts().to_dict())

# Tidy: 'yera recap' is a recaptured Yellow Rail; 'year' is a data-entry artefact -> drop it
iso['species'] = iso['species'].str.strip().replace({'yera recap': 'yera'})
iso = iso[iso['species'].isin(['sora', 'vira', 'yera'])].copy()

names = {'sora': 'Sora', 'vira': 'Virginia Rail', 'yera': 'Yellow Rail'}
iso['species_name'] = iso['species'].map(names)

print("\ncleaned n =", len(iso))
print(iso.groupby('species_name')['dD'].agg(['count', 'mean', 'min', 'max']).round(1))
raw species labels: {'sora': 162, 'yera': 29, 'vira': 14, 'year': 10, 'yera recap': 2}

cleaned n = 207
               count   mean    min   max
species_name                            
Sora             162 -124.6 -193.5 -48.3
Virginia Rail     14 -119.1 -153.5 -75.2
Yellow Rail       31 -113.4 -168.4  -3.9

1. How do the three species compare?¶

A first look at the feather dD distribution per species. More negative values point to more northerly feather origins.

In [3]:
order = ['Sora', 'Virginia Rail', 'Yellow Rail']
fig, ax = plt.subplots(figsize=(8, 5))
sns.boxplot(data=iso, x='species_name', y='dD', order=order,
            width=0.55, showfliers=False, palette='crest', ax=ax)
sns.stripplot(data=iso, x='species_name', y='dD', order=order,
              color='0.25', size=3.5, alpha=0.6, jitter=0.18, ax=ax)
ax.set_xlabel('')
ax.set_ylabel('Feather δD (‰)  —  more negative = further north')
ax.set_title('Feather deuterium by species')
plt.tight_layout()
plt.show()
/tmp/ipykernel_42502/2541507666.py:3: FutureWarning: 

Passing `palette` without assigning `hue` is deprecated and will be removed in v0.14.0. Assign the `x` variable to `hue` and set `legend=False` for the same effect.

  sns.boxplot(data=iso, x='species_name', y='dD', order=order,
No description has been provided for this image

2. Calibrate: does δD really track latitude?¶

Before interpreting our birds, we confirm the ruler. jpe12723-sup-0001-AppendixS1.csv holds feather δD from rails sampled on their breeding grounds, where capture latitude ≈ feather-origin latitude. This file uses Latin-1 encoding and stores coordinates as degrees-minutes-seconds, so we parse them first.

In [4]:
import re
with zipfile.ZipFile(ARCHIVE) as zf:
    cal = pd.read_csv(zf.open('jpe12723-sup-0001-AppendixS1.csv'), encoding='latin-1')

def dms_to_deg(s):
    # Coordinates look like  29° 38' 58.72"N  -> pull the three numbers, apply hemisphere
    nums = re.findall(r'[0-9.]+', str(s))
    if len(nums) < 3:
        return np.nan
    deg, minute, sec = float(nums[0]), float(nums[1]), float(nums[2])
    val = deg + minute/60 + sec/3600
    return -val if ('S' in str(s) or 'W' in str(s)) else val

cal['lat'] = cal['latitude'].map(dms_to_deg)
cal = cal.dropna(subset=['lat', 'dD'])

r, p = pearsonr(cal['lat'], cal['dD'])
slope, intercept, *_ = linregress(cal['lat'], cal['dD'])

fig, ax = plt.subplots(figsize=(8, 5))
sns.scatterplot(data=cal, x='lat', y='dD', hue='species', s=45, alpha=0.85, ax=ax)
xs = np.array([cal['lat'].min(), cal['lat'].max()])
ax.plot(xs, intercept + slope*xs, color='crimson', lw=2,
        label=f'fit: r = {r:.2f}, p = {p:.1e}')
ax.set_xlabel('Capture latitude (°N) on breeding grounds')
ax.set_ylabel('Feather δD (‰)')
ax.set_title('Calibration: feather δD gets more negative going north')
ax.legend(title='')
plt.tight_layout()
plt.show()

print(f"Pearson r = {r:.2f}  (p = {p:.1e}) over {len(cal)} feathers, "
      f"latitude {cal['lat'].min():.0f}-{cal['lat'].max():.0f}N")
No description has been provided for this image
Pearson r = -0.89  (p = 1.7e-19) over 54 feathers, latitude 30-43N

The relationship is strong and negative (r ≈ −0.89): every step north drives δD lower. That validates δD as a latitude proxy, so we can now read our capture data through it.

3. Migratory connectivity in the Sora¶

Sora is the best-sampled species. We attach an approximate latitude to each capture state and plot feather δD against it. If birds molted near where they were caught, δD would fall along the calibration line. Instead, southern capture sites hold birds with a wide spread of δD, including very negative (far-north) values — the fingerprint of long-distance migrants mixing at wintering/stopover sites.

In [5]:
# Approximate latitude of each capture state/province (°N)
state_lat = {
    'sk': 52.0, 'minnesota': 46.7, 'ohio': 40.4, 'illinois': 40.0,
    'missouri': 38.5, 'arkansas': 34.8, 'south_carolina': 33.8,
    'mississippi': 32.7, 'la': 30.2,
}
state_lbl = {
    'sk': 'Saskatchewan', 'minnesota': 'Minnesota', 'ohio': 'Ohio',
    'illinois': 'Illinois', 'missouri': 'Missouri', 'arkansas': 'Arkansas',
    'south_carolina': 'S. Carolina', 'mississippi': 'Mississippi', 'la': 'Louisiana',
}
sora = iso[iso['species'] == 'sora'].copy()
sora['cap_lat'] = sora['state'].map(state_lat)
sora['cap_state'] = sora['state'].map(state_lbl)
sora = sora.dropna(subset=['cap_lat'])

order_states = (sora.groupby('cap_state')['cap_lat'].first()
                    .sort_values(ascending=False).index)

fig, axes = plt.subplots(1, 2, figsize=(13, 5), gridspec_kw={'width_ratios': [1.3, 1]})

sns.boxplot(data=sora, y='cap_state', x='dD', order=order_states,
            showfliers=False, palette='mako', ax=axes[0])
sns.stripplot(data=sora, y='cap_state', x='dD', order=order_states,
              color='0.2', size=3, alpha=0.5, ax=axes[0])
axes[0].set_xlabel('Feather δD (‰)')
axes[0].set_ylabel('Capture state (north → south)')
axes[0].set_title('Sora: origin signal at each capture site')

# Overlay Sora on the calibration line to gauge how far north they came from
axes[1].plot(xs, intercept + slope*xs, color='crimson', lw=2, label='breeding-ground fit')
axes[1].scatter(sora['cap_lat'], sora['dD'], s=28, alpha=0.6,
                color='steelblue', label='Sora captures')
axes[1].set_xlabel('Capture latitude (°N)')
axes[1].set_ylabel('Feather δD (‰)')
axes[1].set_title('Captures vs. the breeding-ground ruler')
axes[1].legend()
plt.tight_layout()
plt.show()
/tmp/ipykernel_42502/1384690687.py:22: FutureWarning: 

Passing `palette` without assigning `hue` is deprecated and will be removed in v0.14.0. Assign the `y` variable to `hue` and set `legend=False` for the same effect.

  sns.boxplot(data=sora, y='cap_state', x='dD', order=order_states,
No description has been provided for this image

Reading the plots. On the breeding-ground ruler (red line) a bird sitting below the line at its capture latitude carries a more-negative signal than the local rainfall — it grew its feather further north and travelled south. Many southern-caught Sora fall well below the line, so wetlands like Missouri and Louisiana pool birds from a broad northern range rather than one tidy source: weak, diffuse migratory connectivity. That matters for conservation — protecting one wintering marsh helps rails from many breeding regions at once.

This is a starting point, not the last word: the state latitudes are coarse, and a proper study assigns each feather a probabilistic origin against a precipitation isoscape (see the intermediate_files assignment tables and scripts/). Ideas to extend it are below.

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 Migratory Connectivity in Wetland Birds (Citizen Science + Isotopes), 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 -- wetland-birds-migration.ipynb && echo "Restored. Now: File → Reload Notebook from Disk"