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