Authors¶
Julien Emile-Geay and Deborah Khider
Preamble¶
Hodell et al (2023) report a continuous millennially resolved record of stable isotopes of planktic and benthic foraminifera at IODP Site U1385 (the “Shackleton Site”) from the southwestern Iberian margin for the last 1.5 million years, which includes the Middle Pleistocene Transition (MPT). Here we use this high-resolution record in the context of Pyleoclim, which permits coherence analysis and advanced visualizations.
Goals¶
Technical skills involved:
using the
pangaeapypackage to access datasets stored on PANGAEAapplying and interpreting spectral analysis in Pyleoclim
applying and interpreting wavelet analysis in Pyleoclim
applying and interpreting Wavelet Transform Coherency analysis
Data¶
This notebook makes use of “Shackleton site” oxygen isotopes records. The main data repository is on PANGAEA and contains 9 sub-datasets, some of which we will be analyzing here.
Reading time¶
10min
Keywords¶
Pyleoclim, Shackleton Site, Spectral Analysis, Wavelet Analysis, Coherence Analysis
Let’s import the needed packages:
%load_ext autoreload
%autoreload 2
import pyleoclim as pyleo
from pangaeapy.pandataset import PanDataSet# load the dataset
dsb = PanDataSet('10.1594/PANGAEA.951382')
print(dsb.title)
display(dsb.data.head())Oxgyen and carbon isotope data for benthic foraminifera at IODP Site 339-U1385
Let’s have a look at the information stored in each column:
dsb.data.columnsIndex(['Event', 'Latitude', 'Longitude', 'Sample label', 'Depth sed',
'Depth comp', 'Depth comp r', 'Depth corr cr', 'Age', 'Age_2', 'Age_3',
'Age_4', 'Species', 'Cibicidoides spp. δ18O', 'Cibicidoides spp. δ13C',
'G. affinis δ18O', 'G. affinis δ13C', 'Uvigerina spp. δ18O',
'Uvigerina spp. δ13C', 'Foram benth δ18O', 'Elevation'],
dtype='object')dsb.data['Foram benth δ18O']Let’s create a GeoSeries object. This object holds additional metadata compared to its Series parent, allowing for better visualizations. Since GeoSeries is a subclass of Series, all the methods available in Series are applicable to GeoSeries.
tsb = pyleo.GeoSeries(time=dsb.data['Age'], value=dsb.data['Foram benth δ18O'],
lat = dsb.data['Latitude'].iloc[0], lon = dsb.data['Longitude'].iloc[0],
elevation = dsb.data['Elevation'].iloc[0], archiveType = 'MarineSediment',
time_name='Age', time_unit='ka BP', label = 'Benthic foram composite',
value_name='$\delta^{18}$O', value_unit=u'‰')Time axis values sorted in ascending order
One of these methods is the dashboard which plots the time series, maps the location of the archive and performs spectral analysis. We will be using the dashboard method here as an example. For speed, the timeseries was interpolated to run spectral analysis with the MTM method.
fig,ax = tsb.interp().dashboard(spectral_kwargs={'method':'mtm'}, plt_kwargs={'invert_yaxis':True})Time axis values sorted in ascending order
Performing spectral analysis on individual series: 100%|██████████| 200/200 [00:04<00:00, 44.60it/s]

We see obliquity and eccentricity periods throughout the record, as expected.
You can also plot the timeseries with its appropriate geological era as such:
fig, ax = tsb.plot(figsize=(10, 4),linewidth=0.5)
ax.invert_yaxis() # d18O is traditionally inverted
fig, ax = pyleo.add_GTS(fig, ax, ranks=['Period', 'Epoch'], location='above')Using rdflib to load GTS information from ICS

Planktonic record¶
The planktonic record is also available on PANGAEA.
dsp = PanDataSet('10.1594/PANGAEA.951386')
print(dsp.title)
display(dsp.data.head())Oxgyen and carbon isotope data for the planktonic foraminifera Globigerina bulloides at IODP Site 339-U1385
tsp = pyleo.GeoSeries(time=dsp.data['Age'], value=dsp.data['G. bulloides δ18O'],
lat = dsp.data['Latitude'].iloc[0], lon = dsp.data['Longitude'].iloc[0],
elevation = dsb.data['Elevation'].iloc[0], archiveType = 'MarineSediment',
time_name='Age', time_unit='ka BP', label = 'Planktonic record (G. bulloides)',
value_name='$\delta^{18}$O', value_unit=u'‰')
fig, ax = tsp.plot(figsize=(10, 4),linewidth=0.5,invert_yaxis=True, legend = False, ylabel = "G. Bulloides $\delta^{18}$O")
fig, ax = pyleo.add_GTS(fig, ax, ranks=['Period', 'Epoch'], location='above')NaNs have been detected and dropped.
Time axis values sorted in ascending order
Using rdflib to load GTS information from ICS

