Coverage for /Users/Newville/Codes/xraylarch/larch/xrf/xrf_model.py: 11%

513 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-11-09 10:08 -0600

1from collections import namedtuple 

2import time 

3import json 

4import numpy as np 

5from numpy.linalg import lstsq 

6from scipy.optimize import nnls 

7 

8 

9from lmfit import Parameters, minimize, fit_report 

10 

11from xraydb import (material_mu, mu_elam, ck_probability, xray_edge, 

12 xray_edges, xray_lines, xray_line) 

13from xraydb.xray import XrayLine 

14 

15from .. import Group 

16from ..math import index_of, interp, savitzky_golay, hypermet, erfc 

17from ..xafs import ftwindow 

18from ..utils import group2dict, json_dump, json_load, gformat, unixpath 

19 

20xrf_prediction = namedtuple("xrf_prediction", ("weights", "total")) 

21xrf_peak = namedtuple('xrf_peak', ('name', 'amplitude', 'center', 'step', 

22 'tail', 'sigmax', 'beta', 'gamma', 

23 'vary_center', 'vary_step', 'vary_tail', 

24 'vary_sigmax', 'vary_beta', 'vary_gamma')) 

25 

26predict_methods = {'lstsq': lstsq, 'nnls': nnls} 

27 

28# Note on units: energies are in keV, lengths in cm 

29 

30 

31# note on Fano Factors 

32# efano = (energy to create e-h pair) * FanoFactor 

33# material E-h excitation (eV) Fano Factor EFano (keV) 

34# Si 3.66 0.115 0.000 4209 

35# Ge 3.0 0.130 0.000 3900 

36FanoFactors = {'Si': 0.4209e-3, 'Ge': 0.3900e-3} 

37 

38 

39class XRF_Material: 

40 def __init__(self, material='Si', thickness=0.050, density=None, 

41 thickness_units='mm'): 

42 self.material = material 

43 self.density = density 

44 self.thickness_units = thickness_units 

45 self.thickness = thickness 

46 self.mu_total = self.mu_photo = None 

47 

48 def calc_mu(self, energy): 

49 "calculate mu for energy in keV" 

50 # note material_mu works in eV! 

51 self.mu_total = material_mu(self.material, 1000*energy, 

52 density=self.density, 

53 kind='total') 

54 self.mu_photo = material_mu(self.material, 1000*energy, 

55 density=self.density, 

56 kind='photo') 

57 

58 def absorbance(self, energy, thickness=None, kind='total'): 

59 """calculate absorbance (fraction absorbed) 

60 

61 Arguments 

62 ---------- 

63 energy float or ndarray energy (keV) of X-ray 

64 thicknesss float material thickness (cm) 

65 

66 Returns 

67 ------- 

68 fraction of X-rays absorbed by material 

69 """ 

70 if thickness is None: 

71 thickness = self.thickness 

72 if self.mu_total is None: 

73 self.calc_mu(energy) 

74 mu = self.mu_total 

75 if kind == 'photo': 

76 mu = self.mu_photo 

77 

78 t = 0.1*thickness 

79 return (1.0 - np.exp(-t*mu)) 

80 

81 def transmission(self, energy, thickness=None, kind='total'): 

82 """calculate transmission (fraction transmitted through material) 

83 

84 Arguments 

85 --------- 

86 energy float or ndarray energy (keV) of X-ray 

87 thicknesss float material thickness (cm) 

88 

89 Returns 

90 ------- 

91 fraction of X-rays transmitted through material 

92 """ 

93 

94 if thickness is None: 

95 thickness = self.thickness 

96 if self.mu_total is None: 

97 self.calc_mu(energy) 

98 mu = self.mu_total 

99 if kind == 'photo': 

100 mu = self.mu_photo 

101 

102 t = 0.1*thickness 

103 return np.exp(-t*mu) 

104 

105 

106class XRF_Element: 

107 def __init__(self, symbol, xray_energy=10, energy_min=1.5): 

108 """get Xray emission lines for an element""" 

109 self.symbol = symbol 

110 

111 # note: 'xray_energy' and 'energy_min' are in keV, 

112 # most of this code works in eV! 

113 if xray_energy is None: xray_energy = 30.0 

114 if energy_min is None: energy_min = 1.5 

115 self.xray_energy = xray_energy 

116 self.energy_min = energy_min 

117 

118 en_xray_ev = xray_energy * 1000.0 

119 en_low_ev = energy_min * 1000.0 

120 self.mu = 1.0 

121 self.edges = ['K'] 

122 self.fyields = {} 

123 

