Open In Colab   Open in Kaggle

Bonus Tutorial 7: Assessing Climate Forcings#

Week 1, Day 4, Paleoclimate

Content creators: Sloane Garelick

Content reviewers: Yosmely Bermúdez, Dionessa Biton, Katrina Dobson, Maria Gonzalez, Will Gregory, Nahid Hasan, Paul Heubel, Sherry Mi, Beatriz Cosenza Muralles, Brodie Pearson, Jenna Pearson, Mauro Tripaldi, Chi Zhang, Ohad Zivan

Content editors: Yosmely Bermúdez, Paul Heubel, Zahra Khodakaramimaghsoud, Jenna Pearson, Agustina Pesce, Chi Zhang, Ohad Zivan

Production editors: Wesley Banfield, Paul Heubel, Jenna Pearson, Konstantine Tsafatinos, Chi Zhang, Ohad Zivan

Our 2024 Sponsors: CMIP, NFDI4Earth

Tutorial Objectives#

Estimated timing of tutorial: 25 minutes

In this tutorial, you will use data analysis tools and climate concepts you’ve learned today, and on previous days, to assess the forcings of climate variations observed in paleoclimate records.

By the end of this tutorial you will be able to:

  • Plot and interpret temperature reconstructions from speleothem oxygen isotopes

  • Generate and plot time series of solar insolation

  • Assess the orbital forcings on monsoon intensity over the past 400,000 years using spectral analysis

Setup#

# installations ( uncomment and run this cell ONLY when using google colab or kaggle )

# !pip install pyleoclim
# !pip install climlab

# you may get errors about fortran extensions not working, and that is ok we are not using those here
# in W1D5 you will learn how to install climlab in colab using conda which will fix these errors and give you
# full functionality of the package
# imports

# for Google Colab users: you might get a numpy.dtype error here, restart your session and rerun the code and it should solve it.
import pooch
import os
import tempfile
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

import pyleoclim as pyleo

from climlab import constants as const
from climlab.solar.orbital import OrbitalTable
from climlab.solar.insolation import daily_insolation
Downloading data from 'http://www.atmos.albany.edu/facstaff/brose/resources/climlab_data/absorptivity/abs_ems_factors_fastvx.c030508.nc' to file '/home/runner/.cache/pooch/61707eb16bd77eb262c3c7db3706b35d-abs_ems_factors_fastvx.c030508.nc'.
Downloading data from 'http://www.atmos.albany.edu/facstaff/brose/resources/climlab_data/orbital/orbit91' to file '/home/runner/.cache/pooch/f5d05db954ff021372b5a39d7b9bb4c7-orbit91'.
---------------------------------------------------------------------------
TimeoutError                              Traceback (most recent call last)
File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/urllib3/connection.py:239, in HTTPConnection._new_conn(self)
    238 try:
--> 239     sock = connection.create_connection(
    240         (self._dns_host, self.port),
    241         self.timeout,
    242         source_address=self.source_address,
    243         socket_options=self.socket_options,
    244     )
    245 except socket.gaierror as e:

File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/urllib3/util/connection.py:85, in create_connection(address, timeout, source_address, socket_options)
     84 try:
---> 85     raise err
     86 finally:
     87     # Break explicitly a reference cycle

File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/urllib3/util/connection.py:73, in create_connection(address, timeout, source_address, socket_options)
     72     sock.bind(source_address)
---> 73 sock.connect(sa)
     74 # Break explicitly a reference cycle

TimeoutError: timed out

The above exception was the direct cause of the following exception:

ConnectTimeoutError                       Traceback (most recent call last)
File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/urllib3/connectionpool.py:793, in HTTPConnectionPool.urlopen(self, method, url, body, headers, retries, redirect, assert_same_host, timeout, pool_timeout, release_conn, chunked, body_pos, preload_content, decode_content, **response_kw)
    792 # Make the request on the HTTPConnection object
