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
« 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
9from lmfit import Parameters, minimize, fit_report
11from xraydb import (material_mu, mu_elam, ck_probability, xray_edge,
12 xray_edges, xray_lines, xray_line)
13from xraydb.xray import XrayLine
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
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'))
26predict_methods = {'lstsq': lstsq, 'nnls': nnls}
28# Note on units: energies are in keV, lengths in cm
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}
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
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')
58 def absorbance(self, energy, thickness=None, kind='total'):
59 """calculate absorbance (fraction absorbed)
61 Arguments
62 ----------
63 energy float or ndarray energy (keV) of X-ray
64 thicknesss float material thickness (cm)
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
78 t = 0.1*thickness
79 return (1.0 - np.exp(-t*mu))
81 def transmission(self, energy, thickness=None, kind='total'):
82 """calculate transmission (fraction transmitted through material)
84 Arguments
85 ---------
86 energy float or ndarray energy (keV) of X-ray
87 thicknesss float material thickness (cm)
89 Returns
90 -------
91 fraction of X-rays transmitted through material
92 """
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
102 t = 0.1*thickness
103 return np.exp(-t*mu)
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
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
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 = {}
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
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
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
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)
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.
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
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)
200 newkey = "%s,%s" % (key, dest)
201 if key[:2] == dest[:2]:
202 newkey = "%s,%s" % (key, dest[2:])
204 flevel = "%s,%s" % (f1, f2)
205 if f1[0] == f2[0]:
206 flevel = "%s,%s" % (f1, f2[1:])
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
217class XRF_Model:
218 """model for X-ray fluorescence data
220 consists of parameterized components for
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)
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)
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))
300 if center is None:
301 center = self.xray_energy
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)
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)
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)
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
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)
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)
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)
344 def clear_background(self):
345 self.bgr = None
346 self.params.pop('background_amp')
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
364 def calc_escape_scale(self, energy, thickness=None):
365 """
366 calculate energy dependence of escape effect
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
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
380 mu_input = material_mu(det.material, 1000*energy)
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
386 def det_sigma(self, energy, noise=0):
387 """ energy width of peak """
388 return np.sqrt(self.efano*energy + noise**2)
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 = {}
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']
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
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'])
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
427 self.atten = self.det_atten * self.filt_atten
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)
438 self.mu_energies.append(1000*self.xray_energy)
439 self.mu_energies = np.array(self.mu_energies)
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
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)
464 self.comps[elem.symbol] = comp
465 self.eigenvalues[elem.symbol] = amp
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
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
493 # calculate total spectrum
494 total = 0. * energy
495 for comp in self.comps.values():
496 total += comp
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
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
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]
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
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))
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
546 if max(work_energy) > 250.0: # if input energies are in eV
547 work_energy /= 1000.0
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)
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
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
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)
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)
584 self.fit_report = fit_report(self.result, min_correl=0.5)
585 pars = self.result.params
586 self.fit_in_progress = False
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)
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()
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)
608 for attr in ('filename', 'label'):
609 setattr(out, 'mca' + attr, getattr(self.mca, attr, 'unknown'))
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))
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))
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})
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
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)
646 def __repr__(self):
647 return 'XRFFitResult(%r, filename=%r)' % (self.label, self.filename)
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))
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)
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)))
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))
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
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
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`]
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]
735 return xrf_prediction(weights, total)
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
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`]
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)
761 win = np.where(self.fit_window > 0)[0]
762 w0 = max(0, win[0]-100)
763 w1 = min(nchan-1, win[-1]+100)
765 xfer = self.transfer_matrix[w0:w1, :]
766 win = self.fit_window[w0:w1]
767 result = np.zeros((ny, nx, ncomps), dtype='float32')
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
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
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())}
804def xrf_model(xray_energy=None, energy_min=1500, energy_max=None, use_bgr=False, **kws):
805 """create an XRF Peak
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)
814def xrf_fitresult(save_file=None):
815 """create an XRF Fit Result, possibly restoring from saved file
817 Returns:
818 ---------
819 an XRFFitResult instance
820 """
822 out = XRFFitResult()
823 if save_file is not None:
824 out.load(save_file)
825 return out