124 self.mu = mu_elam(symbol, en_xray_ev, kind='photo') 

125 self.edges = [] 

126 for ename, xedge in xray_edges(self.symbol).items(): 

127 if ename.lower() in ('k', 'l1', 'l2', 'l3', 'm5'): 

128 if (xedge.energy < en_xray_ev and xedge.energy > en_low_ev): 

129 self.edges.append(ename) 

130 self.fyields[ename] = xedge.fyield 

131 

132 # currently, we consider only one edge per element 

133 if 'K' in self.edges: 

134 self.edges = ['K'] 

135 if 'L3' in self.edges: 

136 tmp = [] 

137 for ename in self.edges: 

138 if ename.lower().startswith('l'): 

139 tmp.append(ename) 

140 self.edges = tmp 

141 

142 # apply CK corrections to fluorescent yields 

143 if 'L3' in self.edges: 

144 nlines = 1.0 

145 ck13 = ck_probability(symbol, 'L1', 'L3') 

146 ck12 = ck_probability(symbol, 'L1', 'L2') 

147 ck23 = ck_probability(symbol, 'L2', 'L3') 

148 fy3 = self.fyields['L3'] 

149 fy2 = self.fyields.get('L2', 0) 

150 fy1 = self.fyields.get('L1', 0) 

151 if 'L2' in self.edges: 

152 nlines = 2.0 

153 fy3 = fy3 + fy2*ck23 

154 fy2 = fy2 * (1 - ck23) 

155 if 'L1' in self.edges: 

156 nlines = 3.0 

157 fy3 = fy3 + fy1 * (ck13 + ck12*ck23) 

158 fy2 = fy2 + fy1 * ck12 

159 fy1 = fy1 * (1 - ck12 - ck13 - ck12*ck23) 

160 self.fyields['L1'] = fy1 

161 self.fyields['L2'] = fy2 

162 self.fyields['L3'] = fy3/nlines 

163 self.fyields['L2'] = fy2/nlines 

164 self.fyields['L1'] = fy1/nlines 

165 

166 # look up X-ray lines, keep track of very close lines 

167 # so that they can be consolidated 

168 # slightly confusing (and working with XrayLine energies in ev) 

169 self.lines = {} 

170 all_lines = {} 

171 en_max = 0 

172 for ename in self.edges: 

173 for key, xline in xray_lines(symbol, ename).items(): 

174 all_lines[key] = xline 

175 en_max = max(en_max, xline.energy) 

176 

177 if en_max < 100: 

178 en_max = en_xray_ev 

179 overlap_energy = en_max*0.001 # should be around 1 to 10 eV. 

180 

181 dups = {} 

182 for i, key in enumerate(all_lines): 

183 xline = all_lines[key] 

184 dup_found = False 

185 for okey, oline in self.lines.items(): 

186 if (okey != key and ((xline.energy-oline.energy) < overlap_energy) and 

187 xline.initial_level == oline.initial_level): 

188 dups[key] = okey 

189 if key not in dups: 

190 self.lines[key] = xline 

191 

192 for key, dest in dups.items(): 

193 if dest in self.lines: 

194 w1, w2 = all_lines[key].intensity, all_lines[dest].intensity 

195 e1, e2 = all_lines[key].energy, all_lines[dest].energy 

196 f1, f2 = all_lines[key].final_level, all_lines[dest].final_level 

197 wt = w1+w2 

198 en = (e1*w1 + e2*w2)/(wt) 

199 

200 newkey = "%s,%s" % (key, dest) 

201 if key[:2] == dest[:2]: 

202 newkey = "%s,%s" % (key, dest[2:]) 

203 

204 flevel = "%s,%s" % (f1, f2) 

205 if f1[0] == f2[0]: 

206 flevel = "%s,%s" % (f1, f2[1:]) 

207 

208 self.lines.pop(dest) 

209 self.lines[newkey] = XrayLine(energy=en, intensity=wt, 

210 initial_level=all_lines[key].initial_level, 

211 final_level=flevel) 

212 for k, d in dups.items(): 

213 if d == dest: 

214 dups[k] = newkey 

215 

216 

217class XRF_Model: 

218 """model for X-ray fluorescence data 

219 

220 consists of parameterized components for 

221 

222 incident beam (energy, angle_in, angle_out) 

223 matrix (list of material, thickness) 

224 filters (list of material, thickness) 

225 detector (material, thickness, step, tail, beta, gamma) 

226 """ 

227 def __init__(self, xray_energy=None, energy_min=1.5, energy_max=30., 

228 count_time=1, bgr=None, iter_callback=None, **kws): 

