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

1""" 

2This module defines a device-independent MultiChannel Analyzer (MCA) class. 

3 

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 

12 

13from xraydb import xray_line, xray_edge, material_mu 

14from ..math import interp 

15from .deadtime import calc_icr, correction_factor 

16from .roi import ROI 

17 

18 

19def isLarchMCAGroup(grp): 

20 """tests whether variable holds a valid Larch MCAGroup""" 

21 return isgroup(grp, 'energy', 'counts', 'rois') 

22 

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. 

28 

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] 

41 

42 def __repr__(self): 

43 return "Environment(desc='%s', val='%s', addr='%s')" % (self.desc, 

44 self.val, 

45 self.addr) 

46 

47 

48class MCA(Group): 

49 """ 

50 MultiChannel Analyzer (MCA) class 

51 

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 

58 

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 

78 

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. 

84 

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 

91 

92 Energy calibration is based on the following: 

93 energy = offset + slope*channel + quad*channel**2 

94 

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): 

101 

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}" 

106 

107 self.nchans = nchans 

108 self.environ = [] 

109 self.rois = [] 

110 self.counts = counts 

111 

112 # Calibration parameters 

113 self.offset = offset # Offset 

114 self.slope = slope # Slope 

115 self.quad = quad # Quadratic 

116 

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) 

139 

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) 

143 

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) 

150 

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() 

159 

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) 

176 

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)) 

181 

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 

198 

199 def predict_escape(self, det='Si', scale=1.0): 

200 """ 

201 predict detector escape for a spectrum, save to 'escape' attribute 

202 

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 

207 

208 """ 

209 fluor_en = xray_line(det, 'Ka').energy/1000.0 

210 edge = xray_edge(det, 'K') 

211 

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 

222 

223 def update_correction(self, tau=None): 

224 """ 

225 Update the deadtime correction 

226 

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() 

233 

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 

248 

249 if self.total_counts > 0: 

250 ocr = self.total_counts / self.live_time 

251 else: 

252 ocr = None 

253 

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 

268 

269 ######################################################################## 

270 def get_counts(self, correct=True): 

271 """ 

272 Returns the counts array from the MCA 

273 

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 

283 

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 

291 

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)) 

308 

309 f.write("#-----------------------------------------\n") 

310 f.write("# energy counts log_counts\n") 

311 

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() 

318 

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)] 

330 

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)]) 

336 

337 # environment 

338 for e in self.environ: 

339 b.append('ENVIRONMENT: %s="%s" (%s)' % (e.addr, e.val, e.desc)) 

340 

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) 

347 

348 def save_mcafile(self, filename): 

349 """write MCA file 

350 

351 Parameters: 

352 ----------- 

353 * filename: output file name 

354 """ 

355 with open(filename, 'w') as fh: 

356 fh.write(self.dump_mcafile()) 

357 

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): 

361 

362 """create an MCA object, containing an XRF (or similar) spectrum 

363 

364 Parameters: 

365 ------------ 

366 counts: counts array 

367 nchans: # channels 

368 

369 Returns: 

370 ---------- 

371 an MCA object 

372 

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)