Pump-Probe Analysis Example¶
This example reproduces the key steps of a time-resolved X-ray scattering experiment: loading / generating data, normalising to an intensity reference, applying timetool correction, and computing the differential signal.
Setting Up¶
import numpy as np
import matplotlib.pyplot as plt
import escape
from escape.storage.example_data import make_pump_probe_scan
# Synthetic pump-probe dataset
# sig — detector signal (events from both laser-ON and laser-OFF shots)
# i0 — incoming X-ray intensity (correlated with sig)
# pump_on — boolean per event: True = laser was fired ("pumped")
# delay — scan delay value as an Array (event-aligned)
sig, i0, pump_on, delay = make_pump_probe_scan(
n_steps=20,
n_events_per_step=600,
noise=0.06,
i0_noise=0.04,
seed=42,
)
print(sig.scan)
# Scan over 20 steps
# Parameters delay_s
Step 1 — Separate Pump-On and Pump-Off Shots¶
# ~50 % of events per step are pumped; the rest serve as reference (off)
sig_on = sig[~pump_on] # laser-pumped signal
sig_off = sig[pump_on] # unpumped reference
i0_on = i0[~pump_on]
i0_off = i0[pump_on]
print(f"ON events per step: {sig_on.scan.count()[:3]} …")
print(f"OFF events per step: {sig_off.scan.count()[:3]} …")
Step 2 — Normalise to I0¶
Index alignment makes normalisation trivial — the division operator finds common pulse IDs automatically:
sig_on_norm = sig_on / i0_on
sig_off_norm = sig_off / i0_off
Step 3 — Drift Correction¶
A slow drift in the source or sample can be removed by dividing each ON shot
by the average of its neighbouring OFF shots. We use
get_index_array() to group shots into time bins of
1000 pulse IDs, then categorize() to apply that grouping:
# Group pulse IDs into bins of ~1000
time_bins = sig_on_norm.get_index_array(N_index_aggregation=1000)
# Apply the same grouping to both ON and OFF
on_rebinned = time_bins.categorize(sig_on_norm)
off_rebinned = time_bins.categorize(sig_off_norm)
# Per time-bin reference average
ref_per_bin = off_rebinned.scan.nanmean(axis=0)
# Divide ON by OFF average within each bin → drift-corrected ratio
ratio_drift = on_rebinned.scan / ref_per_bin
Step 4 — Per-Step Pump/Probe Ratio¶
# Simple version without drift correction — divide per-step ON mean by OFF mean
step_means_on = np.asarray(sig_on_norm.scan.nanmean())
step_means_off = np.asarray(sig_off_norm.scan.nanmean())
ratio = step_means_on / step_means_off - 1.0 # relative change ΔI/I
par_vals = np.asarray(sig.scan.par_steps["delay_s"])
fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(par_vals * 1e12, ratio, "o-", lw=1.5)
ax.axhline(0, ls="--", color="k", alpha=0.4)
ax.set_xlabel("delay / ps")
ax.set_ylabel("ΔI/I")
ax.set_title("Pump-probe signal (no jitter correction)")
plt.tight_layout()
plt.show()
Step 5 — Timetool Jitter Correction with digitize¶
Timing jitter between the pump and probe pulses broadens the apparent response. Correcting it requires re-binning events by the timetool-measured actual delay rather than the nominal scan parameter.
Here we simulate a timetool measurement:
rng = np.random.default_rng(0)
# True time = nominal delay + random jitter (~100 fs rms)
jitter = rng.normal(0, 100e-15, len(sig))
t_jitter = escape.Array(
data=(np.repeat(par_vals, 600) + jitter).astype(np.float64),
index=sig.index,
step_lengths=sig.scan.step_lengths,
parameter=sig.scan.parameter,
name="t_actual_s",
)
# Compute to numpy before digitizing (required by digitize)
t_jitter_np = t_jitter.compute()
# Re-bin onto 50 fs bins covering the delay range
t_bins = np.arange(par_vals.min(), par_vals.max(), 50e-15)
t_sorted = t_jitter_np.digitize(t_bins)
# Apply the same bin ordering to the normalised signal
ratio_tt = t_sorted.categorize(sig_on_norm)
# Per-bin ratio
ratio_tt_mean = np.asarray(ratio_tt.scan.nanmean())
ratio_tt_sem = np.asarray(ratio_tt.scan.nanstd()) / np.sqrt(ratio_tt.scan.count())
t_centers = np.asarray(ratio_tt.scan.par_steps.iloc[:, 0]) * 1e12 # → ps
fig, ax = plt.subplots(figsize=(7, 4))
ax.errorbar(t_centers, ratio_tt_mean, yerr=ratio_tt_sem, fmt="o-", lw=1.5,
label="timetool corrected")
ax.plot(par_vals * 1e12, ratio, "s--", alpha=0.5, label="nominal delay")
ax.axhline(1.0, ls="--", color="k", alpha=0.3)
ax.set_xlabel("delay / ps")
ax.set_ylabel("signal (normalised)")
ax.legend()
ax.set_title("Pump-probe signal with timing jitter correction")
plt.tight_layout()
plt.show()
Step 6 — Storing Results¶
import h5py
with h5py.File("pump_probe_results.h5", "w") as fh:
sig.store(fh, "signal")
i0.store(fh, "i0")
pump_on.store(fh, "pump_on")
sig_on_norm.store(fh, "signal_norm_on")
ratio_tt.store(fh, "signal_tt_corrected")