229 self.xray_energy = xray_energy 

230 self.energy_min = energy_min 

231 self.energy_max = energy_max 

232 self.count_time = count_time 

233 self.iter_callback = None 

234 self.fit_in_progress = False 

235 self.params = Parameters() 

236 self.elements = [] 

237 self.scatter = [] 

238 self.comps = {} 

239 self.eigenvalues = {} 

240 self.transfer_matrix = None 

241 self.matrix_layers = [] 

242 self.matrix = None 

243 self.matrix_atten = 1.0 

244 self.filters = [] 

245 self.det_atten = self.filt_atten = self.escape_amp = 0.0 

246 self.mu_lines = {} 

247 self.mu_energies = [] 

248 self.fit_iter = 0 

249 self.fit_toler = 5.e-3 

250 self.fit_step = 1.e-4 

251 self.max_nfev = 1000 

252 self.fit_log = False 

253 self.bgr = None 

254 self.use_pileup = False 

255 self.use_escape = False 

256 self.escape_scale = None 

257 self.script = '' 

258 self.mca = None 

259 if bgr is not None: 

260 self.add_background(bgr) 

261 

262 def set_detector(self, material='Si', thickness=0.40, noise=0.05, 

263 peak_step=1e-3, peak_tail=0.01, peak_gamma=0, 

264 peak_beta=0.5, cal_offset=0, cal_slope=10., cal_quad=0, 

265 vary_thickness=False, vary_noise=True, 

266 vary_peak_step=True, vary_peak_tail=True, 

267 vary_peak_gamma=False, vary_peak_beta=False, 

268 vary_cal_offset=True, vary_cal_slope=True, 

269 vary_cal_quad=False): 

270 """ 

271 set up detector material, calibration, and general settings for 

272 the hypermet functions for the fluorescence and scatter peaks 

273 """ 

274 self.detector = XRF_Material(material, thickness) 

275 matname = material.title() 

276 if matname not in FanoFactors: 

277 matname = 'Si' 

278 self.efano = FanoFactors[matname] 

279 self.params.add('det_thickness', value=thickness, vary=vary_thickness, min=0) 

280 self.params.add('det_noise', value=noise, vary=vary_noise, min=0) 

281 self.params.add('cal_offset', value=cal_offset, vary=vary_cal_offset, min=-500, max=500) 

282 self.params.add('cal_slope', value=cal_slope, vary=vary_cal_slope, min=0) 

283 self.params.add('cal_quad', value=cal_quad, vary=vary_cal_quad) 

284 self.params.add('peak_step', value=peak_step, vary=vary_peak_step, min=0, max=10) 

285 self.params.add('peak_tail', value=peak_tail, vary=vary_peak_tail, min=0, max=10) 

286 self.params.add('peak_beta', value=peak_beta, vary=vary_peak_beta, min=0) 

287 self.params.add('peak_gamma', value=peak_gamma, vary=vary_peak_gamma, min=0) 

288 

289 def add_scatter_peak(self, name='elastic', amplitude=1000, center=None, 

290 step=0.010, tail=0.5, sigmax=1.0, beta=0.5, 

291 vary_center=True, vary_step=True, vary_tail=True, 

292 vary_sigmax=True, vary_beta=False): 

293 """add Rayleigh (elastic) or Compton (inelastic) scattering peak 

294 """ 

295 if name not in self.scatter: 

296 self.scatter.append(xrf_peak(name, amplitude, center, step, tail, 

297 sigmax, beta, 0.0, vary_center, vary_step, 

298 vary_tail, vary_sigmax, vary_beta, False)) 

299 

300 if center is None: 

301 center = self.xray_energy 

302 

303 self.params.add('%s_amp' % name, value=amplitude, vary=True, min=0) 

304 self.params.add('%s_center' % name, value=center, vary=vary_center, 

305 min=center*0.5, max=center*1.25) 

306 self.params.add('%s_step' % name, value=step, vary=vary_step, min=0, max=10) 

307 self.params.add('%s_tail' % name, value=tail, vary=vary_tail, min=0, max=20) 

308 self.params.add('%s_beta' % name, value=beta, vary=vary_beta, min=0, max=20) 

309 self.params.add('%s_sigmax' % name, value=sigmax, vary=vary_sigmax, 

310 min=0, max=100) 

311 

312 def add_element(self, elem, amplitude=1.e6, vary_amplitude=True): 

313 """add Element to XRF model 

314 """ 

315 self.elements.append(XRF_Element(elem, xray_energy=self.xray_energy, 

316 energy_min=self.energy_min)) 