--> 793 response = self._make_request(
    794     conn,
    795     method,
    796     url,
    797     timeout=timeout_obj,
    798     body=body,
    799     headers=headers,
    800     chunked=chunked,
    801     retries=retries,
    802     response_conn=response_conn,
    803     preload_content=preload_content,
    804     decode_content=decode_content,
    805     **response_kw,
    806 )
    808 # Everything went great!

File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/urllib3/connectionpool.py:499, in HTTPConnectionPool._make_request(self, conn, method, url, body, headers, retries, timeout, chunked, response_conn, preload_content, decode_content, enforce_content_length)
    498 try:
--> 499     conn.request(
    500         method,
    501         url,
    502         body=body,
    503         headers=headers,
    504         chunked=chunked,
    505         preload_content=preload_content,
    506         decode_content=decode_content,
    507         enforce_content_length=enforce_content_length,
    508     )
    510 # We are swallowing BrokenPipeError (errno.EPIPE) since the server is
    511 # legitimately able to close the connection after sending a valid response.
    512 # With this behaviour, the received response is still readable.

File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/urllib3/connection.py:567, in HTTPConnection.request(self, method, url, body, headers, chunked, preload_content, decode_content, enforce_content_length)
    566     self.putheader(header, value)
--> 567 self.endheaders()
    569 # If we're given a body we start sending that in chunks.

File ~/micromamba/envs/climatematch/lib/python3.11/http/client.py:1346, in HTTPConnection.endheaders(self, message_body, encode_chunked)
   1345     raise CannotSendHeader()
-> 1346 self._send_output(message_body, encode_chunked=encode_chunked)

File ~/micromamba/envs/climatematch/lib/python3.11/http/client.py:1106, in HTTPConnection._send_output(self, message_body, encode_chunked)
   1105 del self._buffer[:]
-> 1106 self.send(msg)
   1108 if message_body is not None:
   1109 
   1110     # create a consistent interface to message_body

File ~/micromamba/envs/climatematch/lib/python3.11/http/client.py:1044, in HTTPConnection.send(self, data)
   1043 if self.auto_open:
-> 1044     self.connect()
   1045 else:

File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/urllib3/connection.py:398, in HTTPConnection.connect(self)
    397 def connect(self) -> None:
--> 398     self.sock = self._new_conn()
    399     if self._tunnel_host:
    400         # If we're tunneling it means we're connected to our proxy.

File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/urllib3/connection.py:248, in HTTPConnection._new_conn(self)
    247 except SocketTimeout as e:
--> 248     raise ConnectTimeoutError(
    249         self,
    250         f"Connection to {self.host} timed out. (connect timeout={self.timeout})",
    251     ) from e
    253 except OSError as e:

ConnectTimeoutError: (<HTTPConnection(host='www.atmos.albany.edu', port=80) at 0x796e9c788d10>, 'Connection to www.atmos.albany.edu timed out. (connect timeout=30)')

The above exception was the direct cause of the following exception:

MaxRetryError                             Traceback (most recent call last)
File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/requests/adapters.py:696, in HTTPAdapter.send(self, request, stream, timeout, verify, cert, proxies)
    695 try:
--> 696     resp = conn.urlopen(
    697         method=request.method,
    698         url=url,
    699         body=request.body,  # type: ignore[arg-type]  # urllib3 stubs don't accept Iterable[bytes | str]
    700         headers=request.headers,  # type: ignore[arg-type]  # urllib3#3072
    701         redirect=False,
    702         assert_same_host=False,
    703         preload_content=False,
    704         decode_content=False,
    705         retries=self.max_retries,
    706         timeout=resolved_timeout,
    707         chunked=chunked,
    708     )
    710 except (ProtocolError, OSError) as err:

File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/urllib3/connectionpool.py:847, in HTTPConnectionPool.urlopen(self, method, url, body, headers, retries, redirect, assert_same_host, timeout, pool_timeout, release_conn, chunked, body_pos, preload_content, decode_content, **response_kw)
    845     new_e = ProtocolError("Connection aborted.", new_e)
--> 847 retries = retries.increment(
    848     method, url, error=new_e, _pool=self, _stacktrace=sys.exc_info()[2]
    849 )
    850 retries.sleep()

