-
Notifications
You must be signed in to change notification settings - Fork 207
/
eels.py
1859 lines (1636 loc) · 75.4 KB
/
eels.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- coding: utf-8 -*-
# Copyright 2007-2023 The HyperSpy developers
#
# This file is part of HyperSpy.
#
# HyperSpy is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# HyperSpy is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with HyperSpy. If not, see <https://www.gnu.org/licenses/#GPL>.
import numbers
import logging
import numpy as np
import dask.array as da
import traits.api as t
from scipy import constants
from prettytable import PrettyTable
from hyperspy.signal import BaseSetMetadataItems, BaseSignal
from hyperspy._signals.signal1d import (Signal1D, LazySignal1D)
from hyperspy.signal_tools import EdgesRange
from hyperspy.misc.elements import elements as elements_db
from hyperspy.misc.label_position import SpectrumLabelPosition
import hyperspy.axes
from hyperspy.defaults_parser import preferences
from hyperspy.components1d import PowerLaw
from hyperspy.misc.utils import display, isiterable, underline
from hyperspy.misc.math_tools import optimal_fft_size
from hyperspy.misc.eels.tools import get_edges_near_energy
from hyperspy.misc.eels.electron_inelastic_mean_free_path import iMFP_Iakoubovskii, iMFP_angular_correction
from hyperspy.ui_registry import add_gui_method, DISPLAY_DT, TOOLKIT_DT
from hyperspy.docstrings.signal1d import (
CROP_PARAMETER_DOC,
SPIKES_DIAGNOSIS_DOCSTRING,
MASK_ZERO_LOSS_PEAK_WIDTH,
SPIKES_REMOVAL_TOOL_DOCSTRING,
)
from hyperspy.docstrings.signal import (
SHOW_PROGRESSBAR_ARG,
NUM_WORKERS_ARG,
SIGNAL_MASK_ARG,
NAVIGATION_MASK_ARG,
LAZYSIGNAL_DOC,
)
from hyperspy.docstrings.model import EELSMODEL_PARAMETERS
_logger = logging.getLogger(__name__)
@add_gui_method(toolkey="hyperspy.microscope_parameters_EELS")
class EELSTEMParametersUI(BaseSetMetadataItems):
convergence_angle = t.Float(t.Undefined,
label='Convergence semi-angle (mrad)')
beam_energy = t.Float(t.Undefined,
label='Beam energy (keV)')
collection_angle = t.Float(t.Undefined,
label='Collection semi-angle (mrad)')
mapping = {
'Acquisition_instrument.TEM.convergence_angle':
'convergence_angle',
'Acquisition_instrument.TEM.beam_energy':
'beam_energy',
'Acquisition_instrument.TEM.Detector.EELS.collection_angle':
'collection_angle',
}
class EELSSpectrum(Signal1D):
"""Signal class for EELS spectra."""
_signal_type = "EELS"
_alias_signal_types = ["TEM EELS"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Attributes defaults
self.subshells = set()
self.elements = set()
self.edges = list()
if hasattr(self.metadata, 'Sample') and \
hasattr(self.metadata.Sample, 'elements'):
self.add_elements(self.metadata.Sample.elements)
self.axes_manager.signal_axes[0].is_binned = True
self._edge_markers = {}
def add_elements(self, elements, include_pre_edges=False):
"""Declare the elemental composition of the sample.
The ionisation edges of the elements present in the current
energy range will be added automatically.
Parameters
----------
elements : tuple of strings
The symbol of the elements. Note this input must always be
in the form of a tuple. Meaning: add_elements(('C',)) will
work, while add_elements(('C')) will NOT work.
include_pre_edges : bool
If True, the ionization edges with an onset below the lower
energy limit of the SI will be included
Examples
--------
>>> s = hs.signals.EELSSpectrum(np.arange(1024))
>>> s.add_elements(('C', 'O'))
Raises
------
ValueError
"""
if not isiterable(elements) or isinstance(elements, str):
raise ValueError(
"Input must be in the form of a tuple. For example, "
"if `s` is the variable containing this EELS spectrum:\n "
">>> s.add_elements(('C',))\n"
"See the docstring for more information.")
for element in elements:
if isinstance(element, bytes):
element = element.decode()
if element in elements_db:
self.elements.add(element)
else:
raise ValueError(
"%s is not a valid symbol of a chemical element"
% element)
if not hasattr(self.metadata, 'Sample'):
self.metadata.add_node('Sample')
self.metadata.Sample.elements = list(self.elements)
if self.elements:
self.generate_subshells(include_pre_edges)
def generate_subshells(self, include_pre_edges=False):
"""Calculate the subshells for the current energy range for the
elements present in self.elements
Parameters
----------
include_pre_edges : bool
If True, the ionization edges with an onset below the lower
energy limit of the SI will be included
"""
Eaxis = self.axes_manager.signal_axes[0].axis
if not include_pre_edges:
start_energy = Eaxis[0]
else:
start_energy = 0.
end_energy = Eaxis[-1]
for element in self.elements:
e_shells = list()
for shell in elements_db[element][
'Atomic_properties']['Binding_energies']:
if shell[-1] != 'a':
energy = (elements_db[element]['Atomic_properties']
['Binding_energies'][shell]['onset_energy (eV)'])
if start_energy <= energy <= end_energy:
subshell = '%s_%s' % (element, shell)
if subshell not in self.subshells:
self.subshells.add(
'%s_%s' % (element, shell))
e_shells.append(subshell)
def edges_at_energy(self, energy='interactive', width=10, only_major=False,
order='closest', display=True, toolkit=None):
"""Show EELS edges according to an energy range selected from the
spectrum or within a provided energy window
Parameters
----------
energy : 'interactive' or float
If it is 'interactive', a table with edges are shown and it depends
on the energy range selected in the spectrum. If it is a float, a
table with edges are shown and it depends on the energy window
defined by energy +/- (width/2). The default is 'interactive'.
width : float
Width of window, in eV, around energy in which to find nearby
energies, i.e. a value of 10 eV (the default) means to
search +/- 5 eV. The default is 10.
only_major : bool
Whether to show only the major edges. The default is False.
order : str
Sort the edges, if 'closest', return in the order of energy
difference, if 'ascending', return in ascending order, similarly
for 'descending'. The default is 'closest'.
Returns
-------
An interactive widget if energy is 'interactive', or a html-format
table or ASCII table, depends on the environment.
"""
if energy == 'interactive':
er = EdgesRange(self)
return er.gui(display=display, toolkit=toolkit)
else:
self.print_edges_near_energy(energy, width, only_major, order)
@staticmethod
def print_edges_near_energy(energy=None, width=10, only_major=False,
order='closest', edges=None):
"""Find and print a table of edges near a given energy that are within
the given energy window.
Parameters
----------
energy : float
Energy to search, in eV
width : float
Width of window, in eV, around energy in which to find nearby
energies, i.e. a value of 10 eV (the default) means to
search +/- 5 eV. The default is 10.
only_major : bool
Whether to show only the major edges. The default is False.
order : str
Sort the edges, if 'closest', return in the order of energy
difference, if 'ascending', return in ascending order, similarly
for 'descending'. The default is 'closest'.
edges : iterable
A sequence of edges, if provided, it overrides energy, width,
only_major and order.
Returns
-------
A PrettyText object where its representation is ASCII in terminal and
html-formatted in Jupyter notebook
"""
if edges is None and energy is not None:
edges = get_edges_near_energy(energy=energy, width=width,
only_major=only_major, order=order)
elif edges is None and energy is None:
raise ValueError('Either energy or edges should be provided.')
table = PrettyTable()
table.field_names = [
'edge',
'onset energy (eV)',
'relevance',
'description']
for edge in edges:
element, shell = edge.split('_')
shell_dict = elements_db[element]['Atomic_properties'][
'Binding_energies'][shell]
onset = shell_dict['onset_energy (eV)']
relevance = shell_dict['relevance']
threshold = shell_dict['threshold']
edge_ = shell_dict['edge']
description = threshold + '. '*(threshold !='' and edge_ !='') + edge_
table.add_row([edge, onset, relevance, description])
# this ensures the html version try its best to mimick the ASCII one
table.format = True
display(table)
def estimate_zero_loss_peak_centre(self, mask=None):
"""Estimate the position of the zero-loss peak.
This function provides just a coarse estimation of the position
of the zero-loss peak centre by computing the position of the maximum
of the spectra. For subpixel accuracy use `estimate_shift1D`.
Parameters
----------
mask : Signal1D of bool data type or bool array
It must have signal_dimension = 0 and navigation_shape equal to the
navigation shape of the current signal. Where mask is True the
shift is not computed and set to nan.
Returns
-------
zlpc : Signal1D subclass
The estimated position of the maximum of the ZLP peak.
Notes
-----
This function only works when the zero-loss peak is the most
intense feature in the spectrum. If it is not in most cases
the spectrum can be cropped to meet this criterion.
Alternatively use `estimate_shift1D`.
See Also
--------
estimate_shift1D, align_zero_loss_peak
"""
self._check_signal_dimension_equals_one()
self._check_navigation_mask(mask)
if isinstance(mask, BaseSignal):
mask = mask.data
zlpc = self.valuemax(-1)
if mask is not None:
zlpc.data = np.where(mask, np.nan, zlpc.data)
zlpc.set_signal_type("")
title = self.metadata.General.title
zlpc.metadata.General.title = "ZLP(%s)" % title
return zlpc
def align_zero_loss_peak(
self,
calibrate=True,
also_align=[],
print_stats=True,
subpixel=True,
mask=None,
signal_range=None,
show_progressbar=None,
crop=True,
**kwargs):
"""Align the zero-loss peak.
This function first aligns the spectra using the result of
`estimate_zero_loss_peak_centre` and afterward, if subpixel is True,
proceeds to align with subpixel accuracy using `align1D`. The offset
is automatically correct if `calibrate` is True.
Parameters
----------
calibrate : bool
If True, set the offset of the spectral axis so that the
zero-loss peak is at position zero.
also_align : list of signals
A list containing other spectra of identical dimensions to
align using the shifts applied to the current spectrum.
If `calibrate` is True, the calibration is also applied to
the spectra in the list.
print_stats : bool
If True, print summary statistics of the ZLP maximum before
the alignment.
subpixel : bool
If True, perform the alignment with subpixel accuracy
using cross-correlation.
mask : Signal1D of bool data type or bool array.
It must have signal_dimension = 0 and navigation_shape equal to
the shape of the current signal. Where mask is True the shift is
not computed and set to nan.
signal_range : tuple of integers, tuple of floats. Optional
Will only search for the ZLP within the signal_range. If given
in integers, the range will be in index values. If given floats,
the range will be in spectrum values. Useful if there are features
in the spectrum which are more intense than the ZLP.
Default is searching in the whole signal. Note that ROIs can be used
in place of a tuple.
%s
%s
Raises
------
NotImplementedError
If the signal axis is a non-uniform axis.
Examples
--------
>>> s_ll = hs.signals.EELSSpectrum(np.zeros(1000))
>>> s_ll.data[100] = 100
>>> s_ll.align_zero_loss_peak()
Aligning both the lowloss signal and another signal
>>> s = hs.signals.EELSSpectrum(np.range(1000))
>>> s_ll.align_zero_loss_peak(also_align=[s])
Aligning within a narrow range of the lowloss signal
>>> s_ll.align_zero_loss_peak(signal_range=(-10.,10.))
See Also
--------
estimate_zero_loss_peak_centre, align1D, estimate_shift1D.
Notes
-----
Any extra keyword arguments are passed to `align1D`. For
more information read its docstring.
"""
def substract_from_offset(value, signals):
# Test that axes is uniform
if not self.axes_manager[-1].is_uniform:
raise NotImplementedError("Support for EELS signals with "
"non-uniform signal axes is not yet implemented.")
if isinstance(value, da.Array):
value = value.compute()
for signal in signals:
signal.axes_manager[-1].offset -= value
signal.events.data_changed.trigger(signal)
def estimate_zero_loss_peak_centre(s, mask, signal_range):
if signal_range:
zlpc = s.isig[signal_range[0]:signal_range[1]].\
estimate_zero_loss_peak_centre(mask=mask)
else:
zlpc = s.estimate_zero_loss_peak_centre(mask=mask)
return zlpc
zlpc = estimate_zero_loss_peak_centre(
self, mask=mask, signal_range=signal_range)
mean_ = np.nanmean(zlpc.data)
if print_stats is True:
print(underline("Initial ZLP position statistics"))
zlpc.print_summary_statistics()
for signal in also_align + [self]:
shift_array = -zlpc.data + mean_
if zlpc._lazy:
# We must compute right now because otherwise any changes to the
# axes_manager of the signal later in the workflow may result in
# a wrong shift_array
shift_array = shift_array.compute()
signal.shift1D(
shift_array, crop=crop, show_progressbar=show_progressbar)
if calibrate is True:
zlpc = estimate_zero_loss_peak_centre(
self, mask=mask, signal_range=signal_range)
substract_from_offset(np.nanmean(zlpc.data),
also_align + [self])
if subpixel is False:
return
left, right = -3., 3.
if calibrate is False:
left += mean_
right += mean_
left = (left if left > self.axes_manager[-1].axis[0]
else self.axes_manager[-1].axis[0])
right = (right if right < self.axes_manager[-1].axis[-1]
else self.axes_manager[-1].axis[-1])
if self.axes_manager.navigation_size > 1:
self.align1D(
left,
right,
also_align=also_align,
show_progressbar=show_progressbar,
mask=mask,
crop=crop,
**kwargs)
if calibrate is True:
zlpc = estimate_zero_loss_peak_centre(
self, mask=mask, signal_range=signal_range)
substract_from_offset(np.nanmean(zlpc.data),
also_align + [self])
align_zero_loss_peak.__doc__ %= (SHOW_PROGRESSBAR_ARG, CROP_PARAMETER_DOC)
def get_zero_loss_peak_mask(self, zero_loss_peak_mask_width=5.0,
signal_mask=None):
"""Return boolean array with True value at the position of the zero
loss peak. This mask can be used to restrict operation to the signal
locations not marked as True (masked).
Parameters
----------
zero_loss_peak_mask_width: float
Width of the zero loss peak mask.
%s
Returns
-------
bool array
"""
zlpc = self.estimate_zero_loss_peak_centre()
(signal_axis, ) = self.axes_manager[self.axes_manager.signal_axes]
axis = signal_axis.axis
mini_value = zlpc.data.mean() - zero_loss_peak_mask_width / 2
maxi_value = zlpc.data.mean() + zero_loss_peak_mask_width / 2
mask = np.logical_and(mini_value <= axis, axis <= maxi_value)
if signal_mask is not None:
signal_mask = np.logical_or(mask, signal_mask)
else:
signal_mask = mask
return signal_mask
get_zero_loss_peak_mask.__doc__ %= (SIGNAL_MASK_ARG)
def spikes_diagnosis(self, signal_mask=None, navigation_mask=None,
zero_loss_peak_mask_width=None, **kwargs):
if zero_loss_peak_mask_width is not None:
signal_mask = self.get_zero_loss_peak_mask(zero_loss_peak_mask_width,
signal_mask)
super().spikes_diagnosis(signal_mask=signal_mask, navigation_mask=None,
**kwargs)
spikes_diagnosis.__doc__ = SPIKES_DIAGNOSIS_DOCSTRING % MASK_ZERO_LOSS_PEAK_WIDTH
def spikes_removal_tool(self, signal_mask=None,
navigation_mask=None,
threshold='auto',
zero_loss_peak_mask_width=None,
interactive=True,
display=True,
toolkit=None):
if zero_loss_peak_mask_width is not None:
axis = self.axes_manager.signal_axes[0].axis
# check the zero_loss is in the signal
if (axis[0] - zero_loss_peak_mask_width / 2 > 0 or
axis[-1] + zero_loss_peak_mask_width / 2 < 0):
raise ValueError("The zero loss peaks isn't in the energy range.")
signal_mask = self.get_zero_loss_peak_mask(zero_loss_peak_mask_width,
signal_mask)
super().spikes_removal_tool(signal_mask=signal_mask,
navigation_mask=navigation_mask,
threshold=threshold,
interactive=interactive,
display=display, toolkit=toolkit)
spikes_removal_tool.__doc__ = SPIKES_REMOVAL_TOOL_DOCSTRING % (
SIGNAL_MASK_ARG, NAVIGATION_MASK_ARG, MASK_ZERO_LOSS_PEAK_WIDTH, DISPLAY_DT, TOOLKIT_DT,)
def estimate_elastic_scattering_intensity(
self, threshold, show_progressbar=None):
"""Rough estimation of the elastic scattering intensity by
truncation of a EELS low-loss spectrum.
Parameters
----------
threshold : {Signal1D, float, int}
Truncation energy to estimate the intensity of the elastic
scattering. The threshold can be provided as a signal of the same
dimension as the input spectrum navigation space containing the
threshold value in the energy units. Alternatively a constant
threshold can be specified in energy/index units by passing
float/int.
%s
Returns
-------
I0: Signal1D
The elastic scattering intensity.
See Also
--------
estimate_elastic_scattering_threshold
"""
# TODO: Write units tests
self._check_signal_dimension_equals_one()
if show_progressbar is None:
show_progressbar = preferences.General.show_progressbar
if isinstance(threshold, numbers.Number):
I0 = self.isig[:threshold].integrate1D(-1)
else:
ax = self.axes_manager.signal_axes[0]
# I0 = self._get_navigation_signal()
# I0 = I0.transpose(signal_axes=[])
threshold = threshold.transpose(signal_axes=[])
binned = ax.is_binned
def estimating_function(data, threshold=None):
if np.isnan(threshold):
return np.nan
else:
# the object is just an array, so have to reimplement
# integrate1D. However can make certain assumptions, for
# example 1D signal and pretty much always binned. Should
# probably at some point be joint
ind = ax.value2index(threshold)
data = data[:ind]
if binned:
return data.sum()
else:
from scipy.integrate import simps
axis = ax.axis[:ind]
return simps(y=data, x=axis)
I0 = self.map(estimating_function, threshold=threshold,
ragged=False, show_progressbar=show_progressbar,
inplace=False)
I0.metadata.General.title = (
self.metadata.General.title + ' elastic intensity')
I0.set_signal_type("")
if self.tmp_parameters.has_item('filename'):
I0.tmp_parameters.filename = (
self.tmp_parameters.filename +
'_elastic_intensity')
I0.tmp_parameters.folder = self.tmp_parameters.folder
I0.tmp_parameters.extension = \
self.tmp_parameters.extension
return I0
estimate_elastic_scattering_intensity.__doc__ %= SHOW_PROGRESSBAR_ARG
def estimate_elastic_scattering_threshold(self,
window=10.,
tol=None,
window_length=5,
polynomial_order=3,
start=1.):
"""Calculate the first inflexion point of the spectrum derivative
within a window.
This method assumes that the zero-loss peak is located at position zero
in all the spectra. Currently it looks for an inflexion point, that can
be a local maximum or minimum. Therefore, to estimate the elastic
scattering threshold `start` + `window` must be less than the first
maximum for all spectra (often the bulk plasmon maximum). If there is
more than one inflexion point in energy the window it selects the
smoother one what, often, but not always, is a good choice in this
case.
Parameters
----------
window : {None, float}
If None, the search for the local inflexion point is performed
using the full energy range. A positive float will restrict
the search to the (0,window] energy window, where window is given
in the axis units. If no inflexion point is found in this
spectral range the window value is returned instead.
tol : {None, float}
The threshold tolerance for the derivative. If "auto" it is
automatically calculated as the minimum value that guarantees
finding an inflexion point in all the spectra in given energy
range.
window_length : int
If non zero performs order three Savitzky-Golay smoothing
to the data to avoid falling in local minima caused by
the noise. It must be an odd integer.
polynomial_order : int
Savitzky-Golay filter polynomial order.
start : float
Position from the zero-loss peak centre from where to start
looking for the inflexion point.
Returns
-------
threshold : Signal1D
A Signal1D of the same dimension as the input spectrum
navigation space containing the estimated threshold. Where the
threshold couldn't be estimated the value is set to nan.
See Also
--------
estimate_elastic_scattering_intensity,align_zero_loss_peak,
find_peaks1D_ohaver, fourier_ratio_deconvolution.
Notes
-----
The main purpose of this method is to be used as input for
`estimate_elastic_scattering_intensity`. Indeed, for currently
achievable energy resolutions, there is not such a thing as a elastic
scattering threshold. Therefore, please be aware of the limitations of
this method when using it.
"""
self._check_signal_dimension_equals_one()
# Create threshold with the same shape as the navigation dims.
threshold = self._get_navigation_signal().transpose(signal_axes=0)
# Progress Bar
axis = self.axes_manager.signal_axes[0]
min_index, max_index = axis.value_range_to_indices(start,
start + window)
if max_index < min_index + 10:
raise ValueError("Please select a bigger window")
s = self.isig[min_index:max_index].deepcopy()
if window_length:
s.smooth_savitzky_golay(polynomial_order=polynomial_order,
window_length=window_length,
differential_order=1)
else:
s = s.derivative(-1)
if tol is None:
tol = np.max(abs(s.data).min(axis.index_in_array))
saxis = s.axes_manager[-1]
inflexion = (abs(s.data) <= tol).argmax(saxis.index_in_array)
if isinstance(inflexion, da.Array):
inflexion = inflexion.compute()
threshold.data[:] = saxis.index2value(inflexion)
if isinstance(inflexion, np.ndarray):
threshold.data[inflexion == 0] = np.nan
else: # Single spectrum
if inflexion == 0:
threshold.data[:] = np.nan
del s
if np.isnan(threshold.data).any():
_logger.warning(
"No inflexion point could be found in some positions "
"that have been marked with nans.")
# Create spectrum image, stop and return value
threshold.metadata.General.title = (
self.metadata.General.title +
' elastic scattering threshold')
if self.tmp_parameters.has_item('filename'):
threshold.tmp_parameters.filename = (
self.tmp_parameters.filename +
'_elastic_scattering_threshold')
threshold.tmp_parameters.folder = self.tmp_parameters.folder
threshold.tmp_parameters.extension = \
self.tmp_parameters.extension
threshold.set_signal_type("")
return threshold
def estimate_thickness(self,
threshold=None,
zlp=None,
density=None,
mean_free_path=None,):
"""Estimates the thickness (relative and absolute)
of a sample using the log-ratio method.
The current EELS spectrum must be a low-loss spectrum containing
the zero-loss peak. The hyperspectrum must be well calibrated
and aligned. To obtain the thickness relative to the mean free path
don't set the `density` and the `mean_free_path`.
Parameters
----------
threshold : {BaseSignal, float}, optional
If the zero-loss-peak is not provided, use this energy threshold
to roughly estimate its intensity by truncation.
If the threshold is constant across the dataset use a float. Otherwise,
provide a signal of
the same dimension as the input spectrum navigation space
containing the threshold value in the energy units.
zlp : BaseSignal, optional
If not None the zero-loss peak intensity is calculated from the ZLP
spectrum supplied by integration.
mean_free_path : float, optional
The mean free path of the material in nanometers.
If not provided, the thickness
is given relative to the mean free path.
density : float, optional
The density of the material in g/cm**3. This is used to estimate the mean
free path when the mean free path is not known and to perform the
angular corrections.
Returns
-------
s : BaseSignal
The thickness relative to the MFP. It returns a Signal1D,
Signal2D or a BaseSignal, depending on the current navigation
dimensions.
Notes
-----
For details see Egerton, R. Electron Energy-Loss Spectroscopy in the Electron
Microscope. Springer-Verlag, 2011.
"""
axis = self.axes_manager.signal_axes[0]
total_intensity = self.integrate1D(axis.index_in_array).data
if threshold is None and zlp is None:
raise ValueError("Please provide one of the following keywords: "
"`threshold`, `zlp`")
if zlp is not None:
I0 = zlp.integrate1D(axis.index_in_array).data
else:
I0 = self.estimate_elastic_scattering_intensity(
threshold=threshold,).data
t_over_lambda = np.log(total_intensity / I0)
if density is not None:
if self._are_microscope_parameters_missing():
raise RuntimeError(
"Some microscope parameters are missing. Please use the "
"`set_microscope_parameters()` method to set them. "
"If you don't know them, don't set the `density` keyword."
)
else:
md = self.metadata.Acquisition_instrument.TEM
t_over_lambda *= iMFP_angular_correction(
beam_energy=md.beam_energy,
alpha=md.convergence_angle,
beta=md.Detector.EELS.collection_angle,
density=density,
)
if mean_free_path is None:
mean_free_path = iMFP_Iakoubovskii(
electron_energy=self.metadata.Acquisition_instrument.TEM.beam_energy,
density=density)
_logger.info(f"The estimated iMFP is {mean_free_path} nm")
else:
_logger.warning(
"Computing the thickness without taking into account the effect of "
"the limited collection angle, what usually leads to underestimating "
"the thickness. To perform the angular corrections you must provide "
"the density of the material.")
s = self._get_navigation_signal(data=t_over_lambda)
if mean_free_path is not None:
s.data *= mean_free_path
s.metadata.General.title = (
self.metadata.General.title +
' thickness (nm)')
s.metadata.Signal.quantity = "thickness (nm)"
else:
_logger.warning(
"Computing the relative thickness. To compute the absolute "
"thickness provide the `mean_free_path` and/or the `density`")
s.metadata.General.title = (self.metadata.General.title +
' $\\frac{t}{\\lambda}$')
s.metadata.Signal.quantity = "$\\frac{t}{\\lambda}$"
if self.tmp_parameters.has_item('filename'):
s.tmp_parameters.filename = (
self.tmp_parameters.filename +
'_thickness')
s.tmp_parameters.folder = self.tmp_parameters.folder
s.tmp_parameters.extension = \
self.tmp_parameters.extension
s = s.transpose(signal_axes=[])
s.set_signal_type("")
return s
def fourier_log_deconvolution(self,
zlp,
add_zlp=False,
crop=False):
"""Performs fourier-log deconvolution.
Parameters
----------
zlp : EELSSpectrum
The corresponding zero-loss peak.
add_zlp : bool
If True, adds the ZLP to the deconvolved spectrum
crop : bool
If True crop the spectrum to leave out the channels that
have been modified to decay smoothly to zero at the sides
of the spectrum.
Returns
-------
An EELSSpectrum containing the current data deconvolved.
Raises
------
NotImplementedError
If the signal axis is a non-uniform axis.
Notes
-----
For details see: Egerton, R. Electron Energy-Loss
Spectroscopy in the Electron Microscope. Springer-Verlag, 2011.
"""
self._check_signal_dimension_equals_one()
if not self.axes_manager.signal_axes[0].is_uniform:
raise NotImplementedError(
"This operation is not yet implemented for non-uniform energy axes")
s = self.deepcopy()
zlp_size = zlp.axes_manager.signal_axes[0].size
self_size = self.axes_manager.signal_axes[0].size
tapped_channels = s.hanning_taper()
# Conservative new size to solve the wrap-around problem
size = zlp_size + self_size - 1
# Calculate optimal FFT padding for performance
complex_result = (zlp.data.dtype.kind == 'c' or s.data.dtype.kind == 'c')
size = optimal_fft_size(size, not complex_result)
axis = self.axes_manager.signal_axes[0]
z = np.fft.rfft(zlp.data, n=size, axis=axis.index_in_array)
j = np.fft.rfft(s.data, n=size, axis=axis.index_in_array)
if self._lazy or zlp._lazy:
j1 = z * da.log(j / z).map_blocks(np.nan_to_num)
else:
j1 = z * np.nan_to_num(np.log(j / z))
sdata = np.fft.irfft(j1, axis=axis.index_in_array)
s.data = sdata[s.axes_manager._get_data_slice(
[(axis.index_in_array, slice(None, self_size)), ])]
if add_zlp is True:
if self_size >= zlp_size:
if self._lazy:
_slices_before = s.axes_manager._get_data_slice(
[(axis.index_in_array, slice(None, zlp_size)), ])
_slices_after = s.axes_manager._get_data_slice(
[(axis.index_in_array, slice(zlp_size, None)), ])
s.data = da.stack((s.data[_slices_before] + zlp.data,
s.data[_slices_after]),
axis=axis.index_in_array)
else:
s.data[s.axes_manager._get_data_slice(
[(axis.index_in_array, slice(None, zlp_size)), ])
] += zlp.data
else:
s.data += zlp.data[s.axes_manager._get_data_slice(
[(axis.index_in_array, slice(None, self_size)), ])]
s.metadata.General.title = (s.metadata.General.title +
' after Fourier-log deconvolution')
if s.tmp_parameters.has_item('filename'):
s.tmp_parameters.filename = (
self.tmp_parameters.filename +
'_after_fourier_log_deconvolution')
if crop is True:
s.crop(axis.index_in_axes_manager,
None, int(-tapped_channels))
return s
def fourier_ratio_deconvolution(self, ll,
fwhm=None,
threshold=None,
extrapolate_lowloss=True,
extrapolate_coreloss=True):
"""Performs Fourier-ratio deconvolution.
The core-loss should have the background removed. To reduce the noise
amplification the result is convolved with a Gaussian function.
Parameters
----------
ll: EELSSpectrum
The corresponding low-loss (ll) EELSSpectrum.
fwhm : float or None
Full-width half-maximum of the Gaussian function by which
the result of the deconvolution is convolved. It can be
used to select the final SNR and spectral resolution. If
None, the FWHM of the zero-loss peak of the low-loss is
estimated and used.
threshold : {None, float}
Truncation energy to estimate the intensity of the
elastic scattering. If None the threshold is taken as the
first minimum after the ZLP centre.
extrapolate_lowloss, extrapolate_coreloss : bool
If True the signals are extrapolated using a power law,
Raises
------
NotImplementedError
If the signal axis is a non-uniform axis.
Notes
-----
For details see: Egerton, R. Electron Energy-Loss
Spectroscopy in the Electron Microscope. Springer-Verlag, 2011.
"""
self._check_signal_dimension_equals_one()
if not self.axes_manager.signal_axes[0].is_uniform:
raise NotImplementedError(
"This operation is not yet implemented for non-uniform energy axes.")
if not ll.axes_manager.signal_axes[0].is_uniform:
raise NotImplementedError(
"The low-loss energy axis is non-uniform. "
"This operation is not yet implemented for non-uniform energy axes")
orig_cl_size = self.axes_manager.signal_axes[0].size
if threshold is None:
threshold = ll.estimate_elastic_scattering_threshold()
if extrapolate_coreloss is True:
cl = self.power_law_extrapolation(
window_size=20,
extrapolation_size=100)
else:
cl = self.deepcopy()
if extrapolate_lowloss is True:
ll = ll.power_law_extrapolation(
window_size=100,
extrapolation_size=100)
else:
ll = ll.deepcopy()
ll.hanning_taper()
cl.hanning_taper()
ll_size = ll.axes_manager.signal_axes[0].size
cl_size = self.axes_manager.signal_axes[0].size
# Conservative new size to solve the wrap-around problem
size = ll_size + cl_size - 1
# Calculate the optimal FFT size
size = optimal_fft_size(size)
axis = ll.axes_manager.signal_axes[0]
if fwhm is None:
fwhm = float(ll.get_current_signal().estimate_peak_width()())
_logger.info("FWHM = %1.2f" % fwhm)
I0 = ll.estimate_elastic_scattering_intensity(threshold=threshold)
I0 = I0.data