Skip to content

Commit e44aac6

Browse files
daniloeflDanilo Ferreira de Limatmichela
committed
Improve robustness of the grating calibration (#510)
* Apply grating mask * Removed background subtraction: done in the Gotthard calibration. * Updated tests. --------- Co-authored-by: Danilo Ferreira de Lima <danilo.enoque.ferreira.de.lima@xfel.de> Co-authored-by: Thomas Michelat <32831491+tmichela@users.noreply.github.com>
1 parent 18234ac commit e44aac6

5 files changed

Lines changed: 102 additions & 58 deletions

File tree

docs/changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ Added:
7373
and photon line if the flag `deconvolve` is set to True.
7474
summary about a pulse pattern (!435).
7575
- `StepTimer` class for measuring performance within functions.
76+
- [Grating1DCalibration][extra.applications.Grating1DCalibration] can use the calibration mask,
77+
removes the background subtraction (leaving it to the calibration DB),
78+
and use robust tools to improve the fit quality (!510).
7679

7780
Fixed:
7881

src/extra/applications/grating.py

Lines changed: 91 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@
88
import xarray as xr
99
import h5py
1010

11+
from sklearn.linear_model import RANSACRegressor, LinearRegression
1112
from extra_data import open_run, by_id, DataCollection, KeyData
1213
from extra.components import Scan, XrayPulses
14+
from scipy.ndimage import gaussian_filter
1315

1416
from .base import SerializableMixin
1517

1618
def calc_mean(energy_id: int, scan: Scan,
1719
grating: KeyData,
20+
mask: KeyData=None
1821
) -> np.ndarray:
1922
"""
2023
Calculate mean over train IDs with a given energy value.
@@ -23,41 +26,46 @@ def calc_mean(energy_id: int, scan: Scan,
2326
energy_id: The id of this energy bin in the `scan` object.
2427
scan: The `Scan` object.
2528
grating: The camera key object.
29+
mask: Calibration mask object.
2630
"""
2731
energy, train_ids = scan.steps[energy_id]
2832
logging.debug(f"Energy {energy}, energy id {energy_id}")
29-
data = grating.select_trains(by_id[list(train_ids)]).xarray()
30-
return data.mean('trainId').to_numpy()
33+
data = grating.select_trains(by_id[list(train_ids)]).ndarray()
34+
if mask is not None:
35+
mask = mask.select_trains(by_id[list(train_ids)]).ndarray()
36+
data[mask > 0] = np.nan
37+
return np.nanmean(data, 0)
3138

3239
class Grating1DCalibration(SerializableMixin):
3340
"""
3441
Calibrate a 1D grating spectrometer.
3542
3643
Args:
3744
offset: Offset of the first pulse.
45+
sigma: Smoothing factor to apply to data to reduce noise in pixels.
3846
3947
Example:
4048
```
4149
bkg_run = open_run(proposal=900485, run=590)
4250
calib_run = open_run(proposal=900485, run=611)
4351
scan = Scan(calib_run["SA3_XTD10_MONO/MDL/PHOTON_ENERGY", "actualEnergy"])
4452
grating_signal = calib_run["SQS_EXP_GH2-2/CORR/RECEIVER:daqOutput", "data.adc"]
45-
grating_bkg = bkg_run["SQS_EXP_GH2-2/CORR/RECEIVER:daqOutput", "data.adc"]
4653
pulses = XrayPulses(calib_run)
4754
grating_calib = Grating1DCalibration()
48-
grating_calib.setup(grating_signal, grating_bkg, scan, pulses)
55+
grating_calib.setup(grating_signal, scan, pulses)
4956
5057
# apply it in new data
5158
new_run = open_run(...)
5259
grating_calib.apply(new_run)
5360
```
5461
"""
55-
def __init__(self, offset: Optional[int]=None, min_pixel: int=500, max_pixel: int=1000):
56-
self._version = 1
62+
def __init__(self, offset: Optional[int]=None, min_pixel: int=0, max_pixel: int=1280, sigma: float=2.0):
63+
self._version = 2
5764
self.offset = offset
5865
self.min_pixel = min_pixel
5966
self.max_pixel = max_pixel
6067
self.calibration_mask = None
68+
self.sigma = sigma
6169
self._all_fields = [
6270
"pulse_period",
6371
"offset",
@@ -66,23 +74,22 @@ def __init__(self, offset: Optional[int]=None, min_pixel: int=500, max_pixel: in
6674
"max_pixel",
6775
"e0",
6876
"slope",
69-
"bkg",
70-
"bkg_unc",
7177
"energy_axis",
7278
"calibration_energies",
7379
"calibration_data",
74-
"calibration_unc",
7580
"grating_source",
7681
"grating_key",
7782
"sources",
83+
"grating_mask_key",
84+
"sigma",
7885
"_version",
7986
]
8087

8188
def setup(self,
8289
grating_signal: KeyData,
8390
scan: Scan,
8491
pulses: XrayPulses,
85-
grating_bkg: Optional[KeyData]=None,
92+
grating_mask: Optional[KeyData]=None,
8693
):
8794
"""
8895
Setup calibration.
@@ -94,13 +101,16 @@ def setup(self,
94101
Example: `Scan(run["SA3_XTD10_MONO/MDL/PHOTON_ENERGY", "actualEnergy"])`
95102
pulses: Object with bunch pattern table.
96103
Example: `XrayPulses(run)`
97-
grating_bkg: Where to read the grating background data from.
98-
Example: `bkg_run["SQS_EXP_GH2-2/CORR/RECEIVER:daqOutput", "data.adc"]`
104+
grating_mask: Grating mask from the calibration system.
105+
Example: `signal_run["SQS_EXP_GH2-2/CORR/RECEIVER:daqOutput", "data.mask"]`
99106
"""
100107
self.grating_source = grating_signal.source
101108
self.grating_key = grating_signal.key
109+
self.grating_mask_key = ""
110+
if grating_mask is not None:
111+
self.grating_mask_key = grating_mask.key
102112
self._grating_signal = grating_signal
103-
self._grating_bkg = grating_bkg
113+
self._grating_mask = grating_mask
104114

105115
self.sources = [
106116
self.grating_source,
@@ -123,10 +133,6 @@ def setup(self,
123133
logging.info("Extract bunch pattern table")
124134
self.pulse_period = self.get_pulse_period(pulses)
125135

126-
# background
127-
logging.info("Load background ...")
128-
self.get_background_template()
129-
130136
# load data
131137
logging.info("Load data ...")
132138
self.load_data()
@@ -150,6 +156,8 @@ def _fromdict(cls, all_data):
150156
Rebuild object from dict.
151157
"""
152158
self = cls()
159+
# backwards compatibility
160+
self.grating_mask_key = ""
153161
for k, v in all_data.items():
154162
setattr(self, k, v)
155163
return self
@@ -158,9 +166,24 @@ def guess_offset(self):
158166
"""
159167
Guess offset.
160168
"""
161-
I = self._grating_signal.xarray().sel(dim_1=np.s_[self.min_pixel:self.max_pixel]).mean('dim_1').mean('trainId')
162-
threshold = np.median(I)
163-
self.offset = np.where(I >= threshold)[0][0]
169+
I = self._grating_signal.xarray().sel(dim_1=np.s_[self.min_pixel:self.max_pixel]).to_numpy()
170+
if self._grating_mask is not None:
171+
Im = self._grating_mask.xarray().sel(dim_1=np.s_[self.min_pixel:self.max_pixel]).to_numpy()
172+
I[Im > 0] = 0
173+
# smooth over energy-pixels
174+
if self.sigma > 0:
175+
I = gaussian_filter(np.nan_to_num(I), axes=-1, sigma=self.sigma)
176+
# maximum over energy-pixels
177+
I = np.max(I, axis=-1)
178+
# mean over trains
179+
I = np.mean(I, 0)
180+
# at this point I contains only the time dimension
181+
# look for peaks over background
182+
offset = [None, None]
183+
for s in [0, 1]:
184+
threshold = np.median(I[s::2])
185+
offset[s] = np.where(I[s::2] >= threshold)[0][0]*2 + s
186+
self.offset = max(offset)
164187
logging.info(f"Offset estimated at {self.offset}")
165188

166189
def get_pulse_period(self, pulses: XrayPulses):
@@ -181,42 +204,46 @@ def get_pulse_period(self, pulses: XrayPulses):
181204
logging.info(f"Pulse period estimated at {pulse_period}")
182205
return pulse_period
183206

184-
def get_background_template(self):
185-
"""Get the background template.
186-
"""
187-
if self._grating_bkg is None:
188-
self.bkg = None
189-
self.bkg_unc = None
190-
else:
191-
self.bkg = self._grating_bkg.ndarray().mean(0)
192-
self.bkg_unc = self._grating_bkg.ndarray().std(0)
207+
def apply_mask(self, arr):
208+
"""
209+
Interpolate nans.
210+
"""
211+
axis_full = np.arange(arr.shape[-1])
212+
shape = arr.shape
213+
arr = np.reshape(arr, (-1, shape[-1]))
214+
# if all points are bad, set it to zero
215+
# nothing else can be done there ...
216+
all_bad = np.all(np.isnan(arr), axis=1)
217+
arr[all_bad, :] = 0
218+
# otherwise interpolate
219+
arr = np.apply_along_axis(lambda a: np.interp(axis_full,
220+
axis_full[~np.isnan(a)],
221+
a[~np.isnan(a)],
222+
left=0, right=0),
223+
arr=arr, axis=1)
224+
arr = np.reshape(arr, shape)
225+
return arr
193226

194227
def load_data(self):
195228
"""Load calibration data."""
196229
from scipy.ndimage import rotate
197230
fn = partial(calc_mean,
198231
scan=self._scan,
199232
grating=self._grating_signal,
233+
mask=self._grating_mask,
200234
)
201235
energy_ids = np.arange(len(self.calibration_energies))
202236
# average data in each mono scan bin
203237
with ProcessPoolExecutor() as p:
204238
data = np.stack(list(p.map(fn, energy_ids)), axis=0)
205-
# subtract the background
206-
bkg_unc = np.zeros_like(data).mean(0)
207-
if self.bkg is not None:
208-
data = data - self.bkg
209-
bkg_unc = self.bkg_unc
210-
else:
211-
self.bkg_unc = bkg_unc
212-
self.bkg = np.zeros_like(data).mean(0)
213-
self.calibration_unc = bkg_unc
214239
# skip offset and collect pulse data each pulse_period samples only
215240
self.calibration_data = data[:, self.offset::self.pulse_period, self.min_pixel:self.max_pixel]
216-
self.calibration_unc = self.calibration_unc[self.offset::self.pulse_period, self.min_pixel:self.max_pixel]
241+
# apply mask
242+
self.calibration_data = self.apply_mask(self.calibration_data)
217243
# average over pulses
218-
self.calibration_data = np.mean(self.calibration_data, axis=1)
219-
self.calibration_unc = np.mean(self.calibration_unc, axis=0)
244+
self.calibration_data = np.nanmean(self.calibration_data, axis=1)
245+
if self.sigma > 0:
246+
self.calibration_data = gaussian_filter(np.nan_to_num(self.calibration_data), axes=-1, sigma=self.sigma)
220247
self.calibration_mask = np.ones(self.calibration_data.shape[0], dtype=bool)
221248

222249
def mask_calibration_point(self, energy: float, mask: bool=False, tol: float=1.0):
@@ -234,14 +261,15 @@ def mask_calibration_point(self, energy: float, mask: bool=False, tol: float=1.0
234261

235262
def fit(self):
236263
"""Fit line."""
237-
from scipy.stats import linregress
238264
mask = self.calibration_mask
239265
sample = np.arange(self.calibration_data.shape[-1])
240-
sample_mode = np.argmax(self.calibration_data, axis=-1)
241-
#sample_mode = snp.sum(self.calibration_data*sample, axis=-1)/np.sum(self.calibration_data, axis=-1)
242-
res = linregress(sample_mode[mask], self.calibration_energies[mask])
243-
self.slope = res.slope
244-
self.e0 = res.intercept
266+
sample_mode = np.nanargmax(self.calibration_data, axis=-1)
267+
x = sample_mode[mask]
268+
y = self.calibration_energies[mask]
269+
model = RANSACRegressor(estimator=LinearRegression(), random_state=42)
270+
model.fit(x[:,np.newaxis], y[:, np.newaxis])
271+
self.slope = model.estimator_.coef_[0,0]
272+
self.e0 = model.estimator_.intercept_[0]
245273
self.energy_axis = self.e0 + self.slope*sample
246274

247275
def plot(self):
@@ -251,7 +279,7 @@ def plot(self):
251279
import matplotlib.pyplot as plt
252280
plt.figure(figsize=(10, 8))
253281
sample = np.arange(self.calibration_data.shape[-1])
254-
sample_mode = np.argmax(self.calibration_data, axis=-1)
282+
sample_mode = np.nanargmax(self.calibration_data, axis=-1)
255283
plt.plot(sample, self.energy_axis, lw=2, label="Fit")
256284
plt.xlabel("Pixel")
257285
plt.ylabel("Energy [eV]")
@@ -267,26 +295,35 @@ def apply(self, run: DataCollection, load_all: bool=True) -> xr.Dataset:
267295
268296
Args:
269297
run: Input run.
270-
load_all: If True, load all data in memory at once. This is faste, but uses more memory.
298+
load_all: If True, load all data in memory at once. This is faster, but uses more memory.
271299
Disable if not enough memory is available.
272300
"""
273301
# do it per train to avoid memory overflow
274302
pulse_period = self.get_pulse_period(XrayPulses(run))
275303
if load_all:
276304
out_data = run[self.grating_source, self.grating_key].xarray()
277305
trainId = out_data.trainId.to_numpy()
278-
out_data = out_data.to_numpy() - self.bkg
306+
out_data = out_data.to_numpy()
279307
out_data = out_data[:, self.offset::pulse_period, self.min_pixel:self.max_pixel]
308+
if self.grating_mask_key:
309+
out_mask = run[self.grating_source, self.grating_mask_key].ndarray() > 0
310+
out_mask = out_mask[:, self.offset::pulse_period, self.min_pixel:self.max_pixel]
311+
out_data[out_mask] = np.nan
312+
out_data = self.apply_mask(out_data)
280313
else:
281314
trainId = list()
282315
out_data = list()
283316
for i, (tid, data) in enumerate(run.trains()):
284-
#print(f"Train {tid}, idx {i}")
285317
d = data[self.grating_source][self.grating_key]
286-
if self.bkg is not None:
287-
d = d - self.bkg
288318
# skip offset and collect pulse data each pulse_period samples only
289319
d = d[self.offset::pulse_period, self.min_pixel:self.max_pixel]
320+
321+
if self.grating_mask_key:
322+
m = data[self.grating_source][self.grating_mask_key] > 0
323+
m = m[self.offset::pulse_period, self.min_pixel:self.max_pixel]
324+
d[m] = np.nan
325+
d = self.apply_mask(d)
326+
290327
trainId += [tid]
291328
out_data += [d]
292329
out_data = np.stack(out_data, axis=0)
@@ -298,9 +335,7 @@ def apply(self, run: DataCollection, load_all: bool=True) -> xr.Dataset:
298335
energy=energy
299336
)
300337
)
301-
out_unc = xr.DataArray(data=self.calibration_unc, dims=('energy'),
302-
coords=dict(energy=energy))
303-
return xr.Dataset(data_vars=dict(data=out_data, unc=out_unc))
338+
return xr.Dataset(data_vars=dict(data=out_data))
304339

305340
class Grating2DCalibration(SerializableMixin):
306341
"""

tests/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,8 @@ def mock_sqs_grating_calibration_directory():
319319
Timeserver('SQS_RR_UTC/TSYS/TIMESERVER'),
320320
MonoMdl('SA3_XTD10_MONO/MDL/PHOTON_ENERGY', energy_data=energy),
321321
CameraWithData("SQS_DIAG3_BIU/CAM/CAM_6", data=data2d),
322-
GotthardIIWithData("SQS_EXP_GH2-2/CORR/RECEIVER", data=data_gh),
322+
GotthardIIWithData("SQS_EXP_GH2-2/CORR/RECEIVER", data=data_gh,
323+
mask=np.zeros_like(data_gh)),
323324
]
324325

325326
# for tests

tests/mockdata/camera.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,21 @@ class GotthardIIWithData(DeviceBase):
77

88
instrument_keys = [
99
("adc", "f4", (12,1000)),
10+
("mask", "i4", (12,1000)),
1011
]
1112

12-
def __init__(self, *args, data, **kwargs):
13+
def __init__(self, *args, data, mask, **kwargs):
1314
self.data = data
15+
self.mask = mask
1416
super().__init__(*args, **kwargs)
1517

1618
def write_instrument(self, f):
1719
super().write_instrument(f)
1820

1921
ds = f[f'INSTRUMENT/{self.device_id}:daqOutput/data/adc']
2022
ds[:] = self.data
23+
ds = f[f'INSTRUMENT/{self.device_id}:daqOutput/data/mask']
24+
ds[:] = self.mask
2125

2226
class CameraWithData(DeviceBase):
2327
output_channels = ('daqOutput/data',)

tests/test_applications_grating.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ def test_reading_grating1d(mock_sqs_grating_calibration_run, tmp_path):
9595
grating_calibration.setup(mock_sqs_grating_calibration_run[final_photon_spectrometer, "data.adc"],
9696
monochromator_scan,
9797
pulses=XrayPulses(mock_sqs_grating_calibration_run),
98+
grating_mask=mock_sqs_grating_calibration_run[final_photon_spectrometer, "data.mask"]
9899
)
99100
d = tmp_path / "data"
100101
d.mkdir()

0 commit comments

Comments
 (0)