File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/urllib3/util/retry.py:555, in Retry.increment(self, method, url, response, error, _pool, _stacktrace)
    554     reason = error or ResponseError(cause)
--> 555     raise MaxRetryError(_pool, url, reason) from reason  # type: ignore[arg-type]
    557 log.debug("Incremented Retry for (url='%s'): %r", url, new_retry)

MaxRetryError: HTTPConnectionPool(host='www.atmos.albany.edu', port=80): Max retries exceeded with url: /facstaff/brose/resources/climlab_data/orbital/orbit91 (Caused by ConnectTimeoutError(<HTTPConnection(host='www.atmos.albany.edu', port=80) at 0x796e9c788d10>, 'Connection to www.atmos.albany.edu timed out. (connect timeout=30)'))

During handling of the above exception, another exception occurred:

ConnectTimeout                            Traceback (most recent call last)
Cell In[2], line 14
     10 
     11 import pyleoclim as pyleo
     12 
     13 from climlab import constants as const
---> 14 from climlab.solar.orbital import OrbitalTable
     15 from climlab.solar.insolation import daily_insolation

File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/climlab/solar/orbital/__init__.py:42
     39 import xarray as xr
     40 from .table import _get_Berger_data
---> 42 OrbitalTable = _get_Berger_data()

File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/climlab/solar/orbital/table.py:15, in _get_Berger_data(verbose)
     12 def _get_Berger_data(verbose=True):
     13     '''Read in the Berger and Loutre orbital table as a pandas dataframe, convert to xarray
     14     '''
---> 15     orbit91handle = pooch.retrieve(path,
     16         known_hash="3afc20dda7b385bdd366bc4c9cf60be02d8defdb4c0f317430ca8386d62f81a3")
     17     orbit91_pd = pd.read_csv(orbit91handle, sep='\s+', skiprows=1,)
     18     #  As xarray structure with the dimension named 'kyear'

File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/pooch/core.py:242, in retrieve(url, known_hash, fname, path, processor, downloader, progressbar)
    239 if downloader is None:
    240     downloader = choose_downloader(url, progressbar=progressbar)
--> 242 stream_download(url, full_path, known_hash, downloader, pooch=None)
    244 if known_hash is None:
    245     get_logger().info(
    246         "SHA256 hash of downloaded file: %s\n"
    247         "Use this value as the 'known_hash' argument of 'pooch.retrieve'"
   (...)    250         file_hash(str(full_path)),
    251     )

File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/pooch/core.py:823, in stream_download(url, fname, known_hash, downloader, pooch, retry_if_failed)
    819 try:
    820     # Stream the file to a temporary so that we can safely check its
    821     # hash before overwriting the original.
    822     with temporary_file(path=str(fname.parent)) as tmp:
--> 823         downloader(url, tmp, pooch)
    824         hash_matches(tmp, known_hash, strict=True, source=str(fname.name))
    825         shutil.move(tmp, str(fname))

File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/pooch/downloaders.py:230, in HTTPDownloader.__call__(self, url, output_file, pooch, check_only)
    228     # pylint: enable=consider-using-with
    229 try:
--> 230     response = requests.get(url, timeout=timeout, **kwargs)
    231     response.raise_for_status()
    232     content = response.iter_content(chunk_size=self.chunk_size)

File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/requests/api.py:87, in get(url, params, **kwargs)
     74 def get(
     75     url: _t.UriType, params: _t.ParamsType = None, **kwargs: Unpack[_t.GetKwargs]
     76 ) -> Response:
     77     r"""Sends a GET request.
     78 
     79     :param url: URL for the new :class:`Request` object.
   (...)     84     :rtype: requests.Response
     85     """
---> 87     return request("get", url, params=params, **kwargs)

File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/requests/api.py:71, in request(method, url, **kwargs)
     67 # By using the 'with' statement we are sure the session is closed, thus we
     68 # avoid leaving sockets open which can trigger a ResourceWarning in some
     69 # cases, and look like a memory leak in others.
     70 with sessions.Session() as session:
---> 71     return session.request(method=method, url=url, **kwargs)

File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/requests/sessions.py:651, in Session.request(self, method, url, params, data, headers, cookies, files, auth, timeout, allow_redirects, proxies, hooks, stream, verify, cert, json)
    646 send_kwargs = {
    647     "timeout": timeout,
    648     "allow_redirects": allow_redirects,
    649 }
    650 send_kwargs.update(settings)
--> 651 resp = self.send(prep, **send_kwargs)
    653 return resp

File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/requests/sessions.py:784, in Session.send(self, request, **kwargs)
    781 start = preferred_clock()
    783 # Send the request
--> 784 r = adapter.send(request, **kwargs)
    786 # Total elapsed time of the request (approximately)
    787 elapsed = preferred_clock() - start

File ~/micromamba/envs/climatematch/lib/python3.11/site-packages/requests/adapters.py:717, in HTTPAdapter.send(self, request, stream, timeout, verify, cert, proxies)
    714 if isinstance(e.reason, ConnectTimeoutError):
    715     # TODO: Remove this in 3.0.0: see #2811
    716     if not isinstance(e.reason, NewConnectionError):
--> 717         raise ConnectTimeout(e, request=request)
    719 if isinstance(e.reason, ResponseError):
    720     raise RetryError(e, request=request)

ConnectTimeout: HTTPConnectionPool(host='www.atmos.albany.edu', port=80): Max retries exceeded with url: /facstaff/brose/resources/climlab_data/orbital/orbit91 (Caused by ConnectTimeoutError(<HTTPConnection(host='www.atmos.albany.edu', port=80) at 0x796e9c788d10>, 'Connection to www.atmos.albany.edu timed out. (connect timeout=30)'))

Install and import feedback gadget#

Hide code cell source
# @title Install and import feedback gadget

!pip3 install vibecheck datatops --quiet

from vibecheck import DatatopsContentReviewContainer
def content_review(notebook_section: str):
    return DatatopsContentReviewContainer(
        "",  # No text prompt
        notebook_section,
        {
            "url": "https://pmyvdlilci.execute-api.us-east-1.amazonaws.com/klab",
            "name": "comptools_4clim",
            "user_key": "l5jpxuee",
        },
    ).render()


feedback_prefix = "W1D4_T7"

Figure Settings#

Hide code cell source
# @title Figure Settings
import ipywidgets as widgets  # interactive display

%config InlineBackend.figure_format = 'retina'
plt.style.use(
    "https://raw.githubusercontent.com/abodner/climate-course-content/main/cma.mplstyle"
)

Helper functions#

Hide code cell source
# @title Helper functions

def pooch_load(filelocation=None, filename=None, processor=None):
    shared_location = "/home/jovyan/shared/Data/tutorials/W1D4_Paleoclimate"  # this is different for each day
    user_temp_cache = tempfile.gettempdir()

    if os.path.exists(os.path.join(shared_location, filename)):
        file = os.path.join(shared_location, filename)
    else:
        file = pooch.retrieve(
            filelocation,
            known_hash=None,
            fname=os.path.join(user_temp_cache, filename),
            processor=processor,
        )

    return file

Video 1: Interpreting Climate Forcings#

Submit your feedback#

Hide code cell source
# @title Submit your feedback
content_review(f"{feedback_prefix}_Interpreting_Climate_Forcings_Video")
If you want to download the slides: https://osf.io/download/ztfwk/

Submit your feedback#

Hide code cell source
# @title Submit your feedback
content_review(f"{feedback_prefix}_Interpreting_Climate_Forcings_Slides")

Section 1: Understanding Climate Forcings#

A common task in paleoclimatology is to relate a proxy record (or several of them) to the particular forcing(s) that is thought to dominate that particular record (e.g., based on the proxy, location, etc.). We’ve already spent some time in earlier tutorials learning about the influence of Earth’s orbital configuration on glacial-interglacial cycles. In this tutorial, we’ll assess the climate forcings of monsoon intensity over the past 400,000 years.

Recall from the video that monsoons are seasonal changes in the direction of the strongest wind and precipitation that are primarily driven by variations in seasonal insolation. Land and the ocean have different abilities to hold onto heat. Land cools and warms much faster than the ocean does due to high heat capacity. This temperature difference leads to a pressure difference that drives atmospheric circulations called monsoons.

  • Summer (Northern Hemisphere): the land is warmer than the ocean, so the winds blow towards the land, resulting in heavy rainfall over land.

  • Winter (Northern Hemisphere): the land is cooler than the ocean, so the winds blow away from the land, resulting in heavy rainfall over the ocean and decreased rainfall over land.

On longer timescales, changes in insolation and the mean climate state can drive changes in monsoon intensity. To assess these long-term changes, we can analyze paleoclimate reconstructions from monsoon regions such as India, Southeast Asia or Africa. δ18O records from speleothems in Chinese caves are broadly interpreted to reflect continental-scale monsoon circulations.

In this tutorial, we’ll plot and analyze a composite of three δ18O speleothem records from multiple caves in China (Sanbao, Hulu, and Dongge caves) from Cheng et al. (2016). We will then assess the relationship between the climate signals recorded by the speleothem δ18O and solar insolation.

First, we can download and plot the speleothem oxygen isotope data:

# download the data from the url
filename_Sanbao_composite = "Sanbao_composite.csv"
url_Sanbao_composite = "https://raw.githubusercontent.com/LinkedEarth/paleoHackathon/main/data/Orbital_records/Sanbao_composite.csv"
data = pd.read_csv(
    pooch_load(filelocation=url_Sanbao_composite, filename=filename_Sanbao_composite)
)

# create a pyleo.Series
d18O_data = pyleo.Series(
    time=data["age"] / 1000,
    time_name="Age",
    time_unit="kyr BP",
    value=-data["d18O"],
    value_name=r"$\delta^{18}$O",
    value_unit="\u2030",
)
d18O_data.plot()
Downloading data from 'https://raw.githubusercontent.com/LinkedEarth/paleoHackathon/main/data/Orbital_records/Sanbao_composite.csv' to file '/tmp/Sanbao_composite.csv'.
SHA256 hash of downloaded file: 79755093d2b1fcfe888f141cbd36d112bbe38ab002c722ad2c391b387b0d0743
Use this value as the 'known_hash' argument of 'pooch.retrieve' to ensure that the file hasn't changed if it is downloaded again in the future.
Time axis values sorted in ascending order
(<Figure size 1000x400 with 1 Axes>,
 <Axes: xlabel='Age [kyr BP]', ylabel='$\\delta^{18}$O [‰]'>)
../../../_images/65401f169d84e3834e7e501e7e2d79b1fb21148a0f3cdb4a990b6df91a9f8540.png

You may notice that in the figure we just made, the δ18O values on the y-axis is plotted with more positive values up, whereas in previous tutorials, we’ve plotted isotopic data with more negative values up (since more negative/“depleted” suggests warmer temperatures or increased rainfall). However, the pre-processed δ18O data that we’re using in this tutorial was multipled by -1, so now a more positive/“enriched” value suggests warmer temperatures or increased rainfall. In other words, in this figure, upward on the y-axis is increased monsoon intensity and downward on the y-axis is decreased monsoon intensity.

Let’s apply what we learned in the previous tutorial to perform spectral analysis on the speleothem oxygen isotope data. Recall from the previous tutorial that spectral analysis can help us identify dominant cyclicities in the data, which can be useful for assessing potential climate forcings.

Here we’ll use the Weighted Wavelet Z-Transform (WWZ) method you learned about in the previous tutorial:

# standardize the data
d18O_stnd = d18O_data.interp(step=0.5).standardize()  # save it for future use
# calculate the WWZ spectral analysis
d18O_wwz = d18O_stnd.spectral(method="wwz")
# plot WWZ results
d18O_wwz.plot(xlim=[100, 5], ylim=[0.001, 1000])
(<Figure size 1000x400 with 1 Axes>,
 <Axes: xlabel='Period [kyr]', ylabel='PSD'>)
../../../_images/c0428bf9e3656eaa986af9fe6d47a583a6d9389166fb8d102e90a2fcbcfdaaa6.png

Coding Exercises 1#

  1. The dominant spectral power is at ~23,000 years. This suggests a link between monsoon intensity and orbital precession! Is this peak significant? Use the skills you learned in the last tutorial to test the significance of this peak at the 95% confidence level. For this exercise, input number = 30 as the default value which will take a long time to run.

# perform significance test with 5 surrogates
d18O_wwz_sig = ...

# plot the results
_ = ...

Click for solution

Example output:

Solution hint

Submit your feedback#

Hide code cell source
# @title Submit your feedback
content_review(f"{feedback_prefix}_Coding_Exercises_1")

Section 2: Constructing Insolation Curves#

To further explore and confirm the relationship between monsoon intensity and orbital precession, let’s take a look at insolation data and compare this to the speleothem δ18O records from Asia. Recall that insolation is controlled by variations in Earth’s orbital cycles (eccentricity, obliquity, precession), so by comparing the δ18O record to insolation, we can assess the influence of orbital variations on δ18O and monsoon intensity.

To compute solar insolation, we can use the package climlab by Brian Rose. Let’s create a time series over the past 400,000 years of changes in summer insolation at 31.67ºN, which is the latitude of Sanbao, one of the caves from which the speleothem records were produced.

# specify time interval and units
kyears = np.linspace(-400, 0, 1001)

# subset of orbital parameters for specified time
orb = OrbitalTable.interp(kyear=kyears)
days = np.linspace(0, const.days_per_year, 365)

# generate insolation at Sanbao latitude (31.67)
Qsb = daily_insolation(31.67, days, orb)

# julian days 152-243 are JJA
Qsb_jja = np.mean(Qsb[:, 151:243], axis=1)

Now we can store this data as a Series in Pyleoclim and plot the data versus time:

ts_qsb = pyleo.Series(
    time=-kyears,
    time_name="Age",
    time_unit="ky BP",
    value=Qsb_jja,
    value_name="JJA Insolation",
    value_unit=r"$W.m^{-2}$",
)

ts_qsb.plot()

Next, let’s plot and compare the speleothem δ18O data and the solar insolation data:

# standardize the insolation data
ts_qsb_stnd = ts_qsb.standardize()

# create a MultipleSeries of the speleothem d18O record and insolation data
compare = [d18O_stnd, ts_qsb_stnd]
ms_compare = pyleo.MultipleSeries(compare, time_unit="kyr BP", name=None)

# create a stackplot to compare the data
ms_compare.stackplot()

By visually comparing the time series of the two records, we can see similarites at orbital scales. To confirm this, we can use spectral analysis to determine the dominant spectral power of the insolation data:

# calculate the WWZ spectral analysis
psd_wwz = ts_qsb_stnd.spectral(method="wwz")
psd_wwz.plot()

Questions 2: Climate Connection#

  1. What is the dominant spectral power in summer insolation at 31ºN latitude? How does this compare to the speleothem data?

  2. Why might there be a relationship between solar insolation and monsoon intensity? What does the common spectral power in both the insolation and δ18O records suggest about the climate forcings driving monsoon intensity in this region?

Click for solution

Submit your feedback#

Hide code cell source
# @title Submit your feedback
content_review(f"{feedback_prefix}_Questions_2")

Summary#

In this tutorial, you’ve gained valuable insights into the complex world of paleoclimatology and climate forcings. Here’s a recap of what you’ve learned:

  • You’ve discovered how to plot and interpret temperature reconstructions derived from speleothem oxygen isotopes.

  • You’ve assessed the likely impact of orbital forcings on monsoon intensity over the past 400,000 years using spectral analysis.

  • By comparing the δ18O record to insolation, you’ve developed a deeper understanding of the relationship between solar insolation and monsoon intensity.

Resources#

Code for this tutorial is based on an existing notebook from LinkedEarth that explore forcing and responses in paleoclimate data.

Data from the following sources are used in this tutorial:

  • Cheng, H., Edwards, R., Sinha, A. et al. The Asian monsoon over the past 640,000 years and ice age terminations. Nature 534, 640–646 (2016). https://doi.org/10.1038/nature18591