317 self.params.add('amp_%s' % elem.lower(), value=amplitude, 

318 vary=vary_amplitude, min=0) 

319 

320 def add_filter(self, material, thickness, density=None, vary_thickness=False): 

321 self.filters.append(XRF_Material(material=material, 

322 density=density, 

323 thickness=thickness)) 

324 self.params.add('filterlen_%s' % material, 

325 value=thickness, min=0, vary=vary_thickness) 

326 

327 def set_matrix(self, material, thickness, density=None): 

328 self.matrix = XRF_Material(material=material, density=density, 

329 thickness=thickness) 

330 self.matrix_atten = 1.0 

331 

332 def add_background(self, data, vary=True): 

333 self.bgr = data 

334 self.params.add('background_amp', value=1.0, min=0, vary=vary) 

335 

336 def add_escape(self, scale=1.0, vary=True): 

337 self.use_escape = True 

338 self.params.add('escape_amp', value=scale, min=0, vary=vary) 

339 

340 def add_pileup(self, scale=1.0, vary=True): 

341 self.use_pileup = True 

342 self.params.add('pileup_amp', value=scale, min=0, vary=vary) 

343 

344 def clear_background(self): 

345 self.bgr = None 

346 self.params.pop('background_amp') 

347 

348 def calc_matrix_attenuation(self, energy): 

349 """ 

350 calculate beam attenuation by a matrix built from layers 

351 note that matrix layers and composition cannot be variable 

352 so the calculation can be done once, ahead of time. 

353 """ 

354 atten = 1.0 

355 if self.matrix is not None: 

356 ixray_en = index_of(energy, self.xray_energy) 

357 # print("MATRIX ", ixray_en, self.matrix) 

358 # layer_trans = self.matrix.transmission(energy) # transmission through layer 

359 # incid_trans = layer_trans[ixray_en] # incident beam trans to lower layers 

360 # ncid_absor = 1.0 - incid_trans # incident beam absorption by layer 

361 # atten = layer_trans * incid_absor 

362 self.matrix_atten = atten 

363 

364 def calc_escape_scale(self, energy, thickness=None): 

365 """ 

366 calculate energy dependence of escape effect 

367 

368 X-rays penetrate a depth 1/mu(material, energy) and the 

369 detector fluorescence escapes from that depth as 

370 exp(-mu(material, KaEnergy)*thickness) 

371 with a fluorecence yield of the material 

372 

373 """ 

374 det = self.detector 

375 # note material_mu, xray_edge, xray_line work in eV! 

376 escape_energy_ev = xray_line(det.material, 'Ka').energy 

377 mu_emit = material_mu(det.material, escape_energy_ev) 

378 self.escape_energy = 0.001 * escape_energy_ev 

379 

380 mu_input = material_mu(det.material, 1000*energy) 

381 

382 edge = xray_edge(det.material, 'K') 

383 self.escape_scale = edge.fyield * np.exp(-mu_emit / (2*mu_input)) 

384 self.escape_scale[np.where(energy < 0.001*edge.energy)] = 0.0 

385 

386 def det_sigma(self, energy, noise=0): 

387 """ energy width of peak """ 

388 return np.sqrt(self.efano*energy + noise**2) 

389 

390 def calc_spectrum(self, energy, params=None): 

391 if params is None: 

392 params = self.params 

393 pars = params.valuesdict() 

394 self.comps = {} 

395 self.eigenvalues = {} 

396 

397 det_noise = pars['det_noise'] 

398 step = pars['peak_step'] 

399 tail = pars['peak_tail'] 

400 beta = pars['peak_beta'] 

401 gamma = pars['peak_gamma'] 

402 

403 # escape: calc only if needed 

404 if ((not self.fit_in_progress) or 

405 self.params['det_thickness'].vary or 

406 self.params['escape_amp'].vary): 

407 if self.escape_scale is None: 

408 self.calc_escape_scale(energy, thickness=pars['det_thickness']) 

409 self.escape_amp = pars.get('escape_amp', 0.0) * self.escape_scale 

410 if not self.use_escape: 

411 self.escape_amp = 0 

412 

413 # detector attenuation: calc only if needed 

414 if (not self.fit_in_progress) or self.params['det_thickness'].vary: 

415 self.det_atten = self.detector.absorbance(energy, thickness=pars['det_thickness']) 

416 

417 # filter attenuation: calc only if needed 

418 filt_pars = [self.params['filterlen_%s' % f.material] for f in self.filters] 

419 if (not self.fit_in_progress) or any([f.vary for f in filt_pars]): 

