Generate Synthetic Data for the Multi-Cycle Fitting Example#
Produces the data.csv / energy.csv / time.csv used by
../example.ipynb.
The data is a single 2D dataset: a GLP peak on a linear background (the same
system as 01_basic_fitting). After t = 0 the peak position alternates
between two repeating subcycles — a negative exponential shift and a mirrored
positive one with a slower relaxation — convolved with a Gaussian instrument
response function (IRF).
Ground truth model
LinBack(linear background) +GLPpeak — seemodels_energy_truth.yamlMulti-cycle time dependence on the peak position
GLP_01_x0:['IRF', 'MonoExpNeg', 'MonoExpPos']atfrequency=0.25— seemodels_time_truth.yaml
Re-run this notebook to regenerate the data in place.
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import trspecfit
1. Define the Ground Truth Model#
Build a 2D model on the same energy/time axes the example fits over. The
*_truth.yaml files hold the ground truth parameters. frequency=0.25 means
the dynamics repeat with period 1/0.25 = 4 time units; the two subcycle models
split each period equally, so each subcycle lasts 2 time units.
project = trspecfit.Project(path=Path.cwd(), config_file=None)
# Save the axes first and reload them, so the simulation runs on bit-identical
# axes to what ../example.ipynb later loads. Subcycle masks switch discretely
# at period boundaries, so even 1e-7 axis differences from CSV rounding would
# flip the subcycle assignment of samples that land exactly on a boundary.
data_fmt = '%.6e'
np.savetxt('energy.csv', np.arange(0, 20, 0.05), fmt=data_fmt) # 400 points
np.savetxt('time.csv', np.arange(-4, 16, 0.05), fmt=data_fmt) # 400 points
energy = np.loadtxt('energy.csv')
time = np.loadtxt('time.csv')
file = trspecfit.File(
parent_project=project,
energy=energy,
time=time,
)
# Load the ground truth energy model and add the multi-cycle time dependence.
file.load_model(model_yaml='models_energy_truth.yaml', model_info='base')
file.add_time_dependence(
target_model='base',
target_parameter='GLP_01_x0',
dynamics_yaml='models_time_truth.yaml',
dynamics_model=['IRF', 'MonoExpNeg', 'MonoExpPos'],
frequency=0.25,
)
file.describe_model()
Loading model to describe energy- (and time-)dependent data: base
Loading model to describe time-dependence of a model parameter: GLP_01_x0 of IRF_MonoExpNeg_MonoExpPos model
model name: base
function: LinBack from trspecfit.functions.energy
function: GLP from trspecfit.functions.energy
all lmfit.Parameters() [flattened and sorted alphabetically]:
Name Value Min Max Stderr Vary Expr Brute_Step
GLP_01_A 12 -inf inf None True None None
GLP_01_F 1 -inf inf None True None None
GLP_01_m 0.25 -inf inf None True None None
GLP_01_x0 9 -inf inf None True None None
GLP_01_x0_expFun_01_A -2 -inf inf None True None None
GLP_01_x0_expFun_01_t0 0 -inf inf None True None None
GLP_01_x0_expFun_01_tau 0.4 -inf inf None True None None
GLP_01_x0_expFun_02_A 2 -inf inf None False -GLP_01_x0_expFun_01_A None
GLP_01_x0_expFun_02_t0 0 -inf inf None True None None
GLP_01_x0_expFun_02_tau 0.8 -inf inf None True None None
GLP_01_x0_gaussCONV_SD 0.15 1e-06 1 None True None None
LinBack_b 2 -inf inf None True None None
LinBack_m 0.08 -inf inf None True None None
LinBack_xStart 0 -inf inf None True None None
LinBack_xStop 20 -inf inf None True None None
<site-packages>/trspecfit/mcp.py:2883: UserWarning: 7 time sample(s) lie within a relative tolerance of 1e-06 of a subcycle boundary (t = 2, 4, 6, 8, 10, ...). Subcycle assignment switches discretely there, so the model prediction for these samples can silently change with the floating-point representation of the time axis (e.g. after saving it to text and reloading). Shift these samples off the boundaries or sample strictly inside subcycles.
self.normalize_time() # update the normalization of the time axis
2. Simulate and Save#
Photon-counting detection draws each pixel from a Poisson distribution, so the
noise is signal-dependent (shot noise) — the realistic regime for counting
detectors. Lower counts_per_delay ⇒ noisier data.
sim = trspecfit.Simulator(
model=file.model_active,
detection='photon_counting',
counts_per_delay=5000,
seed=42,
)
clean, noisy, noise = sim.simulate_2d()
# Save noisy data (data is [time, energy], matching File's expectation).
# The axes were already saved (and reloaded) above.
np.savetxt('data.csv', noisy, delimiter=',', fmt=data_fmt)
emask = (energy >= 5) & (energy <= 18)
snr = clean[:, emask].max() / np.std(noise[:, emask])
print(f'data.csv | shape {noisy.shape} | peak SNR ~ {snr:.1f}')
data.csv | shape (400, 400) | peak SNR ~ 14.4
3. Quick Visual Check#
The 2D map shows the peak position alternating between the two subcycles after t = 0; the 1D slice makes the noise level visible. In the 2D map, look for the sawtooth: sharp (IRF-blurred) jumps at each subcycle boundary (t = 0, 2, 4, …) relaxing with two different time constants.
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
im = axes[0].pcolormesh(energy, time, noisy, shading='auto')
axes[0].set_xlabel('Energy')
axes[0].set_ylabel('Time')
axes[0].set_title('Simulated 2D data')
plt.colorbar(im, ax=axes[0])
t_ind = np.abs(time - 0.2).argmin()
axes[1].plot(energy, noisy[t_ind], lw=0.8, label='noisy')
axes[1].plot(energy, clean[t_ind], lw=2.0, label='clean (truth)')
axes[1].set_xlabel('Energy')
axes[1].set_ylabel('Intensity')
axes[1].set_title(f'Slice at t = {time[t_ind]:.1f} (early subcycle 1)')
axes[1].legend()
plt.tight_layout()
plt.show()
Ground Truth Parameters#
Parameter |
Value |
Description |
|---|---|---|
|
0.08 |
Background slope |
|
2.0 |
Background intercept (at |
|
0 / 20 |
Background clamp range |
|
12 |
Peak amplitude |
|
9 |
Peak center (static part) |
|
1.0 |
Peak width |
|
0.25 |
Gaussian/Lorentzian mixing |
|
0.25 |
Repetition rate: period 4, two 2-unit subcycles |
|
0.15 |
IRF width (global element 0) |
|
-2 |
Subcycle-1 shift amplitude |
|
0.4 |
Subcycle-1 relaxation time |
|
|
Subcycle-2 amplitude (linked) |
|
0.8 |
Subcycle-2 relaxation time (independent) |
Model files#
The YAML files next to this notebook, as shipped with the example.
models_energy_truth.yaml#
# ground truth energy model for data generation
# (same structure as the fit model in ../models_energy.yaml)
base:
LinBack:
m: [8E-2, True]
b: [2.0, True]
xStart: [0, True]
xStop: [20, True]
GLP:
A: [12, True]
x0: [9, True]
F: [1.0, True]
m: [0.25, True]
models_time_truth.yaml#
# ground truth time models for data generation
# Multi-cycle dynamics: ['IRF', 'MonoExpNeg', 'MonoExpPos'] at frequency=0.25,
# i.e. a 4-unit period split into two 2-unit subcycles (after t = 0).
# global element 0: Gaussian instrument response, convolves all time traces
IRF:
gaussCONV:
SD: [0.15, True, 1.0E-6, 1]
# subcycle 1: negative shift, decays back to 0 on its local clock
MonoExpNeg:
expFun:
A: [-2, True]
tau: [0.4, True]
t0: [0, True]
# subcycle 2: amplitude mirrors subcycle 1 via expression, tau independent
MonoExpPos:
expFun:
A: ["-expFun_01_A"]
tau: [0.8, True]
t0: [0, True]