xarpes 0.4.0__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
xarpes/bandmap.py ADDED
@@ -0,0 +1,829 @@
1
+ # Copyright (C) 2025 xARPES Developers
2
+ # This program is free software under the terms of the GNU GPLv3 license.
3
+
4
+ # get_ax_fig_plt and add_fig_kwargs originate from pymatgen/util/plotting.py.
5
+ # Copyright (C) 2011-2024 Shyue Ping Ong and the pymatgen Development Team
6
+ # Pymatgen is released under the MIT License.
7
+
8
+ # See also abipy/tools/plotting.py.
9
+ # Copyright (C) 2021 Matteo Giantomassi and the AbiPy Group
10
+ # AbiPy is free software under the terms of the GNU GPLv2 license.
11
+
12
+ """File containing the band map class."""
13
+
14
+ import numpy as np
15
+ from igor2 import binarywave
16
+ from .plotting import get_ax_fig_plt, add_fig_kwargs
17
+ from .functions import fit_leastsq, extend_function
18
+ from .distributions import FermiDirac, Linear
19
+ from .constants import PREF
20
+
21
+ class BandMap:
22
+ r"""
23
+ Class for the band map from the ARPES experiment.
24
+
25
+ Construction
26
+ ------------
27
+ Prefer the alternate constructors:
28
+
29
+ - BandMap._from_ibw_file(path, ...)
30
+ - BandMap._from_np_arrays(intensities=..., angles=..., ekin=... or enel=...)
31
+
32
+ The __init__ takes *canonical* arrays (no file I/O).
33
+ """
34
+
35
+ @classmethod
36
+ def from_ibw_file(cls, datafile, transpose=False, flip_ekin=False,
37
+ flip_angles=False, **kwargs):
38
+ r"""Construct BandMap from an IGOR binary wave (.ibw)."""
39
+ data = binarywave.load(datafile)
40
+ intensities = data['wave']['wData']
41
+
42
+ fnum, anum = data['wave']['wave_header']['nDim'][0:2]
43
+ fstp, astp = data['wave']['wave_header']['sfA'][0:2]
44
+ fmin, amin = data['wave']['wave_header']['sfB'][0:2]
45
+
46
+ if intensities.shape != (fnum, anum):
47
+ raise ValueError('nDim and shape of wData do not match.')
48
+
49
+ if transpose:
50
+ intensities = intensities.T
51
+ fnum, anum = anum, fnum
52
+ fstp, astp = astp, fstp
53
+ fmin, amin = amin, fmin
54
+
55
+ if flip_ekin:
56
+ intensities = intensities[::-1, :]
57
+
58
+ if flip_angles:
59
+ intensities = intensities[:, ::-1]
60
+
61
+ angles = np.linspace(amin, amin + (anum - 1) * astp, anum)
62
+ ekin = np.linspace(fmin, fmin + (fnum - 1) * fstp, fnum)
63
+
64
+ return cls(intensities=intensities, angles=angles, ekin=ekin, **kwargs)
65
+
66
+ @classmethod
67
+ def from_np_arrays(cls, intensities=None, angles=None, ekin=None, enel=None,
68
+ **kwargs):
69
+ r"""Construct BandMap from NumPy arrays."""
70
+ if intensities is None or angles is None:
71
+ raise ValueError('Please provide intensities and angles.')
72
+ if (ekin is None) == (enel is None):
73
+ raise ValueError('Provide exactly one of ekin or enel.')
74
+ return cls(intensities=intensities, angles=angles, ekin=ekin, enel=enel,
75
+ **kwargs)
76
+
77
+ def __init__(self, intensities=None, angles=None, ekin=None, enel=None,
78
+ energy_resolution=None, angle_resolution=None, temperature=None,
79
+ hnuminPhi=None, hnuminPhi_std=None):
80
+
81
+ # --- Required arrays ------------------------------------------------
82
+ if intensities is None:
83
+ raise ValueError('Please provide intensities.')
84
+ if angles is None:
85
+ raise ValueError('Please provide angles.')
86
+
87
+ self.intensities = intensities
88
+ self.angles = angles
89
+
90
+ # --- Initialize energy axes (raw slots) ----------------------------
91
+ self._ekin = None
92
+ self._enel = None
93
+
94
+ # Apply user overrides or file ekin
95
+ if ekin is not None and enel is not None:
96
+ raise ValueError('Provide only one of ekin or enel, not both.')
97
+
98
+ if ekin is not None:
99
+ self._ekin = ekin
100
+ elif enel is not None:
101
+ self._enel = enel
102
+ elif file_ekin is not None:
103
+ self._ekin = file_ekin
104
+ else:
105
+ raise ValueError('Please provide datafile, ekin, or enel.')
106
+
107
+ # Scalars / metadata
108
+ self.energy_resolution = energy_resolution
109
+ self.angle_resolution = angle_resolution
110
+ self.temperature = temperature
111
+
112
+ # Work-function combo and its std
113
+ self._hnuminPhi = None
114
+ self._hnuminPhi_std = None
115
+ self.hnuminPhi = hnuminPhi
116
+ self.hnuminPhi_std = hnuminPhi_std
117
+
118
+ # --- 1) Track which axis is authoritative --------------------------
119
+ self._ekin_explicit = ekin is not None or (file_ekin is not None
120
+ and enel is None)
121
+ self._enel_explicit = enel is not None
122
+
123
+ # --- 2) Derive missing axis if possible ----------------------------
124
+ if self._ekin is None and self._enel is not None \
125
+ and self._hnuminPhi is not None:
126
+ self._ekin = self._enel + self._hnuminPhi
127
+ if self._enel is None and self._ekin is not None \
128
+ and self._hnuminPhi is not None:
129
+ self._enel = self._ekin - self._hnuminPhi
130
+
131
+ # -------------------- Properties: data arrays ---------------------------
132
+ @property
133
+ def intensities(self):
134
+ return self._intensities
135
+
136
+ @intensities.setter
137
+ def intensities(self, x):
138
+ self._intensities = x
139
+
140
+ @property
141
+ def angles(self):
142
+ return self._angles
143
+
144
+ @angles.setter
145
+ def angles(self, x):
146
+ self._angles = x
147
+
148
+ # -------------------- 3) Resolution / temperature ----------------------
149
+ @property
150
+ def angle_resolution(self):
151
+ return self._angle_resolution
152
+
153
+ @angle_resolution.setter
154
+ def angle_resolution(self, x):
155
+ self._angle_resolution = x
156
+
157
+ @property
158
+ def energy_resolution(self):
159
+ return self._energy_resolution
160
+
161
+ @energy_resolution.setter
162
+ def energy_resolution(self, x):
163
+ self._energy_resolution = x
164
+
165
+ @property
166
+ def temperature(self):
167
+ return self._temperature
168
+
169
+ @temperature.setter
170
+ def temperature(self, x):
171
+ self._temperature = x
172
+
173
+ # -------------------- 4) Sync ekin / enel / hnuminPhi ------------------
174
+ @property
175
+ def ekin(self):
176
+ if self._ekin is None and self._enel is not None \
177
+ and self._hnuminPhi is not None:
178
+ return self._enel + self._hnuminPhi
179
+ return self._ekin
180
+
181
+ @ekin.setter
182
+ def ekin(self, x):
183
+ if getattr(self, "_enel_explicit", False):
184
+ raise AttributeError('enel is explicit; set hnuminPhi instead.')
185
+ self._ekin = x
186
+ self._ekin_explicit = True
187
+ if not getattr(self, "_enel_explicit", False) \
188
+ and self._hnuminPhi is not None and x is not None:
189
+ self._enel = x - self._hnuminPhi
190
+
191
+ @property
192
+ def enel(self):
193
+ if self._enel is None and self._ekin is not None \
194
+ and self._hnuminPhi is not None:
195
+ return self._ekin - self._hnuminPhi
196
+ return self._enel
197
+
198
+ @enel.setter
199
+ def enel(self, x):
200
+ if getattr(self, "_ekin_explicit", False):
201
+ raise AttributeError('ekin is explicit; set hnuminPhi instead.')
202
+ self._enel = x
203
+ self._enel_explicit = True
204
+ if not getattr(self, "_ekin_explicit", False) \
205
+ and self._hnuminPhi is not None and x is not None:
206
+ self._ekin = x + self._hnuminPhi
207
+
208
+ @property
209
+ def hnuminPhi(self):
210
+ r"""Returns the photon energy minus the work function in eV if it has
211
+ been set, either during instantiation, with the setter, or by fitting
212
+ the Fermi-Dirac distribution to the integrated weight.
213
+
214
+ Returns
215
+ -------
216
+ hnuminPhi : float, None
217
+ Kinetic energy minus the work function [eV]
218
+
219
+ """
220
+ return self._hnuminPhi
221
+
222
+ @hnuminPhi.setter
223
+ def hnuminPhi(self, x):
224
+ r"""TBD
225
+ """
226
+ self._hnuminPhi = x
227
+ # Re-derive the non-explicit axis if possible
228
+ if not getattr(self, "_ekin_explicit", False) \
229
+ and self._enel is not None and x is not None:
230
+ self._ekin = self._enel + x
231
+ if not getattr(self, "_enel_explicit", False) \
232
+ and self._ekin is not None and x is not None:
233
+ self._enel = self._ekin - x
234
+
235
+ @property
236
+ def hnuminPhi_std(self):
237
+ r"""Returns standard deviation of the photon energy minus the work
238
+ function in eV.
239
+
240
+ Returns
241
+ -------
242
+ hnuminPhi_std : float
243
+ Standard deviation of energy minus the work function [eV]
244
+
245
+ """
246
+ return self._hnuminPhi_std
247
+
248
+ @hnuminPhi_std.setter
249
+ def hnuminPhi_std(self, x):
250
+ r"""Manually sets the standard deviation of photon energy minus the
251
+ work function in eV.
252
+
253
+ Parameters
254
+ ----------
255
+ hnuminPhi_std : float
256
+ Standard deviation of energy minus the work function [eV]
257
+
258
+ """
259
+ self._hnuminPhi_std = x
260
+
261
+ def shift_angles(self, shift):
262
+ r"""
263
+ Shifts the angles by the specified amount in degrees. Used to shift
264
+ from the detector angle to the material angle.
265
+
266
+ Parameters
267
+ ----------
268
+ shift : float
269
+ Angular shift [degrees]
270
+
271
+ """
272
+ self.angles = self.angles + shift
273
+
274
+ def mdc_set(self, angle_min, angle_max, energy_value=None,
275
+ energy_range=None):
276
+ r"""Returns a set of MDCs. Documentation is to be further completed.
277
+
278
+ Parameters
279
+ ----------
280
+ angle_min : float
281
+ Minimum angle of integration interval [degrees]
282
+ angle_max : float
283
+ Maximum angle of integration interval [degrees]
284
+
285
+ Returns
286
+ -------
287
+ angle_range : ndarray
288
+ Array of size n containing the angular values
289
+ energy_range : ndarray
290
+ Array of size m containing the energy values
291
+ mdcs : ndarray
292
+ Array of size n x m containing the MDC intensities
293
+
294
+ """
295
+
296
+ if (energy_value is None and energy_range is None) or \
297
+ (energy_value is not None and energy_range is not None):
298
+ raise ValueError('Please provide either energy_value or ' +
299
+ 'energy_range.')
300
+
301
+ angle_min_index = np.abs(self.angles - angle_min).argmin()
302
+ angle_max_index = np.abs(self.angles - angle_max).argmin()
303
+ angle_range_out = self.angles[angle_min_index:angle_max_index + 1]
304
+
305
+ if energy_value is not None:
306
+ energy_index = np.abs(self.enel - energy_value).argmin()
307
+ enel_range_out = self.enel[energy_index]
308
+ mdcs = self.intensities[energy_index,
309
+ angle_min_index:angle_max_index + 1]
310
+
311
+ if energy_range:
312
+ energy_indices = np.where((self.enel >= np.min(energy_range))
313
+ & (self.enel <= np.max(energy_range))) \
314
+ [0]
315
+ enel_range_out = self.enel[energy_indices]
316
+ mdcs = self.intensities[energy_indices,
317
+ angle_min_index:angle_max_index + 1]
318
+
319
+ return mdcs, angle_range_out, self.angle_resolution, \
320
+ enel_range_out, self.hnuminPhi
321
+
322
+ @add_fig_kwargs
323
+ def plot(self, abscissa='momentum', ordinate='electron_energy',
324
+ self_energies=None, ax=None, markersize=None,
325
+ plot_dispersions='none', **kwargs):
326
+ r"""
327
+ Plot the band map. Optionally overlay a collection of self-energies,
328
+ e.g. a CreateSelfEnergies instance or any iterable of self-energy
329
+ objects. Self-energies are *not* stored internally; they are used
330
+ only for this plotting call.
331
+
332
+ When self-energies are present and ``abscissa='momentum'``, their
333
+ MDC maxima are overlaid with 95 % confidence intervals.
334
+
335
+ The `plot_dispersions` argument controls bare-band plotting:
336
+
337
+ - "full" : use the full momentum range of the map (default)
338
+ - "none" : do not plot bare dispersions
339
+ - "kink" : for each self-energy, use the min/max of its own
340
+ momentum range (typically its MDC maxima), with
341
+ `len(self.angles)` points.
342
+ - "domain" : for SpectralQuadratic, use only the left or right
343
+ domain relative to `center_wavevector`, based on the self-energy
344
+ attribute `side` ("left" / "right"); for other cases this behaves
345
+ as "full".
346
+ """
347
+ import warnings
348
+ from . import settings_parameters as xprs
349
+
350
+ plot_disp_mode = plot_dispersions
351
+ valid_disp_modes = ('full', 'none', 'kink', 'domain')
352
+ if plot_disp_mode not in valid_disp_modes:
353
+ raise ValueError(
354
+ f"Invalid plot_dispersions '{plot_disp_mode}'. "
355
+ f"Valid options: {valid_disp_modes}."
356
+ )
357
+
358
+ valid_abscissa = ('angle', 'momentum')
359
+ valid_ordinate = ('kinetic_energy', 'electron_energy')
360
+
361
+ if abscissa not in valid_abscissa:
362
+ raise ValueError(
363
+ f"Invalid abscissa '{abscissa}'. "
364
+ f"Valid options: {valid_abscissa}"
365
+ )
366
+ if ordinate not in valid_ordinate:
367
+ raise ValueError(
368
+ f"Invalid ordinate '{ordinate}'. "
369
+ f"Valid options: {valid_ordinate}"
370
+ )
371
+
372
+ if self_energies is not None:
373
+
374
+ # MDC maxima are defined in momentum space, not angle space
375
+ if abscissa == 'angle':
376
+ raise ValueError(
377
+ "MDC maxima cannot be plotted against angles; they are "
378
+ "defined in momentum space. Use abscissa='momentum' "
379
+ "when passing self-energies."
380
+ )
381
+
382
+ ax, fig, plt = get_ax_fig_plt(ax=ax)
383
+
384
+ Angl, Ekin = np.meshgrid(self.angles, self.ekin)
385
+
386
+ if abscissa == 'angle':
387
+ ax.set_xlabel('Angle ($\\degree$)')
388
+ if ordinate == 'kinetic_energy':
389
+ mesh = ax.pcolormesh(
390
+ Angl, Ekin, self.intensities,
391
+ shading='auto',
392
+ cmap=plt.get_cmap('bone').reversed())
393
+ ax.set_ylabel('$E_{\\mathrm{kin}}$ (eV)')
394
+ elif ordinate == 'electron_energy':
395
+ Enel = Ekin - self.hnuminPhi
396
+ mesh = ax.pcolormesh(
397
+ Angl, Enel, self.intensities,
398
+ shading='auto',
399
+ cmap=plt.get_cmap('bone').reversed())
400
+ ax.set_ylabel('$E-\\mu$ (eV)')
401
+
402
+ elif abscissa == 'momentum':
403
+ ax.set_xlabel(r'$k_{//}$ ($\mathrm{\AA}^{-1}$)')
404
+
405
+ with warnings.catch_warnings(record=True) as wlist:
406
+ warnings.filterwarnings(
407
+ "always",
408
+ message=(
409
+ "The input coordinates to pcolormesh are "
410
+ "interpreted as cell centers, but are not "
411
+ "monotonically increasing or decreasing."
412
+ ),
413
+ category=UserWarning,
414
+ )
415
+
416
+ Mome = np.sqrt(Ekin / PREF) * np.sin(np.deg2rad(Angl))
417
+ mome_min = np.min(Mome)
418
+ mome_max = np.max(Mome)
419
+ full_disp_momenta = np.linspace(
420
+ mome_min, mome_max, len(self.angles)
421
+ )
422
+
423
+ if ordinate == 'kinetic_energy':
424
+ mesh = ax.pcolormesh(
425
+ Mome, Ekin, self.intensities,
426
+ shading='auto',
427
+ cmap=plt.get_cmap('bone').reversed())
428
+ ax.set_ylabel('$E_{\\mathrm{kin}}$ (eV)')
429
+ elif ordinate == 'electron_energy':
430
+ Enel = Ekin - self.hnuminPhi
431
+ mesh = ax.pcolormesh(
432
+ Mome, Enel, self.intensities,
433
+ shading='auto',
434
+ cmap=plt.get_cmap('bone').reversed())
435
+ ax.set_ylabel('$E-\\mu$ (eV)')
436
+
437
+ y_lims = ax.get_ylim()
438
+
439
+ if any("cell centers" in str(w.message) for w in wlist):
440
+ warnings.warn(
441
+ "Conversion from angle to momenta causes warping of the "
442
+ "cell centers. \n Cell edges of the mesh plot may look "
443
+ "irregular.",
444
+ UserWarning,
445
+ stacklevel=2,
446
+ )
447
+
448
+ if abscissa == 'momentum' and self_energies is not None:
449
+ for self_energy in self_energies:
450
+
451
+ mdc_maxima = getattr(self_energy, "mdc_maxima", None)
452
+
453
+ # If this self-energy doesn't contain maxima, don't plot
454
+ if mdc_maxima is None:
455
+ continue
456
+
457
+ # Reserve a colour from the axes cycle for this self-energy,
458
+ # and use it consistently for MDC maxima and dispersion.
459
+ line_color = ax._get_lines.get_next_color()
460
+
461
+ peak_sigma = getattr(
462
+ self_energy, "peak_positions_sigma", None
463
+ )
464
+ xerr = xprs.sigma_confidence * peak_sigma if peak_sigma is \
465
+ not None else None
466
+
467
+ if ordinate == 'kinetic_energy':
468
+ y_vals = self_energy.ekin_range
469
+ else:
470
+ y_vals = self_energy.enel_range
471
+
472
+ x_vals = mdc_maxima
473
+ label = getattr(self_energy, "label", None)
474
+
475
+ # First plot the MDC maxima, using the reserved colour
476
+ if xerr is not None:
477
+ ax.errorbar(
478
+ x_vals, y_vals, xerr=xerr, fmt='o',
479
+ linestyle='', label=label,
480
+ markersize=markersize,
481
+ color=line_color, ecolor=line_color,
482
+ )
483
+ else:
484
+ ax.plot(
485
+ x_vals, y_vals, linestyle='',
486
+ marker='o', label=label,
487
+ markersize=markersize,
488
+ color=line_color,
489
+ )
490
+
491
+ # Bare-band dispersion for SpectralLinear / SpectralQuadratic
492
+ spec_class = getattr(
493
+ self_energy, "_class",
494
+ self_energy.__class__.__name__,
495
+ )
496
+
497
+ if (plot_disp_mode != 'none'
498
+ and spec_class in ("SpectralLinear",
499
+ "SpectralQuadratic")):
500
+
501
+ # Determine momentum grid for the dispersion
502
+ if plot_disp_mode == 'kink':
503
+ x_arr = np.asarray(x_vals)
504
+ mask = np.isfinite(x_arr)
505
+ if not np.any(mask):
506
+ # No valid k-points to define a range
507
+ continue
508
+ k_min = np.min(x_arr[mask])
509
+ k_max = np.max(x_arr[mask])
510
+ disp_momenta = np.linspace(
511
+ k_min, k_max, len(self.angles)
512
+ )
513
+ elif (plot_disp_mode == 'domain'
514
+ and spec_class == "SpectralQuadratic"):
515
+ side = getattr(self_energy, "side", None)
516
+ if side == 'left':
517
+ disp_momenta = np.linspace(
518
+ mome_min, self_energy.center_wavevector,
519
+ len(self.angles)
520
+ )
521
+ elif side == 'right':
522
+ disp_momenta = np.linspace(
523
+ self_energy.center_wavevector, mome_max,
524
+ len(self.angles)
525
+ )
526
+ else:
527
+ # Fallback: no valid side, use full range
528
+ disp_momenta = full_disp_momenta
529
+ else:
530
+ # 'full' or 'domain' for SpectralLinear
531
+ disp_momenta = full_disp_momenta
532
+
533
+ # --- Robust parameter checks before computing base_disp ---
534
+ if spec_class == "SpectralLinear":
535
+ fermi_vel = getattr(
536
+ self_energy, "fermi_velocity", None
537
+ )
538
+ fermi_k = getattr(
539
+ self_energy, "fermi_wavevector", None
540
+ )
541
+ if fermi_vel is None or fermi_k is None:
542
+ missing = []
543
+ if fermi_vel is None:
544
+ missing.append("fermi_velocity")
545
+ if fermi_k is None:
546
+ missing.append("fermi_wavevector")
547
+ raise TypeError(
548
+ "Cannot plot bare dispersion for "
549
+ "SpectralLinear: "
550
+ f"{', '.join(missing)} is None."
551
+ )
552
+
553
+ base_disp = (
554
+ fermi_vel * (disp_momenta - fermi_k)
555
+ )
556
+
557
+ else: # SpectralQuadratic
558
+ bare_mass = getattr(
559
+ self_energy, "bare_mass", None
560
+ )
561
+ center_k = getattr(
562
+ self_energy, "center_wavevector", None
563
+ )
564
+ fermi_k = getattr(
565
+ self_energy, "fermi_wavevector", None
566
+ )
567
+
568
+ missing = []
569
+ if bare_mass is None:
570
+ missing.append("bare_mass")
571
+ if center_k is None:
572
+ missing.append("center_wavevector")
573
+ if fermi_k is None:
574
+ missing.append("fermi_wavevector")
575
+
576
+ if missing:
577
+ raise TypeError(
578
+ "Cannot plot bare dispersion for "
579
+ "SpectralQuadratic: "
580
+ f"{', '.join(missing)} is None."
581
+ )
582
+
583
+ dk = disp_momenta - center_k
584
+ base_disp = PREF * (dk ** 2 - fermi_k ** 2) / bare_mass
585
+ # --- end parameter checks and base_disp construction ---
586
+
587
+ if ordinate == 'electron_energy':
588
+ disp_vals = base_disp
589
+ else: # kinetic energy
590
+ disp_vals = base_disp + self.hnuminPhi
591
+
592
+ band_label = getattr(self_energy, "label", None)
593
+ if band_label is not None:
594
+ band_label = f"{band_label} (bare)"
595
+
596
+ ax.plot(
597
+ disp_momenta, disp_vals,
598
+ label=band_label,
599
+ linestyle='--',
600
+ color=line_color,
601
+ )
602
+
603
+ handles, labels = ax.get_legend_handles_labels()
604
+ if any(labels):
605
+ ax.legend()
606
+
607
+ ax.set_ylim(y_lims)
608
+
609
+ plt.colorbar(mesh, ax=ax, label='counts (-)')
610
+ return fig
611
+
612
+ @add_fig_kwargs
613
+ def fit_fermi_edge(self, hnuminPhi_guess, background_guess=0.0,
614
+ integrated_weight_guess=1.0, angle_min=-np.inf,
615
+ angle_max=np.inf, ekin_min=-np.inf,
616
+ ekin_max=np.inf, ax=None, **kwargs):
617
+ r"""Fits the Fermi edge of the band map and plots the result.
618
+ Also sets hnuminPhi, the kinetic energy minus the work function in eV.
619
+ The fitting includes an energy convolution with an abscissa range
620
+ expanded by 5 times the energy resolution standard deviation.
621
+
622
+ Parameters
623
+ ----------
624
+ hnuminPhi_guess : float
625
+ Initial guess for kinetic energy minus the work function [eV]
626
+ background_guess : float
627
+ Initial guess for background intensity [counts]
628
+ integrated_weight_guess : float
629
+ Initial guess for integrated spectral intensity [counts]
630
+ angle_min : float
631
+ Minimum angle of integration interval [degrees]
632
+ angle_max : float
633
+ Maximum angle of integration interval [degrees]
634
+ ekin_min : float
635
+ Minimum kinetic energy of integration interval [eV]
636
+ ekin_max : float
637
+ Maximum kinetic energy of integration interval [eV]
638
+ ax : Matplotlib-Axes / NoneType
639
+ Axis for plotting the Fermi edge on. Created if not provided by
640
+ the user.
641
+
642
+ Other parameters
643
+ ----------------
644
+ **kwargs : dict, optional
645
+ Additional arguments passed on to add_fig_kwargs.
646
+
647
+ Returns
648
+ -------
649
+ fig : Matplotlib-Figure
650
+ Figure containing the Fermi edge fit
651
+
652
+ """
653
+ from scipy.ndimage import gaussian_filter
654
+
655
+ ax, fig, plt = get_ax_fig_plt(ax=ax)
656
+
657
+ min_angle_index = np.argmin(np.abs(self.angles - angle_min))
658
+ max_angle_index = np.argmin(np.abs(self.angles - angle_max))
659
+
660
+ min_ekin_index = np.argmin(np.abs(self.ekin - ekin_min))
661
+ max_ekin_index = np.argmin(np.abs(self.ekin - ekin_max))
662
+
663
+ energy_range = self.ekin[min_ekin_index:max_ekin_index]
664
+
665
+ integrated_intensity = np.trapz(
666
+ self.intensities[min_ekin_index:max_ekin_index,
667
+ min_angle_index:max_angle_index], axis=1)
668
+
669
+ fdir_initial = FermiDirac(temperature=self.temperature,
670
+ hnuminPhi=hnuminPhi_guess,
671
+ background=background_guess,
672
+ integrated_weight=integrated_weight_guess,
673
+ name='Initial guess')
674
+
675
+ parameters = np.array(
676
+ [hnuminPhi_guess, background_guess, integrated_weight_guess])
677
+
678
+ extra_args = (self.temperature,)
679
+
680
+ popt, pcov = fit_leastsq(
681
+ parameters, energy_range, integrated_intensity, fdir_initial,
682
+ self.energy_resolution, None, *extra_args)
683
+
684
+ # Update hnuminPhi; automatically sets self.enel
685
+ self.hnuminPhi = popt[0]
686
+ self.hnuminPhi_std = np.sqrt(np.diag(pcov)[0])
687
+
688
+ fdir_final = FermiDirac(temperature=self.temperature,
689
+ hnuminPhi=self.hnuminPhi, background=popt[1],
690
+ integrated_weight=popt[2],
691
+ name='Fitted result')
692
+
693
+ ax.set_xlabel(r'$E_{\mathrm{kin}}$ (-)')
694
+ ax.set_ylabel('Counts (-)')
695
+ ax.set_xlim([ekin_min, ekin_max])
696
+
697
+ ax.plot(energy_range, integrated_intensity, label='Data')
698
+
699
+ extend, step, numb = extend_function(energy_range,
700
+ self.energy_resolution)
701
+
702
+ initial_result = gaussian_filter(fdir_initial.evaluate(extend),
703
+ sigma=step)[numb:-numb if numb else None]
704
+
705
+ final_result = gaussian_filter(fdir_final.evaluate(extend),
706
+ sigma=step)[numb:-numb if numb else None]
707
+
708
+ ax.plot(energy_range, initial_result, label=fdir_initial.name)
709
+ ax.plot(energy_range, final_result, label=fdir_final.name)
710
+
711
+ ax.legend()
712
+
713
+ return fig
714
+
715
+
716
+ @add_fig_kwargs
717
+ def correct_fermi_edge(self, hnuminPhi_guess=None, background_guess=0.0,
718
+ integrated_weight_guess=1.0, angle_min=-np.inf,
719
+ angle_max=np.inf, ekin_min=-np.inf, ekin_max=np.inf,
720
+ slope_guess=0, offset_guess=None,
721
+ true_angle=0, ax=None, **kwargs):
722
+ r"""TBD
723
+ hnuminPhi_guess should be estimate at true angle
724
+
725
+ Parameters
726
+ ----------
727
+ hnuminPhi_guess : float, optional
728
+ Initial guess for kinetic energy minus the work function [eV].
729
+
730
+ Other parameters
731
+ ----------------
732
+ **kwargs : dict, optional
733
+ Additional arguments passed on to add_fig_kwargs.
734
+
735
+ Returns
736
+ -------
737
+ fig : Matplotlib-Figure
738
+ Figure containing the Fermi edge fit
739
+
740
+ """
741
+ from scipy.ndimage import map_coordinates
742
+ from . import settings_parameters as xprs
743
+
744
+ if hnuminPhi_guess is None:
745
+ raise ValueError('Please provide an initial guess for ' +
746
+ 'hnuminPhi.')
747
+
748
+ # Here some loop where it fits all the Fermi edges
749
+ angle_min_index = np.abs(self.angles - angle_min).argmin()
750
+ angle_max_index = np.abs(self.angles - angle_max).argmin()
751
+
752
+ ekin_min_index = np.abs(self.ekin - ekin_min).argmin()
753
+ ekin_max_index = np.abs(self.ekin - ekin_max).argmin()
754
+
755
+ Intensities = self.intensities[ekin_min_index:ekin_max_index + 1,
756
+ angle_min_index:angle_max_index + 1]
757
+ angle_range = self.angles[angle_min_index:angle_max_index + 1]
758
+ energy_range = self.ekin[ekin_min_index:ekin_max_index + 1]
759
+
760
+ angle_shape = angle_range.shape
761
+ nmps = np.zeros(angle_shape)
762
+ stds = np.zeros(angle_shape)
763
+
764
+ hnuminPhi_left = hnuminPhi_guess - (true_angle - angle_min) \
765
+ * slope_guess
766
+
767
+ fdir_initial = FermiDirac(temperature=self.temperature,
768
+ hnuminPhi=hnuminPhi_left,
769
+ background=background_guess,
770
+ integrated_weight=integrated_weight_guess,
771
+ name='Initial guess')
772
+
773
+ parameters = np.array(
774
+ [hnuminPhi_left, background_guess, integrated_weight_guess])
775
+
776
+ extra_args = (self.temperature,)
777
+
778
+ for indx in range(angle_max_index - angle_min_index + 1):
779
+ edge = Intensities[:, indx]
780
+
781
+ parameters, pcov = fit_leastsq(
782
+ parameters, energy_range, edge, fdir_initial,
783
+ self.energy_resolution, None, *extra_args)
784
+
785
+ nmps[indx] = parameters[0]
786
+ stds[indx] = np.sqrt(np.diag(pcov)[0])
787
+
788
+ # Offset at true angle if not set before
789
+ if offset_guess is None:
790
+ offset_guess = hnuminPhi_guess - slope_guess * true_angle
791
+
792
+ parameters = np.array([offset_guess, slope_guess])
793
+
794
+ lin_fun = Linear(offset_guess, slope_guess, 'Linear')
795
+
796
+ popt, pcov = fit_leastsq(parameters, angle_range, nmps, lin_fun, None,
797
+ stds)
798
+
799
+ linsp = lin_fun(angle_range, popt[0], popt[1])
800
+
801
+ # Update hnuminPhi; automatically sets self.enel
802
+ self.hnuminPhi = lin_fun(true_angle, popt[0], popt[1])
803
+ self.hnuminPhi_std = np.sqrt(true_angle**2 * pcov[1, 1] + pcov[0, 0]
804
+ + 2 * true_angle * pcov[0, 1])
805
+
806
+ Angl, Ekin = np.meshgrid(self.angles, self.ekin)
807
+
808
+ ax, fig, plt = get_ax_fig_plt(ax=ax)
809
+
810
+ ax.set_xlabel('Angle ($\degree$)')
811
+ ax.set_ylabel('$E_{\mathrm{kin}}$ (eV)')
812
+ mesh = ax.pcolormesh(Angl, Ekin, self.intensities,
813
+ shading='auto', cmap=plt.get_cmap('bone').reversed(),
814
+ zorder=1)
815
+
816
+ ax.errorbar(angle_range, nmps, yerr=xprs.sigma_confidence * stds, zorder=1)
817
+ ax.plot(angle_range, linsp, zorder=2)
818
+
819
+ cbar = plt.colorbar(mesh, ax=ax, label='counts (-)')
820
+
821
+ # Fermi-edge correction
822
+ rows, cols = self.intensities.shape
823
+ shift_values = popt[1] * self.angles / (self.ekin[0] - self.ekin[1])
824
+ row_coords = np.arange(rows).reshape(-1, 1) - shift_values
825
+ col_coords = np.arange(cols).reshape(1, -1).repeat(rows, axis=0)
826
+ self.intensities = map_coordinates(self.intensities,
827
+ [row_coords, col_coords], order=1)
828
+
829
+ return fig