420 self.filt_atten = np.ones(len(energy)) 

421 for f in self.filters: 

422 thickness = pars.get('filterlen_%s' % f.material, None) 

423 if thickness is not None and int(thickness*1e6) > 1: 

424 fx = f.transmission(energy, thickness=thickness) 

425 self.filt_atten *= fx 

426 

427 self.atten = self.det_atten * self.filt_atten 

428 

429 # matrix corrections #1: get X-ray line energies, only if needed 

430 if ((not self.fit_in_progress) or len(self.mu_energies)==0): 

431 self.mu_lines = {} 

432 self.mu_energies = [] 

433 for elem in self.elements: 

434 for key, line in elem.lines.items(): 

435 self.mu_lines[f'{elem.symbol:s}_{key:s}'] = line.energy 

436 self.mu_energies.append(line.energy) 

437 

438 self.mu_energies.append(1000*self.xray_energy) 

439 self.mu_energies = np.array(self.mu_energies) 

440 

441 # print("Mu energies ", self.mu_energies, len(self.mu_energies)) 

442 # matrix 

443 # if self.matrix_atten is None: 

444 # self.calc_matrix_attenuation(energy) 

445 # atten *= self.matrix_atten 

446 

447 nlines = 0 

448 for elem in self.elements: 

449 comp = 0. * energy 

450 amp = pars.get('amp_%s' % elem.symbol.lower(), None) 

451 if amp is None: 

452 continue 

453 for key, line in elem.lines.items(): 

454 ecen = 0.001*line.energy 

455 nlines += 1 

456 line_amp = line.intensity * elem.mu * elem.fyields[line.initial_level] 

457 sigma = self.det_sigma(ecen, det_noise) 

458 comp += hypermet(energy, amplitude=line_amp, center=ecen, 

459 sigma=sigma, step=step, tail=tail, 

460 beta=beta, gamma=gamma) 

461 comp *= amp * self.atten * self.count_time 

462 comp += self.escape_amp * interp(energy-self.escape_energy, comp, energy) 

463 

464 self.comps[elem.symbol] = comp 

465 self.eigenvalues[elem.symbol] = amp 

466 

467 

468 # scatter peaks for Rayleigh and Compton 

469 for peak in self.scatter: 

470 p = peak.name 

471 amp = pars.get('%s_amp' % p, None) 

472 if amp is None: 

473 continue 

474 ecen = pars['%s_center' % p] 

475 step = pars['%s_step' % p] 

476 tail = pars['%s_tail' % p] 

477 beta = pars['%s_beta' % p] 

478 sigma = pars['%s_sigmax' % p] 

479 sigma *= self.det_sigma(ecen, det_noise) 

480 comp = hypermet(energy, amplitude=1.0, center=ecen, 

481 sigma=sigma, step=step, tail=tail, beta=beta, 

482 gamma=gamma) 

483 comp *= amp * self.atten * self.count_time 

484 comp += self.escape_amp * interp(energy-self.escape_energy, comp, energy) 

485 self.comps[p] = comp 

486 self.eigenvalues[p] = amp 

487 

488 if self.bgr is not None: 

489 bgr_amp = pars.get('background_amp', 0.0) 

490 self.comps['background'] = bgr_amp * self.bgr 

491 self.eigenvalues['background'] = bgr_amp 

492 

493 # calculate total spectrum 

494 total = 0. * energy 

495 for comp in self.comps.values(): 

496 total += comp 

497 

498 if self.use_pileup: 

499 pamp = pars.get('pileup_amp', 0.0) 

500 npts = len(energy) 

501 pileup = pamp*1.e-9*np.convolve(total, total*1.0, 'full')[:npts] 

502 self.comps['pileup'] = pileup 

503 self.eigenvalues['pileup'] = pamp 

504 total += pileup 

505 

506 # remove tiny values so that log plots are usable 

507 floor = 1.e-10*max(total) 

508 total[np.where(total<floor)] = floor 

509 self.current_model = total 

510 return total 

511 

512 def __resid(self, params, data, index): 

513 pars = params.valuesdict() 

514 self.best_en = (pars['cal_offset'] + pars['cal_slope'] * index + 

515 pars['cal_quad'] * index**2) 

516 self.fit_iter += 1 

517 model = self.calc_spectrum(self.best_en, params=params) 

518 if callable(self.iter_callback): 

519 self.iter_callback(iter=self.fit_iter, pars=pars) 

520 return ((data - model) * self.fit_weight)[self.imin:self.imax] 

521 

522 def set_fit_weight(self, energy, counts, emin, emax, ewid=0.050): 