Let’s plot the planktonic and benthic data together. To do so, you can create a MultipleGeoSeries object and use the stackplot function:
ms = tsp & tsb
fig, ax = ms.stackplot()
ax[0].invert_yaxis()
ax[1].invert_yaxis()
res, stats , sign = pyleo.utils.tsbase.resolution(tsp.time)
import matplotlib.pyplot as plt
plt.hist(res)
statsDescribeResult(nobs=np.int64(8100), minmax=(np.float64(0.0), np.float64(3.660000000000025)), mean=np.float64(0.1766456790123457), variance=np.float64(0.035434666390760095), skewness=np.float64(8.636534814144614), kurtosis=np.float64(110.7583996695836))
The data are unenvely spaced, so we will either need to intepret on a common evenly spaced time axis or use methods appropriate for unevenly spaced dataset (such as the weighted wavelet Z transform) for coherence analysis.
Coherence Analysis¶
Let’s have a look at the coherence between the planktonic and benthic data:
coh = tsp.wavelet_coherence(tsb,method='wwz')OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
coh_sig = coh.signif_test(number=100)Performing wavelet coherence on surrogate pairs: 0%| | 0/100 [00:00<?, ?it/s]OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
Performing wavelet coherence on surrogate pairs: 100%|██████████| 100/100 [04:55<00:00, 2.96s/it]
coh_sig.dashboard()(<Figure size 900x1200 with 6 Axes>,
{'ts1': <Axes: ylabel='$\\delta^{18}$O [‰]'>,
'ts2': <Axes: xlabel='Age [ka BP]', ylabel='$\\delta^{18}$O [‰]'>,
'wtc': <Axes: ylabel='Scale [kyrs]'>,
'xwt': <Axes: xlabel='Age [ka BP]', ylabel='Scale [kyrs]'>})
XWT (cross-wavelet transform) informs about areas where there is high common power between the two series. The analysis reveals high common power in the eccentricity band (~100kyr) and to some extent, the obliquity band (~40kyr), which phase angles indicating a out of phase behavior between the two.
WTC (wavelet transform coherence) shows areas of common behavior between the two time series even if there is low power. The analysis shows common behavior accross the 23-200 kyr band through the entire record.
Comparison to insolation¶
climlab by Brian Rose does many great things. One of them is to compute insolation curves.
As a proof of concept, we will create an insolation curve for JJA at the latitude of the site (37°N).
from climlab import constants as const
from climlab.solar.orbital import OrbitalTable
from climlab.solar.insolation import daily_insolation
import numpy as np
kyears = np.linspace(-1500, 0, 1001 ) # specify time interval and units
orb = OrbitalTable.interp(kyear=kyears) # subset of orbital parameters for specified time
days = np.linspace(0, const.days_per_year, 365)
Qsb = daily_insolation(dsb.data['Latitude'].iloc[0], days, orb) # generate insolation at IODP339-U1385 latitude
Qsb_jja = np.mean(Qsb[:,151:243], axis=1) # Julian days 152-243 are JJALet’s create a Pyleoclim Series from the insolation curve.
ts_ins = pyleo.Series(value = Qsb_jja, time = -kyears,
value_name = 'Insolation', time_name = 'Age',
value_unit = '$W/m^{2}$', time_unit='ky BP', label='Insolation', verbose=False )Let’s plot the planktonic record with insolation:
agent code I have two Pyleoclim Series, tsp, which contains information about planktonic d18O and ts_ins, which is an insolation curve. I need to plot them on the same plot using two y-axis, with insolation on the axis on the right.
# This code plots the planktonic d18O Series (tsp) and the insolation Series (ts_ins) on the same figure with two y-axes. The left y-axis shows d18O (inverted, as customary), and the right y-axis shows insolation. It converts both Pyleoclim Series to pandas Series for straightforward plotting with matplotlib, aligns x-limits to their common time coverage, and combines legends from both axes.
# Auto-generated by PaleoPal CodeGenerationAgent with contextual search
# Plot planktonic \u03b418O (tsp) and insolation (ts_ins) on dual y-axes, with insolation on the right
import matplotlib.pyplot as plt
import pandas as pd
# Convert Pyleoclim Series to pandas Series (index=time, values=value)
#tsp_pd = tsp.to_pandas(paleo_style=False)
#ts_ins_pd = ts_ins.to_pandas(paleo_style=False)
# Extract numpy arrays for plotting
#x1 = tsp_pd.index.values
#y1 = tsp_pd.values
#x2 = ts_ins_pd.index.values
#y2 = ts_ins_pd.values
#DK: Going through Pandas is technically correct but right now Matplotlib cannot plot large datetime values
x1 = tsp.time
y1 = tsp.value
x2 = ts_ins.time
y2 = ts_ins.value
# Create figure and primary axis
fig, ax = plt.subplots(figsize=(10, 4))
# Left y-axis: planktonic \u03b418O
line1, = ax.plot(x1, y1, color="tab:blue", linewidth=0.8, label="G Bulloides \u03b418O")
ax.set_ylabel("G. Bulloides \u03b418O (\u2030)", color="tab:blue")
ax.tick_params(axis="y", labelcolor="tab:blue")
ax.invert_yaxis()
# Right y-axis: insolation
ax2 = ax.twinx()
line2, = ax2.plot(x2, y2, color="tab:orange", linewidth=0.8, label="Insolation (JJA)")
ax2.set_ylabel("Insolation (W/m^2)", color="tab:orange")
ax2.tick_params(axis="y", labelcolor="tab:orange")
# X-axis label and common limits
ax.set_xlabel("Age (ka BP)")
try:
xmin = max(float(x1.min()), float(x2.min()))
xmax = min(float(x1.max()), float(x2.max()))
if xmin < xmax:
ax.set_xlim(xmin, xmax)
except Exception:
# If limits cannot be computed, fall back to matplotlib defaults
pass
# Combined legend
lines = [line1, line2]
labels = [l.get_label() for l in lines]
ax.legend(lines, labels, loc="upper right")
# Cosmetic tweaks
ax.grid(True, axis="x", linestyle=":", alpha=0.3)
#plt.tight_layout()
plt.show()
Let’s now run coherence analysis between the planktonic record and JJA insolation at 37°N.
agent code Run wavelet coherence between tsp and ts_ins
# Computes WWZ-based wavelet coherence between existing Series `tsp` (planktonic d18O) and `ts_ins` (JJA insolation), restricts both to their common time overlap, runs a Monte Carlo significance test, produces a coherence dashboard plot, and returns coherence objects plus phase statistics for precession/obliquity/eccentricity bands.
# Auto-generated by PaleoPal CodeGenerationAgent with contextual search
import numpy as np
import pyleoclim as pyleo
# Run wavelet coherence between planktonic \u03b418O (tsp) and JJA insolation (ts_ins)
# Prefer WWZ since tsp is unevenly spaced; ts_ins is evenly spaced but WTC can handle both.
# Ensure time axes are ascending (some paleo series may be descending)
try:
tsp_sorted = tsp.sort(verbose=False, ascending=True, keep_log=False)
except Exception:
tsp_sorted = tsp
try:
ts_ins_sorted = ts_ins.sort(verbose=False, ascending=True, keep_log=False)
except Exception:
ts_ins_sorted = ts_ins
# Restrict to common overlap to avoid edge effects and unnecessary computation
try:
tmin = max(np.nanmin(tsp_sorted.time), np.nanmin(ts_ins_sorted.time))
tmax = min(np.nanmax(tsp_sorted.time), np.nanmax(ts_ins_sorted.time))
tsp_olap = tsp_sorted.slice((tmin, tmax))
ts_ins_olap = ts_ins_sorted.slice((tmin, tmax))
except Exception:
tsp_olap = tsp_sorted
ts_ins_olap = ts_ins_sorted
# Coherence settings: orbital bands of interest ~20-200 kyr => frequencies 1/200 to 1/20 1/kyr
freq_kwargs = {"fmin": 1/200, "fmax": 1/20, "nf": 36}
# Compute wavelet coherence
coh_ins = tsp_olap.wavelet_coherence(
ts_ins_olap,
method="wwz",
freq="log",
freq_kwargs=freq_kwargs,
verbose=True
)
# Significance testing (increase number for research-grade, e.g., 200+)
coh_ins_sig = coh_ins.signif_test(number=50)
# Dashboard plot (XWT + WTC + phase arrows + global coherence)
fig = coh_ins_sig.dashboard()
# Optional: phase statistics for specific scale bands (in kyr)
# Example: precession (19-23 kyr), obliquity (35-45 kyr), eccentricity (80-120 kyr)
phase_precession = coh_ins.phase_stats(scales=[19, 23])
phase_obliquity = coh_ins.phase_stats(scales=[35, 45])
phase_eccentricity = coh_ins.phase_stats(scales=[80, 120])
# Collect key outputs in a dict for downstream use
results = {
"coherence": coh_ins,
"coherence_signif": coh_ins_sig,
"phase_precession": phase_precession,
"phase_obliquity": phase_obliquity,
"phase_eccentricity": phase_eccentricity,
"overlap_timespan": (float(np.nanmin(tsp_olap.time)), float(np.nanmax(tsp_olap.time))) if hasattr(tsp_olap, "time") else None,
}
resultsinconsistent `tau`, recalculating...
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
Performing wavelet coherence on surrogate pairs: 0%| | 0/50 [00:00<?, ?it/s]inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
OMP: Info #276: omp_set_nested routine deprecated, please use omp_set_max_active_levels instead.
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
Performing wavelet coherence on surrogate pairs: 2%|▏ | 1/50 [00:15<13:00, 15.93s/it]inconsistent `tau`, recalculating...
Performing wavelet coherence on surrogate pairs: 28%|██▊ | 14/50 [00:26<00:54, 1.51s/it]inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
Performing wavelet coherence on surrogate pairs: 34%|███▍ | 17/50 [00:26<00:33, 1.01s/it]inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
Performing wavelet coherence on surrogate pairs: 44%|████▍ | 22/50 [00:26<00:14, 1.87it/s]inconsistent `tau`, recalculating...
Performing wavelet coherence on surrogate pairs: 50%|█████ | 25/50 [00:37<00:34, 1.40s/it]inconsistent `tau`, recalculating...
Performing wavelet coherence on surrogate pairs: 52%|█████▏ | 26/50 [00:37<00:29, 1.25s/it]inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
Performing wavelet coherence on surrogate pairs: 56%|█████▌ | 28/50 [00:37<00:21, 1.05it/s]inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
Performing wavelet coherence on surrogate pairs: 66%|██████▌ | 33/50 [00:37<00:08, 1.90it/s]inconsistent `tau`, recalculating...
inconsistent `tau`, recalculating...
Performing wavelet coherence on surrogate pairs: 72%|███████▏ | 36/50 [00:38<00:05, 2.52it/s]inconsistent `tau`, recalculating...
Performing wavelet coherence on surrogate pairs: 76%|███████▌ | 38/50 [00:47<00:16, 1.36s/it]inconsistent `tau`, recalculating...
Performing wavelet coherence on surrogate pairs: 78%|███████▊ | 39/50 [00:47<00:13, 1.20s/it]inconsistent `tau`, recalculating...
Performing wavelet coherence on surrogate pairs: 100%|██████████| 50/50 [00:53<00:00, 1.07s/it]
{'coherence': <pyleoclim.core.coherences.Coherence at 0x33e76aa80>,
'coherence_signif': <pyleoclim.core.coherences.Coherence at 0x33e76ad50>,
'phase_precession': Results(mean_angle=np.float64(-2.261491095182794), kappa=np.float64(1.9555923512328888), sigma=np.float64(0.8587823769100564), kappa_hi=np.float64(1.0160447206388548), sigma_lo=np.float64(1.2577156087852164)),
'phase_obliquity': Results(mean_angle=np.float64(-2.4433980765022905), kappa=np.float64(3.4745152385146185), sigma=np.float64(0.5883724826422292), kappa_hi=np.float64(1.1703081848485475), sigma_lo=np.float64(1.1666165093211687)),
'phase_eccentricity': Results(mean_angle=np.float64(0.08117933783286828), kappa=np.float64(3.7666732606233713), sigma=np.float64(0.5591804313165193), kappa_hi=np.float64(15.791020796914115), sigma_lo=np.float64(0.25584532899281465)),
'overlap_timespan': (0.07, 1430.9)}
The XWT analysis shows strong coherence between the two timeseries in the precession band while the WTC shows coherence, albeit weaker, in the obliquity and eccentricity bands as well. The arrows, indicating the phase relationship, are not consistent throughout, most likely due to large age model uncertainty in the marine sediment record.
- Hodell, D. A., Crowhurst, S. J., Lourens, L., Margari, V., Nicolson, J., Rolfe, J. E., Skinner, L. C., Thomas, N. C., Tzedakis, P. C., Mleneck-Vautravers, M. J., & Wolff, E. W. (2023). A 1.5-million-year record of orbital and millennial climate variability in the North Atlantic. Climate of the Past, 19(3), 607–636. 10.5194/cp-19-607-2023
- Hodell, D. A., Crowhurst, S. J., Lourens, L. J., Margari, V., Nicolson, J., Rolfe, J. E., Skinner, L. C., Thomas, N. C., Tzedakis, P. C., Mleneck-Vautravers, M. J., & Wolff, E. W. (2023). Benthic and planktonic oxygen and carbon isotopes and XRF data at IODP Site U1385 and core MD01-2444 from 0 to 1.5 Ma. PANGAEA. 10.1594/PANGAEA.951401
- Hodell, D. A., Crowhurst, S. J., Lourens, L. J., Margari, V., Nicolson, J., Rolfe, J. E., Skinner, L. C., Thomas, N. C., Tzedakis, P. C., Mleneck-Vautravers, M. J., & Wolff, E. W. (2023). Oxgyen and carbon isotope data for the planktonic foraminifera Globigerina bulloides at IODP Site 339-U1385. PANGAEA. 10.1594/PANGAEA.951386