Coverage for /Users/Newville/Codes/xraylarch/larch/xrf/mca.py: 15%
169 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-11-09 10:08 -0600
« prev ^ index » next coverage.py v7.3.2, created at 2023-11-09 10:08 -0600
1"""
2This module defines a device-independent MultiChannel Analyzer (MCA) class.
4Authors/Modifications:
5----------------------
6* Mark Rivers, GSECARS
7* Modified for Tdl, tpt
8* modified and simplified for Larch, M Newville
9"""
10import numpy as np
11from larch import Group, isgroup
13from xraydb import xray_line, xray_edge, material_mu
14from ..math import interp
15from .deadtime import calc_icr, correction_factor
16from .roi import ROI
19def isLarchMCAGroup(grp):
20 """tests whether variable holds a valid Larch MCAGroup"""
21 return isgroup(grp, 'energy', 'counts', 'rois')
23class Environment:
24 """
25 The "environment" or related parameters for a detector. These might include
26 things like motor positions, temperature, anything that describes the
27 experiment.
29 Attributes:
30 -----------
31 * name # A string name of this parameter, e.g. "13IDD:m1"
32 * description # A string description of this parameter, e.g. "X stage"
33 * value # A string value of this parameter, e.g. "14.223"
34 """
35 def __init__(self, desc='', addr='', val=''):
36 self.addr = str(addr).strip()
37 self.val = str(val).strip()
38 self.desc = str(desc).strip()
39 if self.desc.startswith('(') and self.desc.endswith(')'):
40 self.desc = self.desc[1:-1]
42 def __repr__(self):
43 return "Environment(desc='%s', val='%s', addr='%s')" % (self.desc,
44 self.val,
45 self.addr)
48class MCA(Group):
49 """
50 MultiChannel Analyzer (MCA) class
52 Attributes:
53 -----------
54 * self.label = 'mca' # label for the mca
55 * self.nchans = 2048 # number of mca channels
56 * self.counts = None # MCA data
57 * self.pileup = None # predicted pileup
59 # Counting parameters
60 * self.start_time = '' # Start time and date, a string
61 * self.live_time = 0. # Elapsed live time in seconds
62 * self.real_time = 0. # Elapsed real time in seconds
63 * self.read_time = 0. # Time that the MCA was last read in seconds
64 * self.total_counts = 0. # Total counts between the preset start and stop channels
65 * self.input_counts = 0. # Actual total input counts (eg given by detector software)
66 # Note total_counts and input counts ARE NOT time normalized
67 #
68 * self.tau = -1.0 # Factor for deadtime/detector saturation calculations, ie
69 # ocr = icr * exp(-icr*tau)
70 * self.icr_calc = -1.0 # Calculated input count rate from above expression
71 * self.dt_factor = 1.0 # deadtime correction factor based on icr,ocr,lt,rt
72 # data_corrected = data * dt_factor
73 #
74 # Calibration parameters
75 * self.offset = 0. # Offset
76 * self.slope = 1.0 # Slope
77 * self.quad = 0. # Quadratic
79 Notes:
80 ------
81 If input_counts are read from the data file then there is no need for tau
82 values (and hence icr_calc is not used) - ie we assume the detector
83 provides the icr value.
85 The application of corrections uses the following logic:
86 if tau > 0 this will be used in the correction factor calculation
87 if tau = 0 then we assume ocr = icr in the correction factor calculation
88 if tau < 0 (or None):
89 if input_counts > 0 this will be used for icr in the factor calculation
90 if input_counts <= 0 we assume ocr = icr in the correction factor calculation
92 Energy calibration is based on the following:
93 energy = offset + slope*channel + quad*channel**2
95 """
96 ###############################################################################
97 def __init__(self, counts=None, nchans=2048, start_time='',
98 offset=0, slope=0, quad=0, dt_factor=1,
99 real_time=0, live_time=0, input_counts=0, tau=0,
100 label='mca', name=None, filename=None, **kws):
102 self.label = name if name is not None else label
103 if filename is None: filename = ''
104 if len(filename) > 0:
105 self.label = f"{filename:s}: {self.label:s}"
107 self.nchans = nchans
108 self.environ = []
109 self.rois = []
110 self.counts = counts
112 # Calibration parameters
113 self.offset = offset # Offset
114 self.slope = slope # Slope
115 self.quad = quad # Quadratic
117 # Counting parameters
118 self.start_time = start_time
119 self.real_time = real_time # Elapsed real time in seconds (requested counting time)
120 self.live_time = live_time # Elapsed live time in seconds (time detector is live)
121 self.input_counts = input_counts # Actual total input counts (eg given by detector software)
122 # Note total_counts and input counts ARE NOT time normalized
123 #
124 self.total_counts = 0. # Total counts between the preset start and stop channels
125 self.tau = tau # Factor for deadtime/detector saturation calculations, ie
126 # ocr = icr * exp(-icr*tau)
127 # Calculated correction values
128 self.icr_calc = -1.0 # Calculated input count rate from above expression
129 # corrected_counts = counts * dt_factor
130 self.bgr = None
131 self.dt_factor = float(dt_factor)
132 if counts is not None:
133 self.nchans = len(counts)
134 self.total_counts = counts.sum()
135 self.incident_energy = None
136 self.get_energy()
137 self._calc_correction()
138 Group.__init__(self)
140 def __repr__(self):
141 form = "<MCA %s, nchans=%d, counts=%d, realtime=%.1f>"
142 return form % (self.label, self.nchans, self.total_counts, self.real_time)
144 def add_roi(self, name='', left=0, right=0, bgr_width=3,
145 counts=None, sort=True):
146 """add an ROI"""
147 name = name.strip()
148 roi = ROI(name=name, left=left, right=right,
149 bgr_width=bgr_width, counts=counts)
151 rnames = [r.name.lower() for r in self.rois]
152 if name.lower() in rnames:
153 iroi = rnames.index(name.lower())
154 self.rois[iroi] = roi
155 else:
156 self.rois.append(roi)
157 if sort:
158 self.rois.sort()
160 def get_roi_counts(self, roi, net=False):
161 """get counts for an roi"""
162 thisroi = None
163 if isinstance(roi, ROI):
164 thisroi = roi
165 elif isinstance(roi, int) and roi < len(self.rois):
166 thisroi = self.rois[roi]
167 elif isinstance(roi, str):
168 rnames = [r.name.lower() for r in self.rois]
169 for iroi, nam in enumerate(rnames):
170 if nam.startswith(roi.lower()):
171 thisroi = self.rois[iroi]
172 break
173 if thisroi is None:
174 return None
175 return thisroi.get_counts(self.counts, net=net)
177 def add_environ(self, desc='', val='', addr=''):
178 """add an Environment setting"""
179 if len(desc) > 0 and len(val) > 0:
180 self.environ.append(Environment(desc=desc, val=val, addr=addr))
182 def predict_pileup(self, scale=None):
183 """
184 predict pileup for a spectrum, save to 'pileup' attribute
185 """
186 en = self.energy
187 npts = len(en)
188 counts = self.counts.astype(int)*1.0
189 pileup = 1.e-8*np.convolve(counts, counts, 'full')[:npts]
190 ex = en[0] + np.arange(len(pileup))*(en[1] - en[0])
191 if scale is None:
192 npts = len(en)
193 nhalf = int(npts/2) + 1
194 nmost = int(7*npts/8.0) - 1
195 scale = self.counts[nhalf:].sum()/ pileup[nhalf:nmost].sum()
196 self.pileup = interp(ex, scale*pileup, self.energy, kind='cubic')
197 self.pileup_scale = scale
199 def predict_escape(self, det='Si', scale=1.0):
200 """
201 predict detector escape for a spectrum, save to 'escape' attribute
203 X-rays penetrate a depth 1/mu(material, energy) and the
204 detector fluorescence escapes from that depth as
205 exp(-mu(material, KaEnergy)*thickness)
206 with a fluorecence yield of the material
208 """
209 fluor_en = xray_line(det, 'Ka').energy/1000.0
210 edge = xray_edge(det, 'K')
212 # here we use the 1/e depth for the emission
213 # and the 1/e depth of the incident beam:
214 # the detector fluorescence can escape on either side
215 mu_emit = material_mu(det, fluor_en*1000)
216 mu_input = material_mu(det, self.energy*1000)
217 escape = 0.5*scale*edge.fyield * np.exp(-mu_emit / (2*mu_input))
218 escape[np.where(self.energy*1000 < edge.energy)] = 0.0
219 escape *= interp(self.energy - fluor_en, self.counts*1.0,
220 self.energy, kind='cubic')
221 self.escape = escape
223 def update_correction(self, tau=None):
224 """
225 Update the deadtime correction
227 if tau == None just recompute,
228 otherwise assign a new tau and recompute
229 """
230 if tau is not None:
231 self.tau = tau
232 self._calc_correction()
234 def _calc_correction(self):
235 """
236 if self.tau > 0 this will be used in the correction factor calculation
237 if self.tau = 0 then we assume ocr = icr in the correction factor calculation,
238 ie only lt correction
239 (note deadtime.calc_icr handles above two conditions)
240 if self.tau < 0 (or None):
241 if input_counts > 0 this will be used for icr in the factor calculation
242 if input_counts <= 0 we assume ocr = icr in the correction factor calculation,
243 ie only lt correction
244 """
245 if self.live_time <= 0 or self.real_time <= 0:
246 self.dt_factor = 1.0
247 return
249 if self.total_counts > 0:
250 ocr = self.total_counts / self.live_time
251 else:
252 ocr = None
254 if self.tau >= 0:
255 icr = calc_icr(ocr,self.tau)
256 if icr is None:
257 icr = 0
258 self.icr_calc = icr
259 elif self.input_counts > 0:
260 icr = self.input_counts / self.live_time
261 else:
262 icr = ocr = None
263 self.dt_factor = correction_factor(self.real_time, self.live_time,
264 icr=icr, ocr=ocr)
265 if self.dt_factor <= 0:
266 print( "Error computing counts correction factor --> setting to 1")
267 self.dt_factor = 1.0
269 ########################################################################
270 def get_counts(self, correct=True):
271 """
272 Returns the counts array from the MCA
274 Note if correct == True the corrected counts is returned. However,
275 this does not (re)compute the correction factor, therefore, make
276 sure the correction factor is up to date before requesting
277 corrected counts...
278 """
279 if correct:
280 return (self.dt_factor * self.counts).astype(np.int32)
281 else:
282 return self.counts
284 def get_energy(self):
285 """
286 Returns array of energy for each channel in the MCA spectra.
287 """
288 chans = np.arange(self.nchans, dtype=np.float64)
289 self.energy = self.offset + chans * (self.slope + chans * self.quad)
290 return self.energy
292 def save_columnfile(self, filename, headerlines=None):
293 "write summed counts to simple ASCII column file for mca counts"
294 f = open(filename, "w+")
295 f.write("#XRF counts for %s\n" % self.label)
296 if headerlines is not None:
297 for i in headerlines:
298 f.write("#%s\n" % i)
299 f.write("#\n")
300 f.write("#EnergyCalib.offset = %.9g \n" % self.offset)
301 f.write("#EnergyCalib.slope = %.9g \n" % self.slope)
302 f.write("#EnergyCalib.quad = %.9g \n" % self.quad)
303 f.write("#Acquire.RealTime = %.9g \n" % self.real_time)
304 f.write("#Acquire.LiveTime = %.9g \n" % self.live_time)
305 roiform = "#ROI_%i '%s': [%i, %i]\n"
306 for i, r in enumerate(self.rois):
307 f.write(roiform % (i+1, r.name, r.left, r.right))
309 f.write("#-----------------------------------------\n")
310 f.write("# energy counts log_counts\n")
312 for e, d in zip(self.energy, self.counts):
313 dlog = 0.
314 if d > 0: dlog = np.log10(max(d, 1))
315 f.write(" %10.4f %12i %12.6g\n" % (e, d, dlog))
316 f.write("\n")
317 f.close()
319 def dump_mcafile(self):
320 """return text of mca file, not writing to disk, as for dump/load"""
321 b = ['VERSION: 3.1', 'ELEMENTS: 1',
322 'DATE: %s' % self.start_time,
323 'CHANNELS: %d' % len(self.counts),
324 'REAL_TIME: %f' % self.real_time,
325 'LIVE_TIME: %f' % self.live_time,
326 'CAL_OFFSET: %e' % self.offset,
327 'CAL_SLOPE: %e' % self.slope,
328 'CAL_QUAD: %e' % self.quad,
329 'ROIS: %d' % len(self.rois)]
331 # ROIS
332 for i, roi in enumerate(self.rois):
333 b.extend(['ROI_%i_LEFT: %d' % (i, roi.left),
334 'ROI_%i_RIGHT: %d' % (i, roi.right),
335 'ROI_%i_LABEL: %s &' % (i, roi.name)])
337 # environment
338 for e in self.environ:
339 b.append('ENVIRONMENT: %s="%s" (%s)' % (e.addr, e.val, e.desc))
341 # data
342 b.append('DATA: ')
343 for d in self.counts:
344 b.append(" %d" % d)
345 b.append('')
346 return '\n'.join(b)
348 def save_mcafile(self, filename):
349 """write MCA file
351 Parameters:
352 -----------
353 * filename: output file name
354 """
355 with open(filename, 'w') as fh:
356 fh.write(self.dump_mcafile())
358def create_mca(counts=None, nchans=2048, offset=0, slope=0, quad=0,
359 label='mca', start_time='', real_time=0, live_time=0,
360 dt_factor=1, input_counts=0, tau=0, **kws):
362 """create an MCA object, containing an XRF (or similar) spectrum
364 Parameters:
365 ------------
366 counts: counts array
367 nchans: # channels
369 Returns:
370 ----------
371 an MCA object
373 """
374 return MCA(counts=counts, nchans=nchans, label=label,
375 start_time=start_time, offset=offset, slope=slope,
376 quad=quad, dt_factor=dt_factor, real_time=real_time,
377 live_time=live_time, input_counts=input_counts,
378 tau=tau, **kws)