523 """ 

524 set weighting factor to smoothed square-root of data 

525 """ 

526 ewin = ftwindow(energy, xmin=emin, xmax=emax, dx=ewid, window='hanning') 

527 self.fit_window = ewin 

528 fit_wt = 0.1 + savitzky_golay( (counts+1.0)**(2/3.0), 15, 1) 

529 self.fit_weight = 1.0/fit_wt 

530 

531 def fit_spectrum(self, mca, energy_min=None, energy_max=None, 

532 fit_toler=None, fit_step=None, max_nfev=None): 

533 if fit_toler is not None: 

534 self.fit_toler = max(1.e-7, min(0.25, fit_toler)) 

535 if fit_step is not None: 

536 self.fit_step = max(1.e-7, min(0.1, fit_step)) 

537 if max_nfev is not None: 

538 self.max_nfev = max(200, min(12000, max_nfev)) 

539 

540 self.mca = mca 

541 work_energy = 1.0*mca.energy 

542 work_counts = 1.0*mca.counts 

543 floor = 1.e-10*np.percentile(work_counts, [99])[0] 

544 work_counts[np.where(work_counts<floor)] = floor 

545 

546 if max(work_energy) > 250.0: # if input energies are in eV 

547 work_energy /= 1000.0 

548 

549 imin, imax = 0, len(work_counts) 

550 if energy_min is None: 

551 energy_min = self.energy_min 

552 if energy_min is not None: 

553 imin = index_of(work_energy, energy_min) 

554 if energy_max is None: 

555 energy_max = self.energy_max 

556 if energy_max is not None: 

557 imax = index_of(work_energy, energy_max) 

558 

559 self.imin = max(0, imin-5) 

560 self.imax = min(len(work_counts), imax+5) 

561 self.npts = (self.imax - self.imin) 

562 self.set_fit_weight(work_energy, work_counts, energy_min, energy_max) 

563 self.fit_iter = 0 

564 

565 # reset attenuation calcs for matrix, detector, filters 

566 self.matrix_atten = 1.0 

567 self.escape_scale = None 

568 self.detector.mu_total = None 

569 for f in self.filters: 

570 f.mu_total = None 

571 

572 self.fit_in_progress = False 

573 self.init_fit = self.calc_spectrum(work_energy, params=self.params) 

574 index = np.arange(len(work_counts)) 

575 userkws = dict(data=work_counts, index=index) 

576 

577 tol = self.fit_toler 

578 self.fit_in_progress = True 

579 self.result = minimize(self.__resid, self.params, kws=userkws, 

580 method='leastsq', maxfev=self.max_nfev, 

581 scale_covar=True, 

582 gtol=tol, ftol=tol, epsfcn=self.fit_step) 

583 

584 self.fit_report = fit_report(self.result, min_correl=0.5) 

585 pars = self.result.params 

586 self.fit_in_progress = False 

587 

588 self.best_en = (pars['cal_offset'] + pars['cal_slope'] * index + 

589 pars['cal_quad'] * index**2) 

590 self.fit_iter += 1 

591 self.best_fit = self.calc_spectrum(work_energy, params=self.result.params) 

592 

593 # calculate transfer matrix for linear analysis using this model 

594 tmat= [] 

595 for key, val in self.comps.items(): 

596 arr = val / self.eigenvalues[key] 

597 floor = 1.e-12*max(arr) 

598 arr[np.where(arr<floor)] = 0.0 

599 tmat.append(arr) 

600 self.transfer_matrix = np.array(tmat).transpose() 

601 return self.get_fitresult() 

602 

603 def get_fitresult(self, label='XRF fit result', script='# no script supplied'): 

604 """a simple compilation of fit settings results 

605 to be able to easily save and inspect""" 

606 out = XRFFitResult(label=label, script=script, mca=self.mca) 

607 

608 for attr in ('filename', 'label'): 

609 setattr(out, 'mca' + attr, getattr(self.mca, attr, 'unknown')) 

610 

611 for attr in ('params', 'var_names', 'chisqr', 'redchi', 'nvarys', 

612 'nfev', 'ndata', 'aic', 'bic', 'aborted', 'covar', 'ier', 

613 'message', 'method', 'nfree', 'init_values', 'success', 

614 'residual', 'errorbars', 'lmdif_message', 'nfree'): 

615 setattr(out, attr, getattr(self.result, attr, None)) 

616 

