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/spectral.py DELETED
@@ -1,2476 +0,0 @@
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 all the spectral quantities."""
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 uncr, pref, dtor, kilo, stdv
20
-
21
- class BandMap:
22
- r"""
23
- Class for the band map from the ARPES experiment.
24
-
25
- Parameters
26
- ----------
27
- datafile : str, optional
28
- Path to an IGOR binary wave file.
29
- intensities : ndarray, optional
30
- 2D array of intensities [E, angle].
31
- angles : ndarray, optional
32
- 1D array of emission angles in degrees.
33
- ekin : ndarray, optional
34
- 1D array of kinetic energies in eV.
35
- enel : ndarray, optional
36
- 1D array of electron energies in eV.
37
- energy_resolution : float, optional
38
- Energy-resolution standard deviation [eV].
39
- angle_resolution : float, optional
40
- Angular-resolution standard deviation [deg].
41
- temperature : float, optional
42
- Sample temperature [K].
43
- hnuminphi : float, optional
44
- Photon energy minus work function [eV].
45
- hnuminphi_std : float, optional
46
- Standard deviation on ``hnuminphi`` [eV].
47
- transpose : bool, optional
48
- If True, transpose the input data.
49
- flip_ekin : bool, optional
50
- If True, flip the energy axis.
51
- flip_angles : bool, optional
52
- If True, flip the angle axis.
53
-
54
- Attributes
55
- ----------
56
- intensities : ndarray
57
- 2D intensity map [energy, angle].
58
- angles : ndarray
59
- Emission angles in degrees.
60
- ekin : ndarray
61
- Kinetic-energy axis in eV.
62
- enel : ndarray
63
- Electron-energy axis in eV.
64
- hnuminphi : float or None
65
- Photon energy minus work function.
66
- hnuminphi_std : float or None
67
- Standard deviation on ``hnuminphi``.
68
-
69
- """
70
-
71
- def __init__(self, datafile=None, intensities=None, angles=None,
72
- ekin=None, enel=None, energy_resolution=None,
73
- angle_resolution=None, temperature=None, hnuminphi=None,
74
- hnuminphi_std=None, transpose=False, flip_ekin=False,
75
- flip_angles=False):
76
-
77
- # --- IO / file load -------------------------------------------------
78
- if datafile is not None:
79
- data = binarywave.load(datafile)
80
- self.intensities = data['wave']['wData']
81
-
82
- fnum, anum = data['wave']['wave_header']['nDim'][0:2]
83
- fstp, astp = data['wave']['wave_header']['sfA'][0:2]
84
- fmin, amin = data['wave']['wave_header']['sfB'][0:2]
85
-
86
- if self.intensities.shape != (fnum, anum):
87
- raise ValueError('nDim and shape of wData do not match.')
88
-
89
- if transpose:
90
- self.intensities = self.intensities.T
91
- fnum, anum = anum, fnum
92
- fstp, astp = astp, fstp
93
- fmin, amin = amin, fmin
94
-
95
- if flip_ekin:
96
- self.intensities = self.intensities[::-1, :]
97
-
98
- if flip_angles:
99
- self.intensities = self.intensities[:, ::-1]
100
-
101
- self.angles = np.linspace(amin, amin + (anum - 1) * astp, anum)
102
- file_ekin = np.linspace(fmin, fmin + (fnum - 1) * fstp, fnum)
103
- else:
104
- file_ekin = None
105
-
106
- # --- Required arrays if not using datafile -------------------------
107
- if intensities is not None:
108
- self.intensities = intensities
109
- elif datafile is None:
110
- raise ValueError('Please provide datafile or intensities.')
111
-
112
- if angles is not None:
113
- self.angles = angles
114
- elif datafile is None:
115
- raise ValueError('Please provide datafile or angles.')
116
-
117
- # --- Initialize energy axes (raw slots) ----------------------------
118
- self._ekin = None
119
- self._enel = None
120
-
121
- # Apply user overrides or file ekin
122
- if ekin is not None and enel is not None:
123
- raise ValueError('Provide only one of ekin or enel, not both.')
124
-
125
- if ekin is not None:
126
- self._ekin = ekin
127
- elif enel is not None:
128
- self._enel = enel
129
- elif file_ekin is not None:
130
- self._ekin = file_ekin
131
- else:
132
- raise ValueError('Please provide datafile, ekin, or enel.')
133
-
134
- # Scalars / metadata
135
- self.energy_resolution = energy_resolution
136
- self.angle_resolution = angle_resolution
137
- self.temperature = temperature
138
-
139
- # Work-function combo and its std
140
- self._hnuminphi = None
141
- self._hnuminphi_std = None
142
- self.hnuminphi = hnuminphi
143
- self.hnuminphi_std = hnuminphi_std
144
-
145
- # --- 1) Track which axis is authoritative --------------------------
146
- self._ekin_explicit = ekin is not None or (file_ekin is not None
147
- and enel is None)
148
- self._enel_explicit = enel is not None
149
-
150
- # --- 2) Derive missing axis if possible ----------------------------
151
- if self._ekin is None and self._enel is not None \
152
- and self._hnuminphi is not None:
153
- self._ekin = self._enel + self._hnuminphi
154
- if self._enel is None and self._ekin is not None \
155
- and self._hnuminphi is not None:
156
- self._enel = self._ekin - self._hnuminphi
157
-
158
- # -------------------- Properties: data arrays ---------------------------
159
- @property
160
- def intensities(self):
161
- return self._intensities
162
-
163
- @intensities.setter
164
- def intensities(self, x):
165
- self._intensities = x
166
-
167
- @property
168
- def angles(self):
169
- return self._angles
170
-
171
- @angles.setter
172
- def angles(self, x):
173
- self._angles = x
174
-
175
- # -------------------- 3) Resolution / temperature ----------------------
176
- @property
177
- def angle_resolution(self):
178
- return self._angle_resolution
179
-
180
- @angle_resolution.setter
181
- def angle_resolution(self, x):
182
- self._angle_resolution = x
183
-
184
- @property
185
- def energy_resolution(self):
186
- return self._energy_resolution
187
-
188
- @energy_resolution.setter
189
- def energy_resolution(self, x):
190
- self._energy_resolution = x
191
-
192
- @property
193
- def temperature(self):
194
- return self._temperature
195
-
196
- @temperature.setter
197
- def temperature(self, x):
198
- self._temperature = x
199
-
200
- # -------------------- 4) Sync ekin / enel / hnuminphi ------------------
201
- @property
202
- def ekin(self):
203
- if self._ekin is None and self._enel is not None \
204
- and self._hnuminphi is not None:
205
- return self._enel + self._hnuminphi
206
- return self._ekin
207
-
208
- @ekin.setter
209
- def ekin(self, x):
210
- if getattr(self, "_enel_explicit", False):
211
- raise AttributeError('enel is explicit; set hnuminphi instead.')
212
- self._ekin = x
213
- self._ekin_explicit = True
214
- if not getattr(self, "_enel_explicit", False) \
215
- and self._hnuminphi is not None and x is not None:
216
- self._enel = x - self._hnuminphi
217
-
218
- @property
219
- def enel(self):
220
- if self._enel is None and self._ekin is not None \
221
- and self._hnuminphi is not None:
222
- return self._ekin - self._hnuminphi
223
- return self._enel
224
-
225
- @enel.setter
226
- def enel(self, x):
227
- if getattr(self, "_ekin_explicit", False):
228
- raise AttributeError('ekin is explicit; set hnuminphi instead.')
229
- self._enel = x
230
- self._enel_explicit = True
231
- if not getattr(self, "_ekin_explicit", False) \
232
- and self._hnuminphi is not None and x is not None:
233
- self._ekin = x + self._hnuminphi
234
-
235
- @property
236
- def hnuminphi(self):
237
- r"""Returns the photon energy minus the work function in eV if it has
238
- been set, either during instantiation, with the setter, or by fitting
239
- the Fermi-Dirac distribution to the integrated weight.
240
-
241
- Returns
242
- -------
243
- hnuminphi : float, None
244
- Kinetic energy minus the work function [eV]
245
-
246
- """
247
- return self._hnuminphi
248
-
249
- @hnuminphi.setter
250
- def hnuminphi(self, x):
251
- r"""TBD
252
- """
253
- self._hnuminphi = x
254
- # Re-derive the non-explicit axis if possible
255
- if not getattr(self, "_ekin_explicit", False) \
256
- and self._enel is not None and x is not None:
257
- self._ekin = self._enel + x
258
- if not getattr(self, "_enel_explicit", False) \
259
- and self._ekin is not None and x is not None:
260
- self._enel = self._ekin - x
261
-
262
- @property
263
- def hnuminphi_std(self):
264
- r"""Returns standard deviation of the photon energy minus the work
265
- function in eV.
266
-
267
- Returns
268
- -------
269
- hnuminphi_std : float
270
- Standard deviation of energy minus the work function [eV]
271
-
272
- """
273
- return self._hnuminphi_std
274
-
275
- @hnuminphi_std.setter
276
- def hnuminphi_std(self, x):
277
- r"""Manually sets the standard deviation of photon energy minus the
278
- work function in eV.
279
-
280
- Parameters
281
- ----------
282
- hnuminphi_std : float
283
- Standard deviation of energy minus the work function [eV]
284
-
285
- """
286
- self._hnuminphi_std = x
287
-
288
- def shift_angles(self, shift):
289
- r"""
290
- Shifts the angles by the specified amount in degrees. Used to shift
291
- from the detector angle to the material angle.
292
-
293
- Parameters
294
- ----------
295
- shift : float
296
- Angular shift [degrees]
297
-
298
- """
299
- self.angles = self.angles + shift
300
-
301
- def mdc_set(self, angle_min, angle_max, energy_value=None,
302
- energy_range=None):
303
- r"""Returns a set of MDCs. Documentation is to be further completed.
304
-
305
- Parameters
306
- ----------
307
- angle_min : float
308
- Minimum angle of integration interval [degrees]
309
- angle_max : float
310
- Maximum angle of integration interval [degrees]
311
-
312
- Returns
313
- -------
314
- angle_range : ndarray
315
- Array of size n containing the angular values
316
- energy_range : ndarray
317
- Array of size m containing the energy values
318
- mdcs : ndarray
319
- Array of size n x m containing the MDC intensities
320
-
321
- """
322
-
323
- if (energy_value is None and energy_range is None) or \
324
- (energy_value is not None and energy_range is not None):
325
- raise ValueError('Please provide either energy_value or ' +
326
- 'energy_range.')
327
-
328
- angle_min_index = np.abs(self.angles - angle_min).argmin()
329
- angle_max_index = np.abs(self.angles - angle_max).argmin()
330
- angle_range_out = self.angles[angle_min_index:angle_max_index + 1]
331
-
332
- if energy_value is not None:
333
- energy_index = np.abs(self.enel - energy_value).argmin()
334
- enel_range_out = self.enel[energy_index]
335
- mdcs = self.intensities[energy_index,
336
- angle_min_index:angle_max_index + 1]
337
-
338
- if energy_range:
339
- energy_indices = np.where((self.enel >= np.min(energy_range))
340
- & (self.enel <= np.max(energy_range))) \
341
- [0]
342
- enel_range_out = self.enel[energy_indices]
343
- mdcs = self.intensities[energy_indices,
344
- angle_min_index:angle_max_index + 1]
345
-
346
- return mdcs, angle_range_out, self.angle_resolution, \
347
- enel_range_out, self.hnuminphi
348
-
349
- @add_fig_kwargs
350
- def plot(self, abscissa='momentum', ordinate='electron_energy',
351
- self_energies=None, ax=None, markersize=None,
352
- plot_dispersions='none', **kwargs):
353
- r"""
354
- Plot the band map. Optionally overlay a collection of self-energies,
355
- e.g. a CreateSelfEnergies instance or any iterable of self-energy
356
- objects. Self-energies are *not* stored internally; they are used
357
- only for this plotting call.
358
-
359
- When self-energies are present and ``abscissa='momentum'``, their
360
- MDC maxima are overlaid with 95 % confidence intervals.
361
-
362
- The `plot_dispersions` argument controls bare-band plotting:
363
-
364
- - "full" : use the full momentum range of the map (default)
365
- - "none" : do not plot bare dispersions
366
- - "kink" : for each self-energy, use the min/max of its own
367
- momentum range (typically its MDC maxima), with
368
- `len(self.angles)` points.
369
- - "domain" : for SpectralQuadratic, use only the left or right
370
- domain relative to `center_wavevector`, based on the self-energy
371
- attribute `side` ("left" / "right"); for other cases this behaves
372
- as "full".
373
- """
374
- import warnings
375
-
376
- plot_disp_mode = plot_dispersions
377
- valid_disp_modes = ('full', 'none', 'kink', 'domain')
378
- if plot_disp_mode not in valid_disp_modes:
379
- raise ValueError(
380
- f"Invalid plot_dispersions '{plot_disp_mode}'. "
381
- f"Valid options: {valid_disp_modes}."
382
- )
383
-
384
- valid_abscissa = ('angle', 'momentum')
385
- valid_ordinate = ('kinetic_energy', 'electron_energy')
386
-
387
- if abscissa not in valid_abscissa:
388
- raise ValueError(
389
- f"Invalid abscissa '{abscissa}'. "
390
- f"Valid options: {valid_abscissa}"
391
- )
392
- if ordinate not in valid_ordinate:
393
- raise ValueError(
394
- f"Invalid ordinate '{ordinate}'. "
395
- f"Valid options: {valid_ordinate}"
396
- )
397
-
398
- if self_energies is not None:
399
-
400
- # MDC maxima are defined in momentum space, not angle space
401
- if abscissa == 'angle':
402
- raise ValueError(
403
- "MDC maxima cannot be plotted against angles; they are "
404
- "defined in momentum space. Use abscissa='momentum' "
405
- "when passing self-energies."
406
- )
407
-
408
- ax, fig, plt = get_ax_fig_plt(ax=ax)
409
-
410
- Angl, Ekin = np.meshgrid(self.angles, self.ekin)
411
-
412
- if abscissa == 'angle':
413
- ax.set_xlabel('Angle ($\\degree$)')
414
- if ordinate == 'kinetic_energy':
415
- mesh = ax.pcolormesh(
416
- Angl, Ekin, self.intensities,
417
- shading='auto',
418
- cmap=plt.get_cmap('bone').reversed())
419
- ax.set_ylabel('$E_{\\mathrm{kin}}$ (eV)')
420
- elif ordinate == 'electron_energy':
421
- Enel = Ekin - self.hnuminphi
422
- mesh = ax.pcolormesh(
423
- Angl, Enel, self.intensities,
424
- shading='auto',
425
- cmap=plt.get_cmap('bone').reversed())
426
- ax.set_ylabel('$E-\\mu$ (eV)')
427
-
428
- elif abscissa == 'momentum':
429
- ax.set_xlabel(r'$k_{//}$ ($\mathrm{\AA}^{-1}$)')
430
-
431
- with warnings.catch_warnings(record=True) as wlist:
432
- warnings.filterwarnings(
433
- "always",
434
- message=(
435
- "The input coordinates to pcolormesh are "
436
- "interpreted as cell centers, but are not "
437
- "monotonically increasing or decreasing."
438
- ),
439
- category=UserWarning,
440
- )
441
-
442
- Mome = np.sqrt(Ekin / pref) * np.sin(Angl * dtor)
443
- mome_min = np.min(Mome)
444
- mome_max = np.max(Mome)
445
- full_disp_momenta = np.linspace(
446
- mome_min, mome_max, len(self.angles)
447
- )
448
-
449
- if ordinate == 'kinetic_energy':
450
- mesh = ax.pcolormesh(
451
- Mome, Ekin, self.intensities,
452
- shading='auto',
453
- cmap=plt.get_cmap('bone').reversed())
454
- ax.set_ylabel('$E_{\\mathrm{kin}}$ (eV)')
455
- elif ordinate == 'electron_energy':
456
- Enel = Ekin - self.hnuminphi
457
- mesh = ax.pcolormesh(
458
- Mome, Enel, self.intensities,
459
- shading='auto',
460
- cmap=plt.get_cmap('bone').reversed())
461
- ax.set_ylabel('$E-\\mu$ (eV)')
462
-
463
- y_lims = ax.get_ylim()
464
-
465
- if any("cell centers" in str(w.message) for w in wlist):
466
- warnings.warn(
467
- "Conversion from angle to momenta causes warping of the "
468
- "cell centers. \n Cell edges of the mesh plot may look "
469
- "irregular.",
470
- UserWarning,
471
- stacklevel=2,
472
- )
473
-
474
- if abscissa == 'momentum' and self_energies is not None:
475
- for self_energy in self_energies:
476
-
477
- mdc_maxima = getattr(self_energy, "mdc_maxima", None)
478
-
479
- # If this self-energy doesn't contain maxima, don't plot
480
- if mdc_maxima is None:
481
- continue
482
-
483
- # Reserve a colour from the axes cycle for this self-energy,
484
- # and use it consistently for MDC maxima and dispersion.
485
- line_color = ax._get_lines.get_next_color()
486
-
487
- peak_sigma = getattr(
488
- self_energy, "peak_positions_sigma", None
489
- )
490
- xerr = stdv * peak_sigma if peak_sigma is not None else None
491
-
492
- if ordinate == 'kinetic_energy':
493
- y_vals = self_energy.ekin_range
494
- else:
495
- y_vals = self_energy.enel_range
496
-
497
- x_vals = mdc_maxima
498
- label = getattr(self_energy, "label", None)
499
-
500
- # First plot the MDC maxima, using the reserved colour
501
- if xerr is not None:
502
- ax.errorbar(
503
- x_vals, y_vals, xerr=xerr, fmt='o',
504
- linestyle='', label=label,
505
- markersize=markersize,
506
- color=line_color, ecolor=line_color,
507
- )
508
- else:
509
- ax.plot(
510
- x_vals, y_vals, linestyle='',
511
- marker='o', label=label,
512
- markersize=markersize,
513
- color=line_color,
514
- )
515
-
516
- # Bare-band dispersion for SpectralLinear / SpectralQuadratic
517
- spec_class = getattr(
518
- self_energy, "_class",
519
- self_energy.__class__.__name__,
520
- )
521
-
522
- if (plot_disp_mode != 'none'
523
- and spec_class in ("SpectralLinear",
524
- "SpectralQuadratic")):
525
-
526
- # Determine momentum grid for the dispersion
527
- if plot_disp_mode == 'kink':
528
- x_arr = np.asarray(x_vals)
529
- mask = np.isfinite(x_arr)
530
- if not np.any(mask):
531
- # No valid k-points to define a range
532
- continue
533
- k_min = np.min(x_arr[mask])
534
- k_max = np.max(x_arr[mask])
535
- disp_momenta = np.linspace(
536
- k_min, k_max, len(self.angles)
537
- )
538
- elif (plot_disp_mode == 'domain'
539
- and spec_class == "SpectralQuadratic"):
540
- side = getattr(self_energy, "side", None)
541
- if side == 'left':
542
- disp_momenta = np.linspace(
543
- mome_min, self_energy.center_wavevector,
544
- len(self.angles)
545
- )
546
- elif side == 'right':
547
- disp_momenta = np.linspace(
548
- self_energy.center_wavevector, mome_max,
549
- len(self.angles)
550
- )
551
- else:
552
- # Fallback: no valid side, use full range
553
- disp_momenta = full_disp_momenta
554
- else:
555
- # 'full' or 'domain' for SpectralLinear
556
- disp_momenta = full_disp_momenta
557
-
558
- # --- Robust parameter checks before computing base_disp ---
559
- if spec_class == "SpectralLinear":
560
- fermi_vel = getattr(
561
- self_energy, "fermi_velocity", None
562
- )
563
- fermi_k = getattr(
564
- self_energy, "fermi_wavevector", None
565
- )
566
- if fermi_vel is None or fermi_k is None:
567
- missing = []
568
- if fermi_vel is None:
569
- missing.append("fermi_velocity")
570
- if fermi_k is None:
571
- missing.append("fermi_wavevector")
572
- raise TypeError(
573
- "Cannot plot bare dispersion for "
574
- "SpectralLinear: "
575
- f"{', '.join(missing)} is None."
576
- )
577
-
578
- base_disp = (
579
- fermi_vel * (disp_momenta - fermi_k)
580
- )
581
-
582
- else: # SpectralQuadratic
583
- bare_mass = getattr(
584
- self_energy, "bare_mass", None
585
- )
586
- center_k = getattr(
587
- self_energy, "center_wavevector", None
588
- )
589
- fermi_k = getattr(
590
- self_energy, "fermi_wavevector", None
591
- )
592
-
593
- missing = []
594
- if bare_mass is None:
595
- missing.append("bare_mass")
596
- if center_k is None:
597
- missing.append("center_wavevector")
598
- if fermi_k is None:
599
- missing.append("fermi_wavevector")
600
-
601
- if missing:
602
- raise TypeError(
603
- "Cannot plot bare dispersion for "
604
- "SpectralQuadratic: "
605
- f"{', '.join(missing)} is None."
606
- )
607
-
608
- dk = disp_momenta - center_k
609
- base_disp = (
610
- pref * (dk ** 2 - fermi_k ** 2) / bare_mass
611
- )
612
- # --- end parameter checks and base_disp construction ---
613
-
614
- if ordinate == 'electron_energy':
615
- disp_vals = base_disp
616
- else: # kinetic energy
617
- disp_vals = base_disp + self.hnuminphi
618
-
619
- band_label = getattr(self_energy, "label", None)
620
- if band_label is not None:
621
- band_label = f"{band_label} (bare)"
622
-
623
- ax.plot(
624
- disp_momenta, disp_vals,
625
- label=band_label,
626
- linestyle='--',
627
- color=line_color,
628
- )
629
-
630
- handles, labels = ax.get_legend_handles_labels()
631
- if any(labels):
632
- ax.legend()
633
-
634
- ax.set_ylim(y_lims)
635
-
636
- plt.colorbar(mesh, ax=ax, label='counts (-)')
637
- return fig
638
-
639
- @add_fig_kwargs
640
- def fit_fermi_edge(self, hnuminphi_guess, background_guess=0.0,
641
- integrated_weight_guess=1.0, angle_min=-np.inf,
642
- angle_max=np.inf, ekin_min=-np.inf,
643
- ekin_max=np.inf, ax=None, **kwargs):
644
- r"""Fits the Fermi edge of the band map and plots the result.
645
- Also sets hnuminphi, the kinetic energy minus the work function in eV.
646
- The fitting includes an energy convolution with an abscissa range
647
- expanded by 5 times the energy resolution standard deviation.
648
-
649
- Parameters
650
- ----------
651
- hnuminphi_guess : float
652
- Initial guess for kinetic energy minus the work function [eV]
653
- background_guess : float
654
- Initial guess for background intensity [counts]
655
- integrated_weight_guess : float
656
- Initial guess for integrated spectral intensity [counts]
657
- angle_min : float
658
- Minimum angle of integration interval [degrees]
659
- angle_max : float
660
- Maximum angle of integration interval [degrees]
661
- ekin_min : float
662
- Minimum kinetic energy of integration interval [eV]
663
- ekin_max : float
664
- Maximum kinetic energy of integration interval [eV]
665
- ax : Matplotlib-Axes / NoneType
666
- Axis for plotting the Fermi edge on. Created if not provided by
667
- the user.
668
-
669
- Other parameters
670
- ----------------
671
- **kwargs : dict, optional
672
- Additional arguments passed on to add_fig_kwargs.
673
-
674
- Returns
675
- -------
676
- fig : Matplotlib-Figure
677
- Figure containing the Fermi edge fit
678
-
679
- """
680
- from scipy.ndimage import gaussian_filter
681
-
682
- ax, fig, plt = get_ax_fig_plt(ax=ax)
683
-
684
- min_angle_index = np.argmin(np.abs(self.angles - angle_min))
685
- max_angle_index = np.argmin(np.abs(self.angles - angle_max))
686
-
687
- min_ekin_index = np.argmin(np.abs(self.ekin - ekin_min))
688
- max_ekin_index = np.argmin(np.abs(self.ekin - ekin_max))
689
-
690
- energy_range = self.ekin[min_ekin_index:max_ekin_index]
691
-
692
- integrated_intensity = np.trapz(
693
- self.intensities[min_ekin_index:max_ekin_index,
694
- min_angle_index:max_angle_index], axis=1)
695
-
696
- fdir_initial = FermiDirac(temperature=self.temperature,
697
- hnuminphi=hnuminphi_guess,
698
- background=background_guess,
699
- integrated_weight=integrated_weight_guess,
700
- name='Initial guess')
701
-
702
- parameters = np.array(
703
- [hnuminphi_guess, background_guess, integrated_weight_guess])
704
-
705
- extra_args = (self.temperature,)
706
-
707
- popt, pcov = fit_leastsq(
708
- parameters, energy_range, integrated_intensity, fdir_initial,
709
- self.energy_resolution, None, *extra_args)
710
-
711
- # Update hnuminphi; automatically sets self.enel
712
- self.hnuminphi = popt[0]
713
- self.hnuminphi_std = np.sqrt(np.diag(pcov)[0])
714
-
715
- fdir_final = FermiDirac(temperature=self.temperature,
716
- hnuminphi=self.hnuminphi, background=popt[1],
717
- integrated_weight=popt[2],
718
- name='Fitted result')
719
-
720
- ax.set_xlabel(r'$E_{\mathrm{kin}}$ (-)')
721
- ax.set_ylabel('Counts (-)')
722
- ax.set_xlim([ekin_min, ekin_max])
723
-
724
- ax.plot(energy_range, integrated_intensity, label='Data')
725
-
726
- extend, step, numb = extend_function(energy_range,
727
- self.energy_resolution)
728
-
729
- initial_result = gaussian_filter(fdir_initial.evaluate(extend),
730
- sigma=step)[numb:-numb if numb else None]
731
-
732
- final_result = gaussian_filter(fdir_final.evaluate(extend),
733
- sigma=step)[numb:-numb if numb else None]
734
-
735
- ax.plot(energy_range, initial_result, label=fdir_initial.name)
736
- ax.plot(energy_range, final_result, label=fdir_final.name)
737
-
738
- ax.legend()
739
-
740
- return fig
741
-
742
-
743
- @add_fig_kwargs
744
- def correct_fermi_edge(self, hnuminphi_guess=None, background_guess=0.0,
745
- integrated_weight_guess=1.0, angle_min=-np.inf,
746
- angle_max=np.inf, ekin_min=-np.inf, ekin_max=np.inf,
747
- slope_guess=0, offset_guess=None,
748
- true_angle=0, ax=None, **kwargs):
749
- r"""TBD
750
- hnuminphi_guess should be estimate at true angle
751
-
752
- Parameters
753
- ----------
754
- hnuminphi_guess : float, optional
755
- Initial guess for kinetic energy minus the work function [eV].
756
-
757
- Other parameters
758
- ----------------
759
- **kwargs : dict, optional
760
- Additional arguments passed on to add_fig_kwargs.
761
-
762
- Returns
763
- -------
764
- fig : Matplotlib-Figure
765
- Figure containing the Fermi edge fit
766
-
767
- """
768
- from scipy.ndimage import map_coordinates
769
-
770
- if hnuminphi_guess is None:
771
- raise ValueError('Please provide an initial guess for ' +
772
- 'hnuminphi.')
773
-
774
- # Here some loop where it fits all the Fermi edges
775
- angle_min_index = np.abs(self.angles - angle_min).argmin()
776
- angle_max_index = np.abs(self.angles - angle_max).argmin()
777
-
778
- ekin_min_index = np.abs(self.ekin - ekin_min).argmin()
779
- ekin_max_index = np.abs(self.ekin - ekin_max).argmin()
780
-
781
- Intensities = self.intensities[ekin_min_index:ekin_max_index + 1,
782
- angle_min_index:angle_max_index + 1]
783
- angle_range = self.angles[angle_min_index:angle_max_index + 1]
784
- energy_range = self.ekin[ekin_min_index:ekin_max_index + 1]
785
-
786
- angle_shape = angle_range.shape
787
- nmps = np.zeros(angle_shape)
788
- stds = np.zeros(angle_shape)
789
-
790
- hnuminphi_left = hnuminphi_guess - (true_angle - angle_min) \
791
- * slope_guess
792
-
793
- fdir_initial = FermiDirac(temperature=self.temperature,
794
- hnuminphi=hnuminphi_left,
795
- background=background_guess,
796
- integrated_weight=integrated_weight_guess,
797
- name='Initial guess')
798
-
799
- parameters = np.array(
800
- [hnuminphi_left, background_guess, integrated_weight_guess])
801
-
802
- extra_args = (self.temperature,)
803
-
804
- for indx in range(angle_max_index - angle_min_index + 1):
805
- edge = Intensities[:, indx]
806
-
807
- parameters, pcov = fit_leastsq(
808
- parameters, energy_range, edge, fdir_initial,
809
- self.energy_resolution, None, *extra_args)
810
-
811
- nmps[indx] = parameters[0]
812
- stds[indx] = np.sqrt(np.diag(pcov)[0])
813
-
814
- # Offset at true angle if not set before
815
- if offset_guess is None:
816
- offset_guess = hnuminphi_guess - slope_guess * true_angle
817
-
818
- parameters = np.array([offset_guess, slope_guess])
819
-
820
- lin_fun = Linear(offset_guess, slope_guess, 'Linear')
821
-
822
- popt, pcov = fit_leastsq(parameters, angle_range, nmps, lin_fun, None,
823
- stds)
824
-
825
- linsp = lin_fun(angle_range, popt[0], popt[1])
826
-
827
- # Update hnuminphi; automatically sets self.enel
828
- self.hnuminphi = lin_fun(true_angle, popt[0], popt[1])
829
- self.hnuminphi_std = np.sqrt(true_angle**2 * pcov[1, 1] + pcov[0, 0]
830
- + 2 * true_angle * pcov[0, 1])
831
-
832
- Angl, Ekin = np.meshgrid(self.angles, self.ekin)
833
-
834
- ax, fig, plt = get_ax_fig_plt(ax=ax)
835
-
836
- ax.set_xlabel('Angle ($\degree$)')
837
- ax.set_ylabel('$E_{\mathrm{kin}}$ (eV)')
838
- mesh = ax.pcolormesh(Angl, Ekin, self.intensities,
839
- shading='auto', cmap=plt.get_cmap('bone').reversed(),
840
- zorder=1)
841
-
842
- ax.errorbar(angle_range, nmps, yerr=uncr * stds, zorder=1)
843
- ax.plot(angle_range, linsp, zorder=2)
844
-
845
- cbar = plt.colorbar(mesh, ax=ax, label='counts (-)')
846
-
847
- # Fermi-edge correction
848
- rows, cols = self.intensities.shape
849
- shift_values = popt[1] * self.angles / (self.ekin[0] - self.ekin[1])
850
- row_coords = np.arange(rows).reshape(-1, 1) - shift_values
851
- col_coords = np.arange(cols).reshape(1, -1).repeat(rows, axis=0)
852
- self.intensities = map_coordinates(self.intensities,
853
- [row_coords, col_coords], order=1)
854
-
855
- return fig
856
-
857
-
858
- class MDCs:
859
- r"""
860
- Container for momentum distribution curves (MDCs) and their fits.
861
-
862
- This class stores the MDC intensity maps, angular and energy grids, and
863
- the aggregated fit results produced by :meth:`fit_selection`.
864
-
865
- Parameters
866
- ----------
867
- intensities : ndarray
868
- MDC intensity data. Typically a 2D array with shape
869
- ``(n_energy, n_angle)`` or a 1D array for a single curve.
870
- angles : ndarray
871
- Angular grid corresponding to the MDCs [degrees].
872
- angle_resolution : float
873
- Angular step size or effective angular resolution [degrees].
874
- enel : ndarray or float
875
- Electron binding energies of the MDC slices [eV].
876
- Can be a scalar for a single MDC.
877
- hnuminphi : float
878
- Photon energy minus work function, used to convert ``enel`` to
879
- kinetic energy [eV].
880
-
881
- Attributes
882
- ----------
883
- intensities : ndarray
884
- MDC intensity data (same object as passed to the constructor).
885
- angles : ndarray
886
- Angular grid [degrees].
887
- angle_resolution : float
888
- Angular step size or resolution [degrees].
889
- enel : ndarray or float
890
- Electron binding energies [eV], as given at construction.
891
- ekin : ndarray or float
892
- Kinetic energies [eV], computed as ``enel + hnuminphi``.
893
- hnuminphi : float
894
- Photon energy minus work function [eV].
895
- ekin_range : ndarray
896
- Kinetic-energy values of the slices that were actually fitted.
897
- Set by :meth:`fit_selection`.
898
- individual_properties : dict
899
- Nested mapping of fitted parameters and their uncertainties for each
900
- component and each energy slice. Populated by :meth:`fit_selection`.
901
-
902
- Notes
903
- -----
904
- After calling :meth:`fit_selection`, :attr:`individual_properties` has the
905
- structure::
906
-
907
- {
908
- label: {
909
- class_name: {
910
- 'label': label,
911
- '_class': class_name,
912
- param: [values per energy slice],
913
- param_sigma: [1σ per slice or None],
914
- ...
915
- }
916
- }
917
- }
918
-
919
- where ``param`` is typically one of ``'offset'``, ``'slope'``,
920
- ``'amplitude'``, ``'peak'``, ``'broadening'``, and ``param_sigma`` stores
921
- the corresponding uncertainty for each slice.
922
-
923
- """
924
-
925
- def __init__(self, intensities, angles, angle_resolution, enel, hnuminphi):
926
- # Core input data (read-only)
927
- self._intensities = intensities
928
- self._angles = angles
929
- self._angle_resolution = angle_resolution
930
- self._enel = enel
931
- self._hnuminphi = hnuminphi
932
-
933
- # Derived attributes (populated by fit_selection)
934
- self._ekin_range = None
935
- self._individual_properties = None # combined values + sigmas
936
-
937
- # -------------------- Immutable physics inputs --------------------
938
-
939
- @property
940
- def angles(self):
941
- """Angular axis for the MDCs."""
942
- return self._angles
943
-
944
- @property
945
- def angle_resolution(self):
946
- """Angular step size (float)."""
947
- return self._angle_resolution
948
-
949
- @property
950
- def enel(self):
951
- """Photoelectron binding energies (array-like). Read-only."""
952
- return self._enel
953
-
954
- @enel.setter
955
- def enel(self, _):
956
- raise AttributeError("`enel` is read-only; set it via the constructor.")
957
-
958
- @property
959
- def hnuminphi(self):
960
- """Work-function/photon-energy offset. Read-only."""
961
- return self._hnuminphi
962
-
963
- @hnuminphi.setter
964
- def hnuminphi(self, _):
965
- raise AttributeError("`hnuminphi` is read-only; set it via the constructor.")
966
-
967
- @property
968
- def ekin(self):
969
- """Kinetic energy array: enel + hnuminphi (computed on the fly)."""
970
- return self._enel + self._hnuminphi
971
-
972
- @ekin.setter
973
- def ekin(self, _):
974
- raise AttributeError("`ekin` is derived and read-only.")
975
-
976
- # -------------------- Data arrays --------------------
977
-
978
- @property
979
- def intensities(self):
980
- """2D or 3D intensity map (energy × angle)."""
981
- return self._intensities
982
-
983
- @intensities.setter
984
- def intensities(self, x):
985
- self._intensities = x
986
-
987
- # -------------------- Results populated by fit_selection --------------------
988
-
989
- @property
990
- def ekin_range(self):
991
- """Kinetic-energy slices that were fitted."""
992
- if self._ekin_range is None:
993
- raise AttributeError("`ekin_range` not yet set. Run `.fit_selection()` first.")
994
- return self._ekin_range
995
-
996
- @property
997
- def individual_properties(self):
998
- """
999
- Aggregated fitted parameter values and uncertainties per component.
1000
-
1001
- Returns
1002
- -------
1003
- dict
1004
- Nested mapping::
1005
-
1006
- {
1007
- label: {
1008
- class_name: {
1009
- 'label': label,
1010
- '_class': class_name,
1011
- <param>: [values per slice],
1012
- <param>_sigma: [1σ per slice or None],
1013
- ...
1014
- }
1015
- }
1016
- }
1017
- """
1018
- if self._individual_properties is None:
1019
- raise AttributeError(
1020
- "`individual_properties` not yet set. Run `.fit_selection()` first."
1021
- )
1022
- return self._individual_properties
1023
-
1024
- def energy_check(self, energy_value):
1025
- r"""
1026
- """
1027
- if np.isscalar(self.ekin):
1028
- if energy_value is not None:
1029
- raise ValueError("This dataset contains only one " \
1030
- "momentum-distribution curve; do not provide energy_value.")
1031
- else:
1032
- kinergy = self.ekin
1033
- counts = self.intensities
1034
- else:
1035
- if energy_value is None:
1036
- raise ValueError("This dataset contains multiple " \
1037
- "momentum-distribution curves. Please provide an energy_value "
1038
- "for which to plot the MDCs.")
1039
- else:
1040
- energy_index = np.abs(self.enel - energy_value).argmin()
1041
- kinergy = self.ekin[energy_index]
1042
- counts = self.intensities[energy_index, :]
1043
-
1044
- if not (self.enel.min() <= energy_value <= self.enel.max()):
1045
- raise ValueError(
1046
- f"Selected energy_value={energy_value:.3f} "
1047
- f"is outside the available energy range "
1048
- f"({self.enel.min():.3f} – {self.enel.max():.3f}) "
1049
- "of the MDC collection."
1050
- )
1051
-
1052
- return counts, kinergy
1053
-
1054
-
1055
- def plot(self, energy_value=None, energy_range=None, ax=None, **kwargs):
1056
- """
1057
- Interactive or static plot with optional slider and full wrapper
1058
- support. Behavior consistent with Jupyter and CLI based on show /
1059
- fig_close.
1060
- """
1061
- import matplotlib.pyplot as plt
1062
- from matplotlib.widgets import Slider
1063
- import string
1064
- import sys
1065
- import warnings
1066
-
1067
- # Wrapper kwargs
1068
- title = kwargs.pop("title", None)
1069
- savefig = kwargs.pop("savefig", None)
1070
- show = kwargs.pop("show", True)
1071
- fig_close = kwargs.pop("fig_close", False)
1072
- tight_layout = kwargs.pop("tight_layout", False)
1073
- ax_grid = kwargs.pop("ax_grid", None)
1074
- ax_annotate = kwargs.pop("ax_annotate", False)
1075
- size_kwargs = kwargs.pop("size_kwargs", None)
1076
-
1077
- if energy_value is not None and energy_range is not None:
1078
- raise ValueError(
1079
- "Provide at most energy_value or energy_range, not both.")
1080
-
1081
- ax, fig, plt = get_ax_fig_plt(ax=ax)
1082
-
1083
- angles = self.angles
1084
- energies = self.enel
1085
-
1086
- if np.isscalar(energies):
1087
- if energy_value is not None or energy_range is not None:
1088
- raise ValueError(
1089
- "This dataset contains only one momentum-distribution "
1090
- "curve; do not provide energy_value or energy_range."
1091
- )
1092
-
1093
- intensities = self.intensities
1094
- ax.scatter(angles, intensities, label="Data")
1095
- ax.set_title(f"Energy slice: {energies * kilo:.3f} meV")
1096
-
1097
- # --- y-only autoscale, preserve x ---
1098
- x0, x1 = ax.get_xlim() # keep current x-range
1099
- ax.relim(visible_only=True) # recompute data limits
1100
- ax.autoscale_view(scalex=False, scaley=True)
1101
- ax.set_xlim(x0, x1) # restore x (belt-and-suspenders)
1102
-
1103
- else:
1104
- if (energy_value is not None) and (energy_range is not None):
1105
- raise ValueError("Provide either energy_value or energy_range, not both.")
1106
-
1107
- emin, emax = energies.min(), energies.max()
1108
-
1109
- # ---- Single-slice path (no slider) ----
1110
- if energy_value is not None:
1111
- if energy_value < emin or energy_value > emax:
1112
- raise ValueError(
1113
- f"Requested energy_value {energy_value:.3f} eV is "
1114
- f"outside the available energy range "
1115
- f"[{emin:.3f}, {emax:.3f}] eV."
1116
- )
1117
- idx = int(np.abs(energies - energy_value).argmin())
1118
- intensities = self.intensities[idx]
1119
- ax.scatter(angles, intensities, label="Data")
1120
- ax.set_title(f"Energy slice: {energies[idx] * kilo:.3f} meV")
1121
-
1122
- # --- y-only autoscale, preserve x ---
1123
- x0, x1 = ax.get_xlim() # keep current x-range
1124
- ax.relim(visible_only=True) # recompute data limits
1125
- ax.autoscale_view(scalex=False, scaley=True)
1126
- ax.set_xlim(x0, x1) # restore x (belt-and-suspenders)
1127
-
1128
- # ---- Multi-slice path (slider) ----
1129
- else:
1130
- if energy_range is not None:
1131
- e_min, e_max = energy_range
1132
- mask = (energies >= e_min) & (energies <= e_max)
1133
- else:
1134
- mask = np.ones_like(energies, dtype=bool)
1135
-
1136
- indices = np.where(mask)[0]
1137
- if len(indices) == 0:
1138
- raise ValueError("No energies found in the specified selection.")
1139
-
1140
- intensities = self.intensities[indices]
1141
-
1142
- fig.subplots_adjust(bottom=0.25)
1143
- idx = 0
1144
- scatter = ax.scatter(angles, intensities[idx], label="Data")
1145
- ax.set_title(f"Energy slice: "
1146
- f"{energies[indices[idx]] * kilo:.3f} meV")
1147
-
1148
- # Suppress single-point slider warning (when len(indices) == 1)
1149
- warnings.filterwarnings(
1150
- "ignore",
1151
- message="Attempting to set identical left == right",
1152
- category=UserWarning
1153
- )
1154
-
1155
- slider_ax = fig.add_axes([0.2, 0.08, 0.6, 0.04])
1156
- slider = Slider(
1157
- slider_ax, "Index", 0, len(indices) - 1,
1158
- valinit=idx, valstep=1
1159
- )
1160
-
1161
- def update(val):
1162
- i = int(slider.val)
1163
- yi = intensities[i]
1164
-
1165
- scatter.set_offsets(np.c_[angles, yi])
1166
-
1167
- x0, x1 = ax.get_xlim()
1168
-
1169
- yv = np.asarray(yi, dtype=float).ravel()
1170
- mask = np.isfinite(yv)
1171
- if mask.any():
1172
- y_min = float(yv[mask].min())
1173
- y_max = float(yv[mask].max())
1174
- span = y_max - y_min
1175
- frac = plt.rcParams['axes.ymargin']
1176
-
1177
- if span <= 0 or not np.isfinite(span):
1178
- scale = max(abs(y_max), 1.0)
1179
- pad = frac * scale
1180
- else:
1181
- pad = frac * span
1182
-
1183
- ax.set_ylim(y_min - pad, y_max + pad)
1184
-
1185
- # Keep x unchanged
1186
- ax.set_xlim(x0, x1)
1187
-
1188
- # Update title and redraw
1189
- ax.set_title(f"Energy slice: "
1190
- f"{energies[indices[i]] * kilo:.3f} meV")
1191
- fig.canvas.draw_idle()
1192
-
1193
- slider.on_changed(update)
1194
- self._slider = slider
1195
- self._line = scatter
1196
-
1197
- ax.set_xlabel("Angle (°)")
1198
- ax.set_ylabel("Counts (-)")
1199
- ax.legend()
1200
- self._fig = fig
1201
-
1202
- if size_kwargs:
1203
- fig.set_size_inches(size_kwargs.pop("w"),
1204
- size_kwargs.pop("h"), **size_kwargs)
1205
- if title:
1206
- fig.suptitle(title)
1207
- if tight_layout:
1208
- fig.tight_layout()
1209
- if savefig:
1210
- fig.savefig(savefig)
1211
- if ax_grid is not None:
1212
- for axis in fig.axes:
1213
- axis.grid(bool(ax_grid))
1214
- if ax_annotate:
1215
- tags = string.ascii_lowercase
1216
- for i, axis in enumerate(fig.axes):
1217
- axis.annotate(f"({tags[i]})", xy=(0.05, 0.95),
1218
- xycoords="axes fraction")
1219
-
1220
- is_interactive = hasattr(sys, 'ps1') or 'ipykernel' in sys.modules
1221
- is_cli = not is_interactive
1222
-
1223
- if show:
1224
- if is_cli:
1225
- plt.show()
1226
- if fig_close:
1227
- plt.close(fig)
1228
-
1229
- if not show and (fig_close or is_cli):
1230
- return None
1231
- return fig
1232
-
1233
-
1234
- @add_fig_kwargs
1235
- def visualize_guess(self, distributions, energy_value=None,
1236
- matrix_element=None, matrix_args=None,
1237
- ax=None, **kwargs):
1238
- r"""
1239
- """
1240
-
1241
- counts, kinergy = self.energy_check(energy_value)
1242
-
1243
- ax, fig, plt = get_ax_fig_plt(ax=ax)
1244
-
1245
- ax.set_xlabel('Angle ($\\degree$)')
1246
- ax.set_ylabel('Counts (-)')
1247
- ax.set_title(f"Energy slice: "
1248
- f"{(kinergy - self.hnuminphi) * kilo:.3f} meV")
1249
- ax.scatter(self.angles, counts, label='Data')
1250
-
1251
- final_result = self._merge_and_plot(ax=ax,
1252
- distributions=distributions, kinetic_energy=kinergy,
1253
- matrix_element=matrix_element,
1254
- matrix_args=dict(matrix_args) if matrix_args else None,
1255
- plot_individual=True,
1256
- )
1257
-
1258
- residual = counts - final_result
1259
- ax.scatter(self.angles, residual, label='Residual')
1260
- ax.legend()
1261
-
1262
- return fig
1263
-
1264
-
1265
- def fit_selection(self, distributions, energy_value=None, energy_range=None,
1266
- matrix_element=None, matrix_args=None, ax=None, **kwargs):
1267
- r"""
1268
- """
1269
- import matplotlib.pyplot as plt
1270
- from matplotlib.widgets import Slider
1271
- from copy import deepcopy
1272
- import string
1273
- import sys
1274
- import warnings
1275
- from lmfit import Minimizer
1276
- from scipy.ndimage import gaussian_filter
1277
- from .functions import construct_parameters, build_distributions, \
1278
- residual, resolve_param_name
1279
-
1280
- # Wrapper kwargs
1281
- title = kwargs.pop("title", None)
1282
- savefig = kwargs.pop("savefig", None)
1283
- show = kwargs.pop("show", True)
1284
- fig_close = kwargs.pop("fig_close", False)
1285
- tight_layout = kwargs.pop("tight_layout", False)
1286
- ax_grid = kwargs.pop("ax_grid", None)
1287
- ax_annotate = kwargs.pop("ax_annotate", False)
1288
- size_kwargs = kwargs.pop("size_kwargs", None)
1289
-
1290
- ax, fig, plt = get_ax_fig_plt(ax=ax)
1291
-
1292
- energies = self.enel
1293
- new_distributions = deepcopy(distributions)
1294
-
1295
- if energy_value is not None and energy_range is not None:
1296
- raise ValueError(
1297
- "Provide at most energy_value or energy_range, not both.")
1298
-
1299
- if np.isscalar(energies):
1300
- if energy_value is not None or energy_range is not None:
1301
- raise ValueError(
1302
- "This dataset contains only one momentum-distribution "
1303
- "curve; do not provide energy_value or energy_range."
1304
- )
1305
- kinergies = np.atleast_1d(self.ekin)
1306
- intensities = np.atleast_2d(self.intensities)
1307
-
1308
- else:
1309
- if energy_value is not None:
1310
- if (energy_value < energies.min() or energy_value > energies.max()):
1311
- raise ValueError( f"Requested energy_value {energy_value:.3f} eV is "
1312
- f"outside the available energy range "
1313
- f"[{energies.min():.3f}, {energies.max():.3f}] eV." )
1314
- idx = np.abs(energies - energy_value).argmin()
1315
- indices = np.atleast_1d(idx)
1316
- kinergies = self.ekin[indices]
1317
- intensities = self.intensities[indices, :]
1318
-
1319
- elif energy_range is not None:
1320
- e_min, e_max = energy_range
1321
- indices = np.where((energies >= e_min) & (energies <= e_max))[0]
1322
- if len(indices) == 0:
1323
- raise ValueError("No energies found in the specified energy_range.")
1324
- kinergies = self.ekin[indices]
1325
- intensities = self.intensities[indices, :]
1326
-
1327
- else: # Without specifying a range, all MDCs are plotted
1328
- kinergies = self.ekin
1329
- intensities = self.intensities
1330
-
1331
- # Final shape guard
1332
- kinergies = np.atleast_1d(kinergies)
1333
- intensities = np.atleast_2d(intensities)
1334
-
1335
- all_final_results = []
1336
- all_residuals = []
1337
- all_individual_results = [] # List of (n_individuals, n_angles)
1338
-
1339
- aggregated_properties = {}
1340
-
1341
- # map class_name -> parameter names to extract
1342
- param_spec = {
1343
- 'Constant': ('offset',),
1344
- 'Linear': ('offset', 'slope'),
1345
- 'SpectralLinear': ('amplitude', 'peak', 'broadening'),
1346
- 'SpectralQuadratic': ('amplitude', 'peak', 'broadening'),
1347
- }
1348
-
1349
- order = np.argsort(kinergies)[::-1]
1350
- for idx in order:
1351
- kinergy = kinergies[idx]
1352
- intensity = intensities[idx]
1353
- if matrix_element is not None:
1354
- parameters, element_names = construct_parameters(
1355
- new_distributions, matrix_args)
1356
- new_distributions = build_distributions(new_distributions, parameters)
1357
- mini = Minimizer(
1358
- residual, parameters,
1359
- fcn_args=(self.angles, intensity, self.angle_resolution,
1360
- new_distributions, kinergy, self.hnuminphi,
1361
- matrix_element, element_names)
1362
- )
1363
- else:
1364
- parameters = construct_parameters(new_distributions)
1365
- new_distributions = build_distributions(new_distributions, parameters)
1366
- mini = Minimizer(
1367
- residual, parameters,
1368
- fcn_args=(self.angles, intensity, self.angle_resolution,
1369
- new_distributions, kinergy, self.hnuminphi)
1370
- )
1371
-
1372
- outcome = mini.minimize('least_squares')
1373
-
1374
- pcov = outcome.covar
1375
-
1376
- var_names = getattr(outcome, 'var_names', None)
1377
- if not var_names:
1378
- var_names = [n for n, p in outcome.params.items() if p.vary]
1379
- var_idx = {n: i for i, n in enumerate(var_names)}
1380
-
1381
- param_sigma_full = {}
1382
- for name, par in outcome.params.items():
1383
- sigma = None
1384
- if pcov is not None and name in var_idx:
1385
- d = pcov[var_idx[name], var_idx[name]]
1386
- if np.isfinite(d) and d >= 0:
1387
- sigma = float(np.sqrt(d))
1388
- if sigma is None:
1389
- s = getattr(par, 'stderr', None)
1390
- sigma = float(s) if s is not None else None
1391
- param_sigma_full[name] = sigma
1392
-
1393
- # Rebuild the *fitted* distributions from optimized params
1394
- fitted_distributions = build_distributions(new_distributions, outcome.params)
1395
-
1396
- # If using a matrix element, extract slice-specific args from the fit
1397
- if matrix_element is not None:
1398
- new_matrix_args = {key: outcome.params[key].value for key in matrix_args}
1399
- else:
1400
- new_matrix_args = None
1401
-
1402
- # individual curves (smoothed, cropped) and final sum (no plotting here)
1403
- extend, step, numb = extend_function(self.angles, self.angle_resolution)
1404
-
1405
- total_result_ext = np.zeros_like(extend)
1406
- indiv_rows = [] # (n_individuals, n_angles)
1407
- individual_labels = []
1408
-
1409
- for dist in fitted_distributions:
1410
- # evaluate each component on the extended grid
1411
- if getattr(dist, 'class_name', None) == 'SpectralQuadratic':
1412
- if (getattr(dist, 'center_angle', None) is not None) and (
1413
- kinergy is None or self.hnuminphi is None
1414
- ):
1415
- raise ValueError(
1416
- 'Spectral quadratic function is defined in terms '
1417
- 'of a center angle. Please provide a kinetic energy '
1418
- 'and hnuminphi.'
1419
- )
1420
- extended_result = dist.evaluate(extend, kinergy, self.hnuminphi)
1421
- else:
1422
- extended_result = dist.evaluate(extend)
1423
-
1424
- if matrix_element is not None and hasattr(dist, 'index'):
1425
- args = new_matrix_args or {}
1426
- extended_result *= matrix_element(extend, **args)
1427
-
1428
- total_result_ext += extended_result
1429
-
1430
- # smoothed & cropped individual
1431
- individual_curve = gaussian_filter(extended_result, sigma=step)[
1432
- numb:-numb if numb else None
1433
- ]
1434
- indiv_rows.append(np.asarray(individual_curve))
1435
-
1436
- # label
1437
- label = getattr(dist, 'label', str(dist))
1438
- individual_labels.append(label)
1439
-
1440
- # ---- collect parameters for this distribution
1441
- # (Aggregated over slices)
1442
- cls = getattr(dist, 'class_name', None)
1443
- wanted = param_spec.get(cls, ())
1444
-
1445
- # ensure dicts exist
1446
- label_bucket = aggregated_properties.setdefault(label, {})
1447
- class_bucket = label_bucket.setdefault(
1448
- cls, {'label': label, '_class': cls}
1449
- )
1450
-
1451
- # store center_wavevector (scalar) for SpectralQuadratic
1452
- if (
1453
- cls == 'SpectralQuadratic'
1454
- and hasattr(dist, 'center_wavevector')
1455
- ):
1456
- class_bucket.setdefault(
1457
- 'center_wavevector', dist.center_wavevector
1458
- )
1459
-
1460
- # ensure keys for both values and sigmas
1461
- for pname in wanted:
1462
- class_bucket.setdefault(pname, [])
1463
- class_bucket.setdefault(f"{pname}_sigma", [])
1464
-
1465
- # append values and sigmas in the order of slices
1466
- for pname in wanted:
1467
- param_key = resolve_param_name(outcome.params, label, pname)
1468
-
1469
- if param_key is not None and param_key in outcome.params:
1470
- class_bucket[pname].append(outcome.params[param_key].value)
1471
- class_bucket[f"{pname}_sigma"].append(param_sigma_full.get(param_key, None))
1472
- else:
1473
- # Not fitted in this slice → keep the value if present on the dist, sigma=None
1474
- class_bucket[pname].append(getattr(dist, pname, None))
1475
- class_bucket[f"{pname}_sigma"].append(None)
1476
-
1477
- # final (sum) curve, smoothed & cropped
1478
- final_result_i = gaussian_filter(total_result_ext, sigma=step)[
1479
- numb:-numb if numb else None]
1480
- final_result_i = np.asarray(final_result_i)
1481
-
1482
- # Residual for this slice
1483
- residual_i = np.asarray(intensity) - final_result_i
1484
-
1485
- # Store per-slice results
1486
- all_final_results.append(final_result_i)
1487
- all_residuals.append(residual_i)
1488
- all_individual_results.append(np.vstack(indiv_rows))
1489
-
1490
- # --- after the reversed-order loop, restore original (ascending) order ---
1491
- inverse_order = np.argsort(np.argsort(kinergies)[::-1])
1492
-
1493
- # Reorder per-slice arrays/lists computed in the loop
1494
- all_final_results[:] = [all_final_results[i] for i in inverse_order]
1495
- all_residuals[:] = [all_residuals[i] for i in inverse_order]
1496
- all_individual_results[:] = [all_individual_results[i] for i in inverse_order]
1497
-
1498
- # Reorder all per-slice lists in aggregated_properties
1499
- for label_dict in aggregated_properties.values():
1500
- for cls_dict in label_dict.values():
1501
- for key, val in cls_dict.items():
1502
- if isinstance(val, list) and len(val) == len(kinergies):
1503
- cls_dict[key] = [val[i] for i in inverse_order]
1504
-
1505
- self._ekin_range = kinergies
1506
- self._individual_properties = aggregated_properties
1507
-
1508
- if np.isscalar(energies):
1509
- # One slice only: plot MDC, Fit, Residual, and Individuals
1510
- ydata = np.asarray(intensities).squeeze()
1511
- yfit = np.asarray(all_final_results[0]).squeeze()
1512
- yres = np.asarray(all_residuals[0]).squeeze()
1513
- yind = np.asarray(all_individual_results[0])
1514
-
1515
- ax.scatter(self.angles, ydata, label="Data")
1516
- # plot individuals with their labels
1517
- for j, lab in enumerate(individual_labels or []):
1518
- ax.plot(self.angles, yind[j], label=str(lab))
1519
- ax.plot(self.angles, yfit, label="Fit")
1520
- ax.scatter(self.angles, yres, label="Residual")
1521
-
1522
- ax.set_title(f"Energy slice: {energies * kilo:.3f} meV")
1523
- ax.relim() # recompute data limits from all artists
1524
- ax.autoscale_view() # apply autoscaling + axes.ymargin padding
1525
-
1526
- else:
1527
- if energy_value is not None:
1528
- _idx = int(np.abs(energies - energy_value).argmin())
1529
- energies_sel = np.atleast_1d(energies[_idx])
1530
- elif energy_range is not None:
1531
- e_min, e_max = energy_range
1532
- energies_sel = energies[(energies >= e_min)
1533
- & (energies <= e_max)]
1534
- else:
1535
- energies_sel = energies
1536
-
1537
- # Number of slices must match
1538
- n_slices = len(all_final_results)
1539
- assert intensities.shape[0] == n_slices == len(all_residuals) \
1540
- == len(all_individual_results), (f"Mismatch: data \
1541
- {intensities.shape[0]}, fits {len(all_final_results)}, "
1542
- f"residuals {len(all_residuals)}, \
1543
- individuals {len(all_individual_results)}."
1544
- )
1545
- n_individuals = all_individual_results[0].shape[0] \
1546
- if n_slices else 0
1547
-
1548
- fig.subplots_adjust(bottom=0.25)
1549
- idx = 0
1550
-
1551
- # Initial draw (MDC + Individuals + Fit + Residual) at slice 0
1552
- scatter = ax.scatter(self.angles, intensities[idx], label="Data")
1553
-
1554
- individual_lines = []
1555
- if n_individuals:
1556
- for j in range(n_individuals):
1557
- if individual_labels and j < len(individual_labels):
1558
- label = str(individual_labels[j])
1559
- else:
1560
- label = f"Comp {j}"
1561
-
1562
- yvals = all_individual_results[idx][j]
1563
- line, = ax.plot(self.angles, yvals, label=label)
1564
- individual_lines.append(line)
1565
-
1566
- result_line, = ax.plot(self.angles, all_final_results[idx],
1567
- label="Fit")
1568
- resid_scatter = ax.scatter(self.angles, all_residuals[idx],
1569
- label="Residual")
1570
-
1571
- # Title + limits (use only the currently shown slice)
1572
- ax.set_title(f"Energy slice: {energies_sel[idx] * kilo:.3f} meV")
1573
- ax.relim() # recompute data limits from all artists
1574
- ax.autoscale_view() # apply autoscaling + axes.ymargin padding
1575
-
1576
- # Suppress warning when a single MDC is plotted
1577
- warnings.filterwarnings(
1578
- "ignore",
1579
- message="Attempting to set identical left == right",
1580
- category=UserWarning
1581
- )
1582
-
1583
- # Slider over slice index (0..n_slices-1)
1584
- slider_ax = fig.add_axes([0.2, 0.08, 0.6, 0.04])
1585
- slider = Slider(
1586
- slider_ax, "Index", 0, n_slices - 1,
1587
- valinit=idx, valstep=1
1588
- )
1589
-
1590
- def update(val):
1591
- i = int(slider.val)
1592
- # Update MDC points
1593
- scatter.set_offsets(np.c_[self.angles, intensities[i]])
1594
-
1595
- # Update individuals
1596
- if n_individuals:
1597
- Yi = all_individual_results[i] # (n_individuals, n_angles)
1598
- for j, ln in enumerate(individual_lines):
1599
- ln.set_ydata(Yi[j])
1600
-
1601
- # Update fit and residual
1602
- result_line.set_ydata(all_final_results[i])
1603
- resid_scatter.set_offsets(np.c_[self.angles, all_residuals[i]])
1604
-
1605
- ax.relim()
1606
- ax.autoscale_view()
1607
-
1608
- # Update title and redraw
1609
- ax.set_title(f"Energy slice: "
1610
- f"{energies_sel[i] * kilo:.3f} meV")
1611
- fig.canvas.draw_idle()
1612
-
1613
- slider.on_changed(update)
1614
- self._slider = slider
1615
- self._line = scatter
1616
- self._individual_lines = individual_lines
1617
- self._result_line = result_line
1618
- self._resid_scatter = resid_scatter
1619
-
1620
- ax.set_xlabel("Angle (°)")
1621
- ax.set_ylabel("Counts (-)")
1622
- ax.legend()
1623
- self._fig = fig
1624
-
1625
- if size_kwargs:
1626
- fig.set_size_inches(size_kwargs.pop("w"),
1627
- size_kwargs.pop("h"), **size_kwargs)
1628
- if title:
1629
- fig.suptitle(title)
1630
- if tight_layout:
1631
- fig.tight_layout()
1632
- if savefig:
1633
- fig.savefig(savefig)
1634
- if ax_grid is not None:
1635
- for axis in fig.axes:
1636
- axis.grid(bool(ax_grid))
1637
- if ax_annotate:
1638
- tags = string.ascii_lowercase
1639
- for i, axis in enumerate(fig.axes):
1640
- axis.annotate(f"({tags[i]})", xy=(0.05, 0.95),
1641
- xycoords="axes fraction")
1642
-
1643
- is_interactive = hasattr(sys, 'ps1') or 'ipykernel' in sys.modules
1644
- is_cli = not is_interactive
1645
-
1646
- if show:
1647
- if is_cli:
1648
- plt.show()
1649
- if fig_close:
1650
- plt.close(fig)
1651
-
1652
- if not show and (fig_close or is_cli):
1653
- return None
1654
- return fig
1655
-
1656
-
1657
- @add_fig_kwargs
1658
- def fit(self, distributions, energy_value=None, matrix_element=None,
1659
- matrix_args=None, ax=None, **kwargs):
1660
- r"""
1661
- """
1662
- from copy import deepcopy
1663
- from lmfit import Minimizer
1664
- from .functions import construct_parameters, build_distributions, \
1665
- residual
1666
-
1667
- counts, kinergy = self.energy_check(energy_value)
1668
-
1669
- ax, fig, plt = get_ax_fig_plt(ax=ax)
1670
-
1671
- ax.set_xlabel('Angle ($\\degree$)')
1672
- ax.set_ylabel('Counts (-)')
1673
- ax.set_title(f"Energy slice: "
1674
- f"{(kinergy - self.hnuminphi) * kilo:.3f} meV")
1675
-
1676
- ax.scatter(self.angles, counts, label='Data')
1677
-
1678
- new_distributions = deepcopy(distributions)
1679
-
1680
- if matrix_element is not None:
1681
- parameters, element_names = construct_parameters(distributions,
1682
- matrix_args)
1683
- new_distributions = build_distributions(new_distributions, \
1684
- parameters)
1685
- mini = Minimizer(
1686
- residual, parameters,
1687
- fcn_args=(self.angles, counts, self.angle_resolution,
1688
- new_distributions, kinergy, self.hnuminphi,
1689
- matrix_element, element_names))
1690
- else:
1691
- parameters = construct_parameters(distributions)
1692
- new_distributions = build_distributions(new_distributions,
1693
- parameters)
1694
- mini = Minimizer(residual, parameters,
1695
- fcn_args=(self.angles, counts, self.angle_resolution,
1696
- new_distributions, kinergy, self.hnuminphi))
1697
-
1698
- outcome = mini.minimize('least_squares')
1699
- pcov = outcome.covar
1700
-
1701
- # If matrix params were fitted, pass the fitted values to plotting
1702
- if matrix_element is not None:
1703
- new_matrix_args = {key: outcome.params[key].value for key in
1704
- matrix_args}
1705
- else:
1706
- new_matrix_args = None
1707
-
1708
- final_result = self._merge_and_plot(ax=ax,
1709
- distributions=new_distributions, kinetic_energy=kinergy,
1710
- matrix_element=matrix_element, matrix_args=new_matrix_args,
1711
- plot_individual=True)
1712
-
1713
- residual_vals = counts - final_result
1714
- ax.scatter(self.angles, residual_vals, label='Residual')
1715
- ax.legend()
1716
- if matrix_element is not None:
1717
- return fig, new_distributions, pcov, new_matrix_args
1718
- else:
1719
- return fig, new_distributions, pcov
1720
-
1721
-
1722
- def _merge_and_plot(self, ax, distributions, kinetic_energy,
1723
- matrix_element=None, matrix_args=None,
1724
- plot_individual=True):
1725
- r"""
1726
- Evaluate distributions on the extended grid, apply optional matrix
1727
- element, smooth, plot individuals and the summed curve.
1728
-
1729
- Returns
1730
- -------
1731
- final_result : np.ndarray
1732
- Smoothed, cropped total distribution aligned with self.angles.
1733
- """
1734
- from scipy.ndimage import gaussian_filter
1735
-
1736
- # Build extended grid
1737
- extend, step, numb = extend_function(self.angles, self.angle_resolution)
1738
- total_result = np.zeros_like(extend)
1739
-
1740
- for dist in distributions:
1741
- # Special handling for SpectralQuadratic
1742
- if getattr(dist, 'class_name', None) == 'SpectralQuadratic':
1743
- if (getattr(dist, 'center_angle', None) is not None) and (
1744
- kinetic_energy is None or self.hnuminphi is None
1745
- ):
1746
- raise ValueError(
1747
- 'Spectral quadratic function is defined in terms '
1748
- 'of a center angle. Please provide a kinetic energy '
1749
- 'and hnuminphi.'
1750
- )
1751
- extended_result = dist.evaluate(extend, kinetic_energy, \
1752
- self.hnuminphi)
1753
- else:
1754
- extended_result = dist.evaluate(extend)
1755
-
1756
- # Optional matrix element (only for components that advertise an index)
1757
- if matrix_element is not None and hasattr(dist, 'index'):
1758
- args = matrix_args or {}
1759
- extended_result *= matrix_element(extend, **args)
1760
-
1761
- total_result += extended_result
1762
-
1763
- if plot_individual and ax:
1764
- individual = gaussian_filter(extended_result, sigma=step)\
1765
- [numb:-numb if numb else None]
1766
- ax.plot(self.angles, individual, label=getattr(dist, \
1767
- 'label', str(dist)))
1768
-
1769
- # Smoothed, cropped total curve aligned to self.angles
1770
- final_result = gaussian_filter(total_result, sigma=step)[numb:-numb \
1771
- if numb else None]
1772
- if ax:
1773
- ax.plot(self.angles, final_result, label='Distribution sum')
1774
-
1775
- return final_result
1776
-
1777
-
1778
- def expose_parameters(self, select_label, fermi_wavevector=None,
1779
- fermi_velocity=None, bare_mass=None, side=None):
1780
- r"""
1781
- Select and return fitted parameters for a given component label, plus a
1782
- flat export dictionary containing values **and** 1σ uncertainties.
1783
-
1784
- Parameters
1785
- ----------
1786
- select_label : str
1787
- Label to look for among the fitted distributions.
1788
- fermi_wavevector : float, optional
1789
- Optional Fermi wave vector to include.
1790
- fermi_velocity : float, optional
1791
- Optional Fermi velocity to include.
1792
- bare_mass : float, optional
1793
- Optional bare mass to include (used for SpectralQuadratic
1794
- dispersions).
1795
- side : {'left','right'}, optional
1796
- Optional side selector for SpectralQuadratic dispersions.
1797
-
1798
- Returns
1799
- -------
1800
- ekin_range : np.ndarray
1801
- Kinetic-energy grid corresponding to the selected label.
1802
- hnuminphi : float
1803
- Photoelectron work-function offset.
1804
- label : str
1805
- Label of the selected distribution.
1806
- selected_properties : dict or list of dict
1807
- Nested dictionary (or list thereof) containing <param> and
1808
- <param>_sigma arrays. For SpectralQuadratic components, a
1809
- scalar `center_wavevector` is also present.
1810
- exported_parameters : dict
1811
- Flat dictionary of parameters and their uncertainties, plus
1812
- optional Fermi quantities and `side`. For SpectralQuadratic
1813
- components, `center_wavevector` is included and taken directly
1814
- from the fitted distribution.
1815
- """
1816
-
1817
- if self._ekin_range is None:
1818
- raise AttributeError(
1819
- "ekin_range not yet set. Run `.fit_selection()` first."
1820
- )
1821
-
1822
- store = getattr(self, "_individual_properties", None)
1823
- if not store or select_label not in store:
1824
- all_labels = (sorted(store.keys())
1825
- if isinstance(store, dict) else [])
1826
- raise ValueError(
1827
- f"Label '{select_label}' not found in available labels: "
1828
- f"{all_labels}"
1829
- )
1830
-
1831
- # Convert lists → numpy arrays within the selected label’s classes.
1832
- # Keep scalar center_wavevector as a scalar.
1833
- per_class_dicts = []
1834
- for cls, bucket in store[select_label].items():
1835
- dct = {}
1836
- for k, v in bucket.items():
1837
- if k in ("label", "_class"):
1838
- dct[k] = v
1839
- elif k == "center_wavevector":
1840
- # keep scalar as-is, do not wrap in np.asarray
1841
- dct[k] = v
1842
- else:
1843
- dct[k] = np.asarray(v)
1844
- per_class_dicts.append(dct)
1845
-
1846
- selected_properties = (
1847
- per_class_dicts[0] if len(per_class_dicts) == 1 else per_class_dicts
1848
- )
1849
-
1850
- # Flat export dict: simple keys, includes optional extras
1851
- exported_parameters = {
1852
- "fermi_wavevector": fermi_wavevector,
1853
- "fermi_velocity": fermi_velocity,
1854
- "bare_mass": bare_mass,
1855
- "side": side,
1856
- }
1857
-
1858
- # Collect parameters without prefixing by class. This will also include
1859
- # center_wavevector from the fitted SpectralQuadratic class, and since
1860
- # there is no function argument with that name, it cannot be overridden.
1861
- if isinstance(selected_properties, dict):
1862
- for key, val in selected_properties.items():
1863
- if key not in ("label", "_class"):
1864
- exported_parameters[key] = val
1865
- else:
1866
- # If multiple classes, merge sequentially
1867
- # (last overwrites same-name keys).
1868
- for cls_bucket in selected_properties:
1869
- for key, val in cls_bucket.items():
1870
- if key not in ("label", "_class"):
1871
- exported_parameters[key] = val
1872
-
1873
- return (self._ekin_range, self.hnuminphi, select_label,
1874
- selected_properties, exported_parameters)
1875
-
1876
-
1877
- class SelfEnergy:
1878
- r"""Self-energy (ekin-leading; hnuminphi/ekin are read-only)."""
1879
-
1880
- def __init__(self, ekin_range, hnuminphi, label, properties, parameters):
1881
- # core read-only state
1882
- self._ekin_range = ekin_range
1883
- self._hnuminphi = hnuminphi
1884
- self._label = label
1885
-
1886
- # accept either a dict or a single-element list of dicts
1887
- if isinstance(properties, list):
1888
- if len(properties) == 1:
1889
- properties = properties[0]
1890
- else:
1891
- raise ValueError("`properties` must be a dict or a single " \
1892
- "dict in a list.")
1893
-
1894
- # single source of truth for all params (+ their *_sigma)
1895
- self._properties = dict(properties or {})
1896
- self._class = self._properties.get("_class", None)
1897
-
1898
- # ---- enforce supported classes at construction
1899
- if self._class not in ("SpectralLinear", "SpectralQuadratic"):
1900
- raise ValueError(
1901
- f"Unsupported spectral class '{self._class}'. "
1902
- "Only 'SpectralLinear' or 'SpectralQuadratic' are allowed."
1903
- )
1904
-
1905
- # grab user parameters
1906
- self._parameters = dict(parameters or {})
1907
- self._fermi_wavevector = self._parameters.get("fermi_wavevector")
1908
- self._fermi_velocity = self._parameters.get("fermi_velocity")
1909
- self._bare_mass = self._parameters.get("bare_mass")
1910
- self._side = self._parameters.get("side", None)
1911
-
1912
- # ---- class-specific parameter constraints
1913
- if self._class == "SpectralLinear" and (self._bare_mass is not None):
1914
- raise ValueError("`bare_mass` cannot be set for SpectralLinear.")
1915
- if self._class == "SpectralQuadratic" and (self._fermi_velocity is not None):
1916
- raise ValueError("`fermi_velocity` cannot be set for SpectralQuadratic.")
1917
-
1918
- if self._side is not None and self._side not in ("left", "right"):
1919
- raise ValueError("`side` must be 'left' or 'right' if provided.")
1920
- if self._side is not None:
1921
- self._parameters["side"] = self._side
1922
-
1923
- # convenience attributes (read from properties)
1924
- self._amplitude = self._properties.get("amplitude")
1925
- self._amplitude_sigma = self._properties.get("amplitude_sigma")
1926
- self._peak = self._properties.get("peak")
1927
- self._peak_sigma = self._properties.get("peak_sigma")
1928
- self._broadening = self._properties.get("broadening")
1929
- self._broadening_sigma = self._properties.get("broadening_sigma")
1930
- self._center_wavevector = self._properties.get("center_wavevector")
1931
-
1932
- # lazy caches
1933
- self._peak_positions = None
1934
- self._peak_positions_sigma = None
1935
- self._real = None
1936
- self._real_sigma = None
1937
- self._imag = None
1938
- self._imag_sigma = None
1939
-
1940
- def _check_mass_velocity_exclusivity(self):
1941
- """Ensure that fermi_velocity and bare_mass are not both set."""
1942
- if (self._fermi_velocity is not None) and (self._bare_mass is not None):
1943
- raise ValueError(
1944
- "Cannot set both `fermi_velocity` and `bare_mass`: "
1945
- "choose one physical parametrization (SpectralLinear or SpectralQuadratic)."
1946
- )
1947
-
1948
- # ---------------- core read-only axes ----------------
1949
- @property
1950
- def ekin_range(self):
1951
- return self._ekin_range
1952
-
1953
- @property
1954
- def enel_range(self):
1955
- if self._ekin_range is None:
1956
- return None
1957
- hnp = 0.0 if self._hnuminphi is None else self._hnuminphi
1958
- return np.asarray(self._ekin_range) - hnp
1959
-
1960
- @property
1961
- def hnuminphi(self):
1962
- return self._hnuminphi
1963
-
1964
- # ---------------- identifiers ----------------
1965
- @property
1966
- def label(self):
1967
- return self._label
1968
-
1969
- @label.setter
1970
- def label(self, x):
1971
- self._label = x
1972
-
1973
- # ---------------- exported user parameters ----------------
1974
- @property
1975
- def parameters(self):
1976
- """Dictionary with user-supplied parameters (read-only view)."""
1977
- return self._parameters
1978
-
1979
- @property
1980
- def side(self):
1981
- """Optional side selector: 'left' or 'right'."""
1982
- return self._side
1983
-
1984
- @side.setter
1985
- def side(self, x):
1986
- if x is not None and x not in ("left", "right"):
1987
- raise ValueError("`side` must be 'left' or 'right' if provided.")
1988
- self._side = x
1989
- if x is not None:
1990
- self._parameters["side"] = x
1991
- else:
1992
- self._parameters.pop("side", None)
1993
- # affects sign of peak_positions and thus `real`
1994
- self._peak_positions = None
1995
- self._real = None
1996
- self._real_sigma = None
1997
- self._mdc_maxima = None
1998
-
1999
- @property
2000
- def fermi_wavevector(self):
2001
- """Optional k_F; can be set later."""
2002
- return self._fermi_wavevector
2003
-
2004
- @fermi_wavevector.setter
2005
- def fermi_wavevector(self, x):
2006
- self._fermi_wavevector = x
2007
- self._parameters["fermi_wavevector"] = x
2008
- # invalidate dependent cache
2009
- self._real = None
2010
- self._real_sigma = None
2011
-
2012
- @property
2013
- def fermi_velocity(self):
2014
- """Optional v_F; can be set later."""
2015
- return self._fermi_velocity
2016
-
2017
- @fermi_velocity.setter
2018
- def fermi_velocity(self, x):
2019
- if self._class == "SpectralQuadratic":
2020
- raise ValueError("`fermi_velocity` cannot be set for" \
2021
- " SpectralQuadratic.")
2022
- self._fermi_velocity = x
2023
- self._parameters["fermi_velocity"] = x
2024
- # invalidate dependents
2025
- self._imag = None; self._imag_sigma = None
2026
- self._real = None; self._real_sigma = None
2027
-
2028
- @property
2029
- def bare_mass(self):
2030
- """Optional bare mass; used by SpectralQuadratic formulas."""
2031
- return self._bare_mass
2032
-
2033
- @bare_mass.setter
2034
- def bare_mass(self, x):
2035
- if self._class == "SpectralLinear":
2036
- raise ValueError("`bare_mass` cannot be set for SpectralLinear.")
2037
- self._bare_mass = x
2038
- self._parameters["bare_mass"] = x
2039
- # invalidate dependents
2040
- self._imag = None; self._imag_sigma = None
2041
- self._real = None; self._real_sigma = None
2042
-
2043
- # ---------------- optional fit parameters (convenience) ----------------
2044
- @property
2045
- def amplitude(self):
2046
- return self._amplitude
2047
-
2048
- @amplitude.setter
2049
- def amplitude(self, x):
2050
- self._amplitude = x
2051
- self._properties["amplitude"] = x
2052
-
2053
- @property
2054
- def amplitude_sigma(self):
2055
- return self._amplitude_sigma
2056
-
2057
- @amplitude_sigma.setter
2058
- def amplitude_sigma(self, x):
2059
- self._amplitude_sigma = x
2060
- self._properties["amplitude_sigma"] = x
2061
-
2062
- @property
2063
- def peak(self):
2064
- return self._peak
2065
-
2066
- @peak.setter
2067
- def peak(self, x):
2068
- self._peak = x
2069
- self._properties["peak"] = x
2070
- # invalidate dependent cache
2071
- self._peak_positions = None
2072
- self._real = None
2073
- self._mdc_maxima = None
2074
-
2075
- @property
2076
- def peak_sigma(self):
2077
- return self._peak_sigma
2078
-
2079
- @peak_sigma.setter
2080
- def peak_sigma(self, x):
2081
- self._peak_sigma = x
2082
- self._properties["peak_sigma"] = x
2083
- self._peak_positions_sigma = None
2084
- self._real_sigma = None
2085
-
2086
- @property
2087
- def broadening(self):
2088
- return self._broadening
2089
-
2090
- @broadening.setter
2091
- def broadening(self, x):
2092
- self._broadening = x
2093
- self._properties["broadening"] = x
2094
- self._imag = None
2095
-
2096
- @property
2097
- def broadening_sigma(self):
2098
- return self._broadening_sigma
2099
-
2100
- @broadening_sigma.setter
2101
- def broadening_sigma(self, x):
2102
- self._broadening_sigma = x
2103
- self._properties["broadening_sigma"] = x
2104
- self._imag_sigma = None
2105
-
2106
- @property
2107
- def center_wavevector(self):
2108
- """Read-only center wavevector (SpectralQuadratic, if present)."""
2109
- return self._center_wavevector
2110
-
2111
- # ---------------- derived outputs ----------------
2112
- @property
2113
- def peak_positions(self):
2114
- r"""k_parallel = peak * dtor * sqrt(ekin_range / pref) (lazy)."""
2115
- if self._peak_positions is None:
2116
- if self._peak is None or self._ekin_range is None:
2117
- return None
2118
- if self._class == "SpectralQuadratic":
2119
- if self._side is None:
2120
- raise AttributeError(
2121
- "For SpectralQuadratic, set `side` ('left'/'right') "
2122
- "before accessing peak_positions and quantities that "
2123
- "depend on the latter."
2124
- )
2125
- kpar_mag = np.sqrt(self._ekin_range / pref) * \
2126
- np.sin(np.abs(self._peak) * dtor)
2127
- self._peak_positions = (-1.0 if self._side == "left" \
2128
- else 1.0) * kpar_mag
2129
- else:
2130
- self._peak_positions = np.sqrt(self._ekin_range / pref) \
2131
- * np.sin(self._peak * dtor)
2132
- return self._peak_positions
2133
-
2134
- @property
2135
- def peak_positions_sigma(self):
2136
- r"""Std. dev. of k_parallel (lazy)."""
2137
- if self._peak_positions_sigma is None:
2138
- if self._peak_sigma is None or self._ekin_range is None:
2139
- return None
2140
- self._peak_positions_sigma = (np.sqrt(self._ekin_range / pref)
2141
- * np.abs(np.cos(self._peak * dtor))
2142
- * self._peak_sigma * dtor)
2143
- return self._peak_positions_sigma
2144
-
2145
- @property
2146
- def imag(self):
2147
- r"""-Σ'' (lazy)."""
2148
- if self._imag is None:
2149
- if self._broadening is None or self._ekin_range is None:
2150
- return None
2151
- if self._class == "SpectralLinear":
2152
- if self._fermi_velocity is None:
2153
- raise AttributeError("Cannot compute `imag` "
2154
- "(SpectralLinear): set `fermi_velocity` first.")
2155
- self._imag = np.abs(self._fermi_velocity) * np.sqrt(self._ekin_range \
2156
- / pref) * self._broadening
2157
- else:
2158
- if self._bare_mass is None:
2159
- raise AttributeError("Cannot compute `imag` "
2160
- "(SpectralQuadratic): set `bare_mass` first.")
2161
- self._imag = (self._ekin_range * self._broadening) \
2162
- / np.abs(self._bare_mass)
2163
- return self._imag
2164
-
2165
- @property
2166
- def imag_sigma(self):
2167
- r"""Std. dev. of -Σ'' (lazy)."""
2168
- if self._imag_sigma is None:
2169
- if self._broadening_sigma is None or self._ekin_range is None:
2170
- return None
2171
- if self._class == "SpectralLinear":
2172
- if self._fermi_velocity is None:
2173
- raise AttributeError("Cannot compute `imag_sigma` "
2174
- "(SpectralLinear): set `fermi_velocity` first.")
2175
- self._imag_sigma = np.abs(self._fermi_velocity) * \
2176
- np.sqrt(self._ekin_range / pref) * self._broadening_sigma
2177
- else:
2178
- if self._bare_mass is None:
2179
- raise AttributeError("Cannot compute `imag_sigma` "
2180
- "(SpectralQuadratic): set `bare_mass` first.")
2181
- self._imag_sigma = (self._ekin_range * \
2182
- self._broadening_sigma) / np.abs(self._bare_mass)
2183
- return self._imag_sigma
2184
-
2185
- @property
2186
- def real(self):
2187
- r"""Σ' (lazy)."""
2188
- if self._real is None:
2189
- if self._peak is None or self._ekin_range is None:
2190
- return None
2191
- if self._class == "SpectralLinear":
2192
- if self._fermi_velocity is None or self._fermi_wavevector is None:
2193
- raise AttributeError("Cannot compute `real` "
2194
- "(SpectralLinear): set `fermi_velocity` and " \
2195
- "`fermi_wavevector` first.")
2196
- self._real = self.enel_range - self._fermi_velocity * \
2197
- (self.peak_positions - self._fermi_wavevector)
2198
- else:
2199
- if self._bare_mass is None or self._fermi_wavevector is None:
2200
- raise AttributeError("Cannot compute `real` "
2201
- "(SpectralQuadratic): set `bare_mass` and " \
2202
- "`fermi_wavevector` first.")
2203
- self._real = self.enel_range - (pref / \
2204
- self._bare_mass) * (self.peak_positions**2 \
2205
- - self._fermi_wavevector**2)
2206
- return self._real
2207
-
2208
- @property
2209
- def real_sigma(self):
2210
- r"""Std. dev. of Σ' (lazy)."""
2211
- if self._real_sigma is None:
2212
- if self._peak_sigma is None or self._ekin_range is None:
2213
- return None
2214
- if self._class == "SpectralLinear":
2215
- if self._fermi_velocity is None:
2216
- raise AttributeError("Cannot compute `real_sigma` "
2217
- "(SpectralLinear): set `fermi_velocity` first.")
2218
- self._real_sigma = np.abs(self._fermi_velocity) * self.peak_positions_sigma
2219
- else:
2220
- if self._bare_mass is None or self._fermi_wavevector is None:
2221
- raise AttributeError("Cannot compute `real_sigma` "
2222
- "(SpectralQuadratic): set `bare_mass` and " \
2223
- "`fermi_wavevector` first.")
2224
- self._real_sigma = 2 * pref * self.peak_positions_sigma \
2225
- * np.abs(self.peak_positions / self._bare_mass)
2226
- return self._real_sigma
2227
-
2228
- @property
2229
- def mdc_maxima(self):
2230
- """
2231
- MDC maxima (lazy).
2232
-
2233
- SpectralLinear:
2234
- identical to peak_positions
2235
-
2236
- SpectralQuadratic:
2237
- peak_positions + center_wavevector
2238
- """
2239
- if getattr(self, "_mdc_maxima", None) is None:
2240
- if self.peak_positions is None:
2241
- return None
2242
-
2243
- if self._class == "SpectralLinear":
2244
- self._mdc_maxima = self.peak_positions
2245
- elif self._class == "SpectralQuadratic":
2246
- self._mdc_maxima = (
2247
- self.peak_positions + self._center_wavevector
2248
- )
2249
-
2250
- return self._mdc_maxima
2251
-
2252
- def _se_legend_labels(self):
2253
- """Return (real_label, imag_label) for legend with safe subscripts."""
2254
- se_label = getattr(self, "label", None)
2255
-
2256
- if se_label is None:
2257
- real_label = r"$\Sigma'(E)$"
2258
- imag_label = r"$-\Sigma''(E)$"
2259
- return real_label, imag_label
2260
-
2261
- safe_label = str(se_label).replace("_", r"\_")
2262
-
2263
- # If the label is empty after conversion, fall back
2264
- if safe_label == "":
2265
- real_label = r"$\Sigma'(E)$"
2266
- imag_label = r"$-\Sigma''(E)$"
2267
- return real_label, imag_label
2268
-
2269
- real_label = rf"$\Sigma_{{\mathrm{{{safe_label}}}}}'(E)$"
2270
- imag_label = rf"$-\Sigma_{{\mathrm{{{safe_label}}}}}''(E)$"
2271
-
2272
- return real_label, imag_label
2273
-
2274
- @add_fig_kwargs
2275
- def plot_real(self, ax=None, **kwargs):
2276
- r"""Plot the real part Σ' of the self-energy as a function of E-μ.
2277
-
2278
- Parameters
2279
- ----------
2280
- ax : Matplotlib-Axes or None
2281
- Axis to plot on. Created if not provided by the user.
2282
- **kwargs :
2283
- Additional keyword arguments passed to ``ax.errorbar``.
2284
-
2285
- Returns
2286
- -------
2287
- fig : Matplotlib-Figure
2288
- Figure containing the Σ'(E) plot.
2289
- """
2290
-
2291
- ax, fig, plt = get_ax_fig_plt(ax=ax)
2292
-
2293
- x = self.enel_range
2294
- y = self.real
2295
- y_sigma = self.real_sigma
2296
-
2297
- real_label, _ = self._se_legend_labels()
2298
- kwargs.setdefault("label", real_label)
2299
-
2300
- if y_sigma is not None:
2301
- if np.isnan(y_sigma).any():
2302
- print(
2303
- "Warning: some Σ'(E) uncertainty values are missing. "
2304
- "Error bars omitted at those energies."
2305
- )
2306
- kwargs.setdefault("yerr", stdv * y_sigma)
2307
-
2308
- ax.errorbar(x, y, **kwargs)
2309
- ax.set_xlabel(r"$E-\mu$ (eV)")
2310
- ax.set_ylabel(r"$\Sigma'(E)$ (eV)")
2311
- ax.legend()
2312
-
2313
- return fig
2314
-
2315
- @add_fig_kwargs
2316
- def plot_imag(self, ax=None, **kwargs):
2317
- r"""Plot the imaginary part -Σ'' of the self-energy vs. E-μ.
2318
-
2319
- Parameters
2320
- ----------
2321
- ax : Matplotlib-Axes or None
2322
- Axis to plot on. Created if not provided by the user.
2323
- **kwargs :
2324
- Additional keyword arguments passed to ``ax.errorbar``.
2325
-
2326
- Returns
2327
- -------
2328
- fig : Matplotlib-Figure
2329
- Figure containing the -Σ''(E) plot.
2330
- """
2331
-
2332
- ax, fig, plt = get_ax_fig_plt(ax=ax)
2333
-
2334
- x = self.enel_range
2335
- y = self.imag
2336
- y_sigma = self.imag_sigma
2337
-
2338
- _, imag_label = self._se_legend_labels()
2339
- kwargs.setdefault("label", imag_label)
2340
-
2341
- if y_sigma is not None:
2342
- if np.isnan(y_sigma).any():
2343
- print(
2344
- "Warning: some -Σ''(E) uncertainty values are missing. "
2345
- "Error bars omitted at those energies."
2346
- )
2347
- kwargs.setdefault("yerr", stdv * y_sigma)
2348
-
2349
- ax.errorbar(x, y, **kwargs)
2350
- ax.set_xlabel(r"$E-\mu$ (eV)")
2351
- ax.set_ylabel(r"$-\Sigma''(E)$ (eV)")
2352
- ax.legend()
2353
-
2354
- return fig
2355
-
2356
- @add_fig_kwargs
2357
- def plot_both(self, ax=None, **kwargs):
2358
- r"""Plot Σ'(E) and -Σ''(E) vs. E-μ on the same axis."""
2359
-
2360
- ax, fig, plt = get_ax_fig_plt(ax=ax)
2361
-
2362
- x = self.enel_range
2363
- real = self.real
2364
- imag = self.imag
2365
- real_sigma = self.real_sigma
2366
- imag_sigma = self.imag_sigma
2367
-
2368
- real_label, imag_label = self._se_legend_labels()
2369
-
2370
- # --- plot Σ'
2371
- kw_real = dict(kwargs)
2372
- if real_sigma is not None:
2373
- if np.isnan(real_sigma).any():
2374
- print(
2375
- "Warning: some Σ'(E) uncertainty values are missing. "
2376
- "Error bars omitted at those energies."
2377
- )
2378
- kw_real.setdefault("yerr", stdv * real_sigma)
2379
- kw_real.setdefault("label", real_label)
2380
- ax.errorbar(x, real, **kw_real)
2381
-
2382
- # --- plot -Σ''
2383
- kw_imag = dict(kwargs)
2384
- if imag_sigma is not None:
2385
- if np.isnan(imag_sigma).any():
2386
- print(
2387
- "Warning: some -Σ''(E) uncertainty values are missing. "
2388
- "Error bars omitted at those energies."
2389
- )
2390
- kw_imag.setdefault("yerr", stdv * imag_sigma)
2391
- kw_imag.setdefault("label", imag_label)
2392
- ax.errorbar(x, imag, **kw_imag)
2393
-
2394
- ax.set_xlabel(r"$E-\mu$ (eV)")
2395
- ax.set_ylabel(r"$\Sigma'(E),\ -\Sigma''(E)$ (eV)")
2396
- ax.legend()
2397
-
2398
- return fig
2399
-
2400
-
2401
- class CreateSelfEnergies:
2402
- r"""
2403
- Thin container for self-energies with leaf-aware utilities.
2404
- All items are assumed to be leaf self-energy objects with
2405
- a `.label` attribute for identification.
2406
- """
2407
-
2408
- def __init__(self, self_energies):
2409
- self.self_energies = self_energies
2410
-
2411
- # ------ Basic container protocol ------
2412
- def __call__(self):
2413
- return self.self_energies
2414
-
2415
- @property
2416
- def self_energies(self):
2417
- return self._self_energies
2418
-
2419
- @self_energies.setter
2420
- def self_energies(self, x):
2421
- self._self_energies = x
2422
-
2423
- def __iter__(self):
2424
- return iter(self.self_energies)
2425
-
2426
- def __getitem__(self, index):
2427
- return self.self_energies[index]
2428
-
2429
- def __setitem__(self, index, value):
2430
- self.self_energies[index] = value
2431
-
2432
- def __len__(self):
2433
- return len(self.self_energies)
2434
-
2435
- def __deepcopy__(self, memo):
2436
- import copy
2437
- return type(self)(copy.deepcopy(self.self_energies, memo))
2438
-
2439
- # ------ Label-based utilities ------
2440
- def get_by_label(self, label):
2441
- r"""
2442
- Return the self-energy object with the given label.
2443
-
2444
- Parameters
2445
- ----------
2446
- label : str
2447
- Label of the self-energy to retrieve.
2448
-
2449
- Returns
2450
- -------
2451
- obj : SelfEnergy
2452
- The corresponding self-energy instance.
2453
-
2454
- Raises
2455
- ------
2456
- KeyError
2457
- If no self-energy with the given label exists.
2458
- """
2459
- for se in self.self_energies:
2460
- if getattr(se, "label", None) == label:
2461
- return se
2462
- raise KeyError(
2463
- f"No self-energy with label {label!r} found in container."
2464
- )
2465
-
2466
- def labels(self):
2467
- r"""
2468
- Return a list of all labels.
2469
- """
2470
- return [getattr(se, "label", None) for se in self.self_energies]
2471
-
2472
- def as_dict(self):
2473
- r"""
2474
- Return a {label: self_energy} dictionary for convenient access.
2475
- """
2476
- return {se.label: se for se in self.self_energies}