Module 3 — The Calibration Curve Explorer#
Goal: Build intuition for the four parameters that define the TEXAS calibration curve, without needing to read any equations.
The core idea#
TEX86 (and its close relative, the Ring Index) increases with sea surface temperature. But it does not increase linearly — it follows an S-shaped curve. Warm tropical waters give high Ring Index values; cold polar waters give low values; and the curve bends most steeply somewhere in between.
TEXAS fits this S-curve to a global dataset of coretop and culture/mesocosm observations. The fitted curve then lets you go in the other direction: given a measured Ring Index from a sediment core, what temperature does it imply?
The shape of that S-curve is controlled by four parameters: t₀, k, b, and v. The interactive widget below lets you move each one and watch the curve respond in real time.
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from ipywidgets import interact, FloatSlider, Layout
# The very same function TEXAS uses internally for the calibration curve.
# Importing it here means this explorer can never drift from the package.
from TEXAS.models.logistics import generalized_logistic_fixed_upper
The generalized logistic curve#
The curve TEXAS uses is called a generalized logistic function:
If that looks intimidating, don’t worry — the widget below lets you develop a feel for each parameter without needing to parse the equation. The short version:
Parameter |
Plain English |
|---|---|
t₀ |
Where on the temperature axis the curve bends fastest |
k |
How steeply the curve rises |
b |
How low the curve bottoms out in cold water |
v |
Whether the rise is symmetric or lopsided |
T_RANGE = np.linspace(-5, 100, 600)
# Approximate coretop scatter to give visual context (same curve + noise)
rng = np.random.default_rng(42)
_t_ref = rng.uniform(0, 30, 120)
_ri_ref = generalized_logistic_fixed_upper(_t_ref, t0=48, b=0.15, k=0.12, v=1.2)
_ri_ref = _ri_ref + rng.normal(0, 0.04, len(_t_ref))
_ri_ref = np.clip(_ri_ref, 0.02, 0.98)
SLIDER_STYLE = {'description_width': '160px'}
SLIDER_LAYOUT = Layout(width='500px')
def plot_curve(t0=48.0, k=0.12, b=0.15, v=1.2):
ri = generalized_logistic_fixed_upper(T_RANGE, t0=t0, b=b, k=k, v=v)
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
# --- left panel: full curve with annotations ---
ax = axes[0]
ax.scatter(_t_ref, _ri_ref, s=18, alpha=0.35, color='gray', zorder=1, label='simulated coretop scatter')
ax.plot(T_RANGE, ri, color='steelblue', lw=2.5, zorder=3, label='calibration curve')
# inflection point annotation
ri_at_t0 = float(generalized_logistic_fixed_upper(t0, t0=t0, b=b, k=k, v=v))
ax.axvline(t0, color='darkorange', lw=1.5, ls='--', alpha=0.8)
ax.axhline(ri_at_t0, color='darkorange', lw=1.0, ls=':', alpha=0.6)
ax.plot(t0, ri_at_t0, 'o', color='darkorange', ms=9, zorder=5,
label=f't\u2080 = {t0:.0f} \u00b0C (inflection point)')
# lower asymptote annotation
ax.axhline(b, color='seagreen', lw=1.5, ls=':', alpha=0.8,
label=f'b = {b:.2f} (lower asymptote)')
ax.axhline(1.0, color='slategray', lw=1.0, ls=':', alpha=0.5,
label='upper asymptote = 1 (fixed)')
ax.set_xlabel('Sea Surface Temperature (\u00b0C)')
ax.set_ylabel('Ring Index')
ax.set_xlim(-5, 95)
ax.set_ylim(-0.03, 1.08)
ax.set_title('Calibration curve', fontweight='bold')
ax.legend(fontsize=9, loc='upper left')
ax.grid(True, alpha=0.2)
# --- right panel: sensitivity (dRI/dT) ---
ax2 = axes[1]
dri_dt = np.gradient(ri, T_RANGE)
ax2.plot(T_RANGE, dri_dt, color='tomato', lw=2.5)
ax2.axvline(t0, color='darkorange', lw=1.5, ls='--', alpha=0.8,
label=f't\u2080 = {t0:.0f} \u00b0C')
peak_sens = dri_dt.max()
ax2.annotate(f'peak sensitivity\n{peak_sens:.4f} RI / \u00b0C',
xy=(T_RANGE[np.argmax(dri_dt)], peak_sens),
xytext=(T_RANGE[np.argmax(dri_dt)] + 8, peak_sens * 0.85),
arrowprops=dict(arrowstyle='->', color='tomato'),
fontsize=9, color='tomato')
ax2.set_xlabel('Sea Surface Temperature (\u00b0C)')
ax2.set_ylabel('dRI / dT (sensitivity)')
ax2.set_xlim(-5, 95)
ax2.set_ylim(bottom=0)
ax2.set_title('Proxy sensitivity \u2014 where does 1 \u00b0C matter most?', fontweight='bold')
ax2.legend(fontsize=9)
ax2.grid(True, alpha=0.2)
plt.suptitle(
f't\u2080={t0:.0f} \u00b0C k={k:.3f} b={b:.2f} v={v:.1f}',
fontsize=10, color='dimgray', y=1.01
)
plt.tight_layout()
plt.show()
interact(
plot_curve,
t0=FloatSlider(value=48, min=10, max=80, step=1,
description='t\u2080 \u2014 inflection temp (\u00b0C)',
style=SLIDER_STYLE, layout=SLIDER_LAYOUT),
k=FloatSlider(value=0.12, min=0.01, max=0.50, step=0.01,
description='k \u2014 slope / steepness',
style=SLIDER_STYLE, layout=SLIDER_LAYOUT),
b=FloatSlider(value=0.15, min=0.00, max=0.40, step=0.01,
description='b \u2014 lower asymptote',
style=SLIDER_STYLE, layout=SLIDER_LAYOUT),
v=FloatSlider(value=1.2, min=0.1, max=5.0, step=0.1,
description='v \u2014 shape (curve asymmetry)',
style=SLIDER_STYLE, layout=SLIDER_LAYOUT),
);
What each parameter controls#
t₀ — the inflection temperature#
Drag t₀ left and right. The whole curve slides along the temperature axis. The point where the curve bends fastest — the inflection point — moves with it.
What it means physically: t₀ is the temperature at which Ring Index is changing fastest per degree of warming. If t₀ is far outside the temperature range of your samples, the proxy will be nearly flat over your data — it won’t discriminate well between warm and cool sites.
Try it: Push t₀ to 80 °C and notice how the calibration curve becomes nearly a flat line across the 0–30 °C range most coretops live in. The right panel (sensitivity) tells the same story: the peak sensitivity moves out of the range of your data.
k — the slope#
Drag k from low to high. A high k makes the curve steep — Ring Index jumps rapidly over a narrow temperature window. A low k makes it gradual.
What it means physically: k controls how discriminating the proxy is. High k = a small temperature difference produces a large Ring Index difference, which sounds desirable — but it also means measurement noise on Ring Index translates into large temperature uncertainty in cold reconstructions.
Try it: Set k very low (≈ 0.02). The sensitivity panel is nearly flat — the proxy barely responds to temperature at all, so it would be a poor thermometer.
b — the lower asymptote#
Drag b. The bottom of the curve rises and falls. The upper asymptote is fixed at 1 in TEXAS (we assume Ring Index cannot exceed 1 by definition).
What it means physically: b sets the minimum Ring Index we expect even in the coldest water. Biologically, some GDGT ring production is always present regardless of temperature (basal membrane fluidity), so b > 0 is physically realistic.
Try it: Set b = 0 and b = 0.30 and compare. The curve with b = 0.30 has less range to work with (it can only span 0.30 to 1.0, not 0 to 1.0), compressing all temperature information into a narrower band of Ring Index values.
v — asymmetry#
Drag v. At v = 1 you get a classic symmetric S-curve. Increasing v skews the steepest part of the curve toward lower temperatures; decreasing it pushes the steep part toward higher temperatures.
What it means physically: Real GDGT–temperature relationships may not be perfectly symmetric. v lets the model fit that asymmetry. Look at the sensitivity panel: v determines whether the proxy is most sensitive in cold water or warm water.
Try it: Set v = 0.3 and then v = 4.0. Notice how the peak sensitivity shifts dramatically — a low v means the proxy discriminates best in warm tropical water; a high v means it discriminates best in cold polar water.
What TEXAS actually does#
Rather than choosing fixed values for t₀, k, b, and v, TEXAS estimates a probability distribution over each parameter — the posterior. After calibration, instead of “t₀ = 48 °C”, TEXAS knows “t₀ is probably between 42 and 55 °C, most likely around 48 °C”. That uncertainty flows through to every paleotemperature reconstruction you run.
Module 4 explains how TEXAS draws samples from those distributions. Module 5 shows how to interpret the resulting temperature credible intervals.