617 for attr in ('atten', 'best_en', 'best_fit', 'bgr', 'comps', 'count_time', 

618 'eigenvalues', 'energy_max', 'energy_min', 'fit_iter', 'fit_log', 

619 'fit_report', 'fit_toler', 'fit_weight', 'fit_window', 'init_fit', 

620 'scatter', 'script', 'transfer_matrix', 'xray_energy'): 

621 setattr(out, attr, getattr(self, attr, None)) 

622 

623 elem_attrs = ('edges', 'fyields', 'lines', 'mu', 'symbol', 'xray_energy') 

624 out.elements = [] 

625 for el in self.elements: 

626 out.elements.append({attr: getattr(el, attr) for attr in elem_attrs}) 

627 

628 mater_attrs = ('material', 'mu_photo', 'mu_total', 'thickness') 

629 out.detector = {attr: getattr(self.detector, attr) for attr in mater_attrs} 

630 out.matrix = None 

631 if self.matrix is not None: 

632 out.matrix = {attr: getattr(self.matrix, attr) for attr in mater_attrs} 

633 out.filters = [] 

634 for ft in self.filters: 

635 out.filters.append({attr: getattr(ft, attr) for attr in mater_attrs}) 

636 return out 

637 

638class XRFFitResult(Group): 

639 """Result of XRF Fit""" 

640 def __init__(self, label='xrf fit', filename=None, script='# No script', 

641 mca=None, **kws): 

642 kwargs = dict(label=label, filename=filename, script=script, mca=mca) 

643 kwargs.update(kws) 

644 Group.__init__(self, **kwargs) 

645 

646 def __repr__(self): 

647 return 'XRFFitResult(%r, filename=%r)' % (self.label, self.filename) 

648 

649 def save(self, filename): 

650 """save XRFFitResult result in a manner that can be loaded later""" 

651 tmp = {} 

652 for key, val in group2dict(self).items(): 

653 if key in ('__name__', '__repr__', 'save', 'load', 'export', 

654 '_prep_decompose', 'decompose_mca', 'decompose_map'): 

655 continue 

656 if key == 'mca': 

657 val = val.dump_mcafile() 

658 tmp[key] = val 

659 json_dump(tmp, unixpath(filename)) 

660 

661 def load(self, filename): 

662 from ..io import GSEMCA_File 

663 for key, val in json_load(filename).items(): 

664 if key == 'mca': 

665 val = GSEMCA_File(text=val) 

666 setattr(self, key, val) 

667 

668 def export(self, filename): 

669 """save result to text file""" 

670 buff = ['# XRF Fit %s: %s' % (self.mca.label, self.label), 

671 '#### Fit Script:'] 

672 for a in self.script.split('\n'): 

673 buff.append('# %s' % a) 

674 buff.append('#'*60) 

675 buff.append('#### Fit Report:') 

676 for a in self.fit_report.split('\n'): 

677 buff.append('# %s' % a) 

678 buff.append('#'*60) 

679 labels = ['energy', 'counts', 'best_fit', 'best_energy', 'fit_window', 

680 'fit_weight', 'attenuation'] 

681 labels.extend(list(self.comps.keys())) 

682 buff.append('# %s' % (' '.join(labels))) 

683 

684 npts = len(self.mca.energy) 

685 for i in range(npts): 

686 dline = [gformat(self.mca.energy[i]), 

687 gformat(self.mca.counts[i]), 

688 gformat(self.best_fit[i]), 

689 gformat(self.best_en[i]), 

690 gformat(self.fit_window[i]), 

691 gformat(self.fit_weight[i]), 

692 gformat(self.atten[i])] 

693 for c in self.comps.values(): 

694 dline.append(gformat(c[i])) 

695 buff.append(' '.join(dline)) 

696 buff.append('\n') 

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

698 fh.write('\n'.join(buff)) 

699 

700 def _prep_decompose(self, scale, count_time, method): 

701 if self.transfer_matrix is None: 

702 raise ValueError("XRFFitResult incomplete: need to fit a spectrum first") 

703 fit_method = predict_methods.get(method, lstsq) 

704 if count_time is None: 

705 count_time = self.count_time 

706 scale *= self.count_time / count_time 

707 return fit_method, scale 

708 

709 def decompose_spectrum(self, counts, scale=1.0, count_time=None, method='lstsq'): 

710 """ 

711 Apply XRFFitResult to another spectrum, decomposing it into elemenetal weights 

712 

713 Arguments: 

714 ---------- 

715 counts MCA counts for spectrum, on the same energy grid as the fitted data. 

716 scale scale factor to apply to output weights [1] 

717 count_time count time in seconds [None - use count_time of fitted spectrum] 

718 method decomposition method: one of `lstsq` for basic least-squares or 

719 `nnls` for non-negative least-squares [`lstsq`] 

720 

721 Returns: 

722 --------- 

723 namedtuple of XRF prediction with elements: 

724 weights dict of element: weights for all components used in the fit 

725 total predicted total spectrum 

726 """ 

727 method, scale = self._prep_decompose(scale, count_time, method) 

728 results = method(self.transfer_matrix, counts*self.fit_window) 

729 weights = {} 

730 total = 0.0*counts 

731 for i, name in enumerate(self.eigenvalues.keys()): 

732 weights[name] = results[0][i] * scale 

733 total += results[0][i] * self.transfer_matrix[:, i] 

734 

735 return xrf_prediction(weights, total) 

736 

737 def decompose_map(self, map, scale=1.0, pixel_time=1.0, method='lstsq', 

738 nworkers=4): 

739 """ 

740 Apply XRFFitResult to an XRF Map, decomposing it into maps of elemental weights 

741 

742 Arguments: 

743 ---------- 

744 map XRF map array: [NY, NX, NMCA], on the same energy grid as the fitted data. 

745 scale scale factor to apply to output weights [1] 

746 pixel_time count time in seconds for each pixel [1.0] 

747 method decomposition method: one of `lstsq` for basic least-squares or 

748 `nnls` for non-negative least-squares [`lstsq`] 

749 

750 Returns: 

751 --------- 

752 dict of elements: weights maps (NY, NX) for all components used in the fit 

753 """ 

754 method, scale = self._prep_decompose(scale, pixel_time, method) 

755 ny, nx, nchan = map.shape 

756 nchanx, ncomps = self.transfer_matrix.shape 

757 nchanw = self.fit_window.shape[0] 

758 if nchan != nchanx or nchan != nchanw: 

759 raise ValueError("map data has wrong shape ", map.shape) 

760 

761 win = np.where(self.fit_window > 0)[0] 

762 w0 = max(0, win[0]-100) 

763 w1 = min(nchan-1, win[-1]+100) 

764 

765 xfer = self.transfer_matrix[w0:w1, :] 

766 win = self.fit_window[w0:w1] 

767 result = np.zeros((ny, nx, ncomps), dtype='float32') 

768 

769 def decomp_lstsq(i0, i1): 

770 """very efficient lstsq""" 

771 tmap = map[i0:i1, :, w0:w1].swapaxes(1, 2) 

772 ny = tmap.shape[0] 

773 win.shape = (win.shape[0], 1) 

774 for iy in range(ny): 

775 results = lstsq(xfer, win*tmap[iy]) 

776 for i in range(ncomps): 

777 result[i0+iy,:,i] = results[0][i] * scale 

778 

779 def decomp_nnls(i0, i1): 

780 """need to explicitly loop of nx as well as ny""" 

781 tmap = map[i0:i1, :, w0:w1] 

782 ny = tmap.shape[0] 

783 for iy in range(ny): 

784 for ix in range(nx): 

785 results = nnls(xfer, win*tmap[iy,ix,:]) 

786 for i in range(ncomps): 

787 result[i0+iy,ix,i] = results[0][i] * scale 

788 

789 decomp = decomp_lstsq 

790 if method == nnls: 

791 decomp = decomp_nnls 

792 # if we're going to use up more than ~1Gb per lstsq, do it in chunks 

793 if (ny*nx*(w1-w0) > 1e8): 

794 nchunks = 1+int(1.e-8*ny*nx*(w1-w0)) 

795 ns = int(ny/nchunks) 

796 for i in range(nchunks): 

797 ilast = (i+1)*ns 

798 if i == nchunks-1: ilast = ny 

799 decomp(i*ns, ilast) 

800 else: 

801 decomp(0, ny) 

802 return {name: result[:,:,i] for i, name in enumerate(self.eigenvalues.keys())} 

803 

804def xrf_model(xray_energy=None, energy_min=1500, energy_max=None, use_bgr=False, **kws): 

805 """create an XRF Peak 

806 

807 Returns: 

808 --------- 

809 an XRF_Model instance 

810 """ 

811 return XRF_Model(xray_energy=xray_energy, use_bgr=use_bgr, 

812 energy_min=energy_min, energy_max=energy_max, **kws) 

813 

814def xrf_fitresult(save_file=None): 

815 """create an XRF Fit Result, possibly restoring from saved file 

816 

817 Returns: 

818 --------- 

819 an XRFFitResult instance 

820 """ 

821 

822 out = XRFFitResult() 

823 if save_file is not None: 

824 out.load(save_file) 

825 return out