xarpes 0.2.3__py3-none-any.whl → 0.3.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 ADDED
@@ -0,0 +1,2067 @@
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
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
+
350
+ @add_fig_kwargs
351
+ def plot(self, abscissa='momentum', ordinate='electron_energy',
352
+ self_energies=None, ax=None, **kwargs):
353
+ r"""
354
+ Plot the band map. Optionally attach a collection of self-energies,
355
+ e.g. a CreateSelfEnergies instance or any iterable of self-energy
356
+ objects. They are stored on `self` for later overlay plotting.
357
+ """
358
+
359
+ # Optionally store self-energies on the instance
360
+ if self_energies is not None:
361
+ # You can wrap here if you like, e.g.:
362
+ # from .containers import CreateSelfEnergies
363
+ # if not isinstance(self_energies, CreateSelfEnergies):
364
+ # self_energies = CreateSelfEnergies(self_energies)
365
+ self._self_energies = self_energies
366
+ elif not hasattr(self, "_self_energies"):
367
+ self._self_energies = None
368
+
369
+ # Validate options early
370
+ valid_abscissa = ('angle', 'momentum')
371
+ valid_ordinate = ('kinetic_energy', 'electron_energy')
372
+
373
+ if abscissa not in valid_abscissa:
374
+ raise ValueError(
375
+ f"Invalid abscissa '{abscissa}'. "
376
+ f"Valid options: {valid_abscissa}"
377
+ )
378
+ if ordinate not in valid_ordinate:
379
+ raise ValueError(
380
+ f"Invalid ordinate '{ordinate}'. "
381
+ f"Valid options: {valid_ordinate}"
382
+ )
383
+
384
+ ax, fig, plt = get_ax_fig_plt(ax=ax)
385
+
386
+ Angl, Ekin = np.meshgrid(self.angles, self.ekin)
387
+ mesh = None # sentinel to detect missing branch
388
+
389
+ if abscissa == 'angle':
390
+ ax.set_xlabel('Angle ($\\degree$)')
391
+ if ordinate == 'kinetic_energy':
392
+ mesh = ax.pcolormesh(
393
+ Angl, Ekin, self.intensities, shading='auto',
394
+ cmap=plt.get_cmap('bone').reversed(), **kwargs
395
+ )
396
+ ax.set_ylabel('$E_{\\mathrm{kin}}$ (eV)')
397
+ elif ordinate == 'electron_energy':
398
+ Enel = Ekin - self.hnuminphi
399
+ mesh = ax.pcolormesh(
400
+ Angl, Enel, self.intensities, shading='auto',
401
+ cmap=plt.get_cmap('bone').reversed(), **kwargs
402
+ )
403
+ ax.set_ylabel('$E-\\mu$ (eV)')
404
+
405
+ elif abscissa == 'momentum':
406
+ Mome = np.sqrt(Ekin / pref) * np.sin(Angl * dtor)
407
+ ax.set_xlabel(r'$k_{//}$ ($\mathrm{\AA}^{-1}$)')
408
+ if ordinate == 'kinetic_energy':
409
+ mesh = ax.pcolormesh(
410
+ Mome, Ekin, self.intensities, shading='auto',
411
+ cmap=plt.get_cmap('bone').reversed(), **kwargs
412
+ )
413
+ ax.set_ylabel('$E_{\\mathrm{kin}}$ (eV)')
414
+ elif ordinate == 'electron_energy':
415
+ Enel = Ekin - self.hnuminphi
416
+ mesh = ax.pcolormesh(
417
+ Mome, Enel, self.intensities, shading='auto',
418
+ cmap=plt.get_cmap('bone').reversed(), **kwargs
419
+ )
420
+ ax.set_ylabel('$E-\\mu$ (eV)')
421
+
422
+ # If no branch set 'mesh', fail with a clear message
423
+ if mesh is None:
424
+ raise RuntimeError(
425
+ "No plot produced for the combination: "
426
+ f"abscissa='{abscissa}', ordinate='{ordinate}'. "
427
+ f"Valid abscissa: {valid_abscissa}; "
428
+ f"valid ordinate: {valid_ordinate}."
429
+ )
430
+
431
+ plt.colorbar(mesh, ax=ax, label='counts (-)')
432
+ return fig
433
+
434
+
435
+ @add_fig_kwargs
436
+ def fit_fermi_edge(self, hnuminphi_guess, background_guess=0.0,
437
+ integrated_weight_guess=1.0, angle_min=-np.inf,
438
+ angle_max=np.inf, ekin_min=-np.inf,
439
+ ekin_max=np.inf, ax=None, **kwargs):
440
+ r"""Fits the Fermi edge of the band map and plots the result.
441
+ Also sets hnuminphi, the kinetic energy minus the work function in eV.
442
+ The fitting includes an energy convolution with an abscissa range
443
+ expanded by 5 times the energy resolution standard deviation.
444
+
445
+ Parameters
446
+ ----------
447
+ hnuminphi_guess : float
448
+ Initial guess for kinetic energy minus the work function [eV]
449
+ background_guess : float
450
+ Initial guess for background intensity [counts]
451
+ integrated_weight_guess : float
452
+ Initial guess for integrated spectral intensity [counts]
453
+ angle_min : float
454
+ Minimum angle of integration interval [degrees]
455
+ angle_max : float
456
+ Maximum angle of integration interval [degrees]
457
+ ekin_min : float
458
+ Minimum kinetic energy of integration interval [eV]
459
+ ekin_max : float
460
+ Maximum kinetic energy of integration interval [eV]
461
+ ax : Matplotlib-Axes / NoneType
462
+ Axis for plotting the Fermi edge on. Created if not provided by
463
+ the user.
464
+
465
+ Other parameters
466
+ ----------------
467
+ **kwargs : dict, optional
468
+ Additional arguments passed on to add_fig_kwargs.
469
+
470
+ Returns
471
+ -------
472
+ fig : Matplotlib-Figure
473
+ Figure containing the Fermi edge fit
474
+
475
+ """
476
+ from scipy.ndimage import gaussian_filter
477
+
478
+ ax, fig, plt = get_ax_fig_plt(ax=ax)
479
+
480
+ min_angle_index = np.argmin(np.abs(self.angles - angle_min))
481
+ max_angle_index = np.argmin(np.abs(self.angles - angle_max))
482
+
483
+ min_ekin_index = np.argmin(np.abs(self.ekin - ekin_min))
484
+ max_ekin_index = np.argmin(np.abs(self.ekin - ekin_max))
485
+
486
+ energy_range = self.ekin[min_ekin_index:max_ekin_index]
487
+
488
+ integrated_intensity = np.trapz(
489
+ self.intensities[min_ekin_index:max_ekin_index,
490
+ min_angle_index:max_angle_index], axis=1)
491
+
492
+ fdir_initial = FermiDirac(temperature=self.temperature,
493
+ hnuminphi=hnuminphi_guess,
494
+ background=background_guess,
495
+ integrated_weight=integrated_weight_guess,
496
+ name='Initial guess')
497
+
498
+ parameters = np.array(
499
+ [hnuminphi_guess, background_guess, integrated_weight_guess])
500
+
501
+ extra_args = (self.temperature,)
502
+
503
+ popt, pcov = fit_leastsq(
504
+ parameters, energy_range, integrated_intensity, fdir_initial,
505
+ self.energy_resolution, None, *extra_args)
506
+
507
+ # Update hnuminphi; automatically sets self.enel
508
+ self.hnuminphi = popt[0]
509
+ self.hnuminphi_std = np.sqrt(np.diag(pcov)[0])
510
+
511
+ fdir_final = FermiDirac(temperature=self.temperature,
512
+ hnuminphi=self.hnuminphi, background=popt[1],
513
+ integrated_weight=popt[2],
514
+ name='Fitted result')
515
+
516
+ ax.set_xlabel(r'$E_{\mathrm{kin}}$ (-)')
517
+ ax.set_ylabel('Counts (-)')
518
+ ax.set_xlim([ekin_min, ekin_max])
519
+
520
+ ax.plot(energy_range, integrated_intensity, label='Data')
521
+
522
+ extend, step, numb = extend_function(energy_range,
523
+ self.energy_resolution)
524
+
525
+ initial_result = gaussian_filter(fdir_initial.evaluate(extend),
526
+ sigma=step)[numb:-numb if numb else None]
527
+
528
+ final_result = gaussian_filter(fdir_final.evaluate(extend),
529
+ sigma=step)[numb:-numb if numb else None]
530
+
531
+ ax.plot(energy_range, initial_result, label=fdir_initial.name)
532
+ ax.plot(energy_range, final_result, label=fdir_final.name)
533
+
534
+ ax.legend()
535
+
536
+ return fig
537
+
538
+
539
+ @add_fig_kwargs
540
+ def correct_fermi_edge(self, hnuminphi_guess=None, background_guess=0.0,
541
+ integrated_weight_guess=1.0, angle_min=-np.inf,
542
+ angle_max=np.inf, ekin_min=-np.inf, ekin_max=np.inf,
543
+ slope_guess=0, offset_guess=None,
544
+ true_angle=0, ax=None, **kwargs):
545
+ r"""TBD
546
+ hnuminphi_guess should be estimate at true angle
547
+
548
+ Parameters
549
+ ----------
550
+ hnuminphi_guess : float, optional
551
+ Initial guess for kinetic energy minus the work function [eV].
552
+
553
+ Other parameters
554
+ ----------------
555
+ **kwargs : dict, optional
556
+ Additional arguments passed on to add_fig_kwargs.
557
+
558
+ Returns
559
+ -------
560
+ fig : Matplotlib-Figure
561
+ Figure containing the Fermi edge fit
562
+
563
+ """
564
+ from scipy.ndimage import map_coordinates
565
+
566
+ if hnuminphi_guess is None:
567
+ raise ValueError('Please provide an initial guess for ' +
568
+ 'hnuminphi.')
569
+
570
+ # Here some loop where it fits all the Fermi edges
571
+ angle_min_index = np.abs(self.angles - angle_min).argmin()
572
+ angle_max_index = np.abs(self.angles - angle_max).argmin()
573
+
574
+ ekin_min_index = np.abs(self.ekin - ekin_min).argmin()
575
+ ekin_max_index = np.abs(self.ekin - ekin_max).argmin()
576
+
577
+ Intensities = self.intensities[ekin_min_index:ekin_max_index + 1,
578
+ angle_min_index:angle_max_index + 1]
579
+ angle_range = self.angles[angle_min_index:angle_max_index + 1]
580
+ energy_range = self.ekin[ekin_min_index:ekin_max_index + 1]
581
+
582
+ angle_shape = angle_range.shape
583
+ nmps = np.zeros(angle_shape)
584
+ stds = np.zeros(angle_shape)
585
+
586
+ hnuminphi_left = hnuminphi_guess - (true_angle - angle_min) \
587
+ * slope_guess
588
+
589
+ fdir_initial = FermiDirac(temperature=self.temperature,
590
+ hnuminphi=hnuminphi_left,
591
+ background=background_guess,
592
+ integrated_weight=integrated_weight_guess,
593
+ name='Initial guess')
594
+
595
+ parameters = np.array(
596
+ [hnuminphi_left, background_guess, integrated_weight_guess])
597
+
598
+ extra_args = (self.temperature,)
599
+
600
+ for indx in range(angle_max_index - angle_min_index + 1):
601
+ edge = Intensities[:, indx]
602
+
603
+ parameters, pcov = fit_leastsq(
604
+ parameters, energy_range, edge, fdir_initial,
605
+ self.energy_resolution, None, *extra_args)
606
+
607
+ nmps[indx] = parameters[0]
608
+ stds[indx] = np.sqrt(np.diag(pcov)[0])
609
+
610
+ # Offset at true angle if not set before
611
+ if offset_guess is None:
612
+ offset_guess = hnuminphi_guess - slope_guess * true_angle
613
+
614
+ parameters = np.array([offset_guess, slope_guess])
615
+
616
+ lin_fun = Linear(offset_guess, slope_guess, 'Linear')
617
+
618
+ popt, pcov = fit_leastsq(parameters, angle_range, nmps, lin_fun, None,
619
+ stds)
620
+
621
+ linsp = lin_fun(angle_range, popt[0], popt[1])
622
+
623
+ # Update hnuminphi; automatically sets self.enel
624
+ self.hnuminphi = lin_fun(true_angle, popt[0], popt[1])
625
+ self.hnuminphi_std = np.sqrt(true_angle**2 * pcov[1, 1] + pcov[0, 0]
626
+ + 2 * true_angle * pcov[0, 1])
627
+
628
+ Angl, Ekin = np.meshgrid(self.angles, self.ekin)
629
+
630
+ ax, fig, plt = get_ax_fig_plt(ax=ax)
631
+
632
+ ax.set_xlabel('Angle ($\degree$)')
633
+ ax.set_ylabel('$E_{\mathrm{kin}}$ (eV)')
634
+ mesh = ax.pcolormesh(Angl, Ekin, self.intensities,
635
+ shading='auto', cmap=plt.get_cmap('bone').reversed(),
636
+ zorder=1)
637
+
638
+ ax.errorbar(angle_range, nmps, yerr=uncr * stds, zorder=1)
639
+ ax.plot(angle_range, linsp, zorder=2)
640
+
641
+ cbar = plt.colorbar(mesh, ax=ax, label='counts (-)')
642
+
643
+ # Fermi-edge correction
644
+ rows, cols = self.intensities.shape
645
+ shift_values = popt[1] * self.angles / (self.ekin[0] - self.ekin[1])
646
+ row_coords = np.arange(rows).reshape(-1, 1) - shift_values
647
+ col_coords = np.arange(cols).reshape(1, -1).repeat(rows, axis=0)
648
+ self.intensities = map_coordinates(self.intensities,
649
+ [row_coords, col_coords], order=1)
650
+
651
+ return fig
652
+
653
+
654
+ class MDCs:
655
+ r"""
656
+ Container for momentum distribution curves (MDCs) and their fits.
657
+
658
+ This class stores the MDC intensity maps, angular and energy grids, and
659
+ the aggregated fit results produced by :meth:`fit_selection`.
660
+
661
+ Parameters
662
+ ----------
663
+ intensities : ndarray
664
+ MDC intensity data. Typically a 2D array with shape
665
+ ``(n_energy, n_angle)`` or a 1D array for a single curve.
666
+ angles : ndarray
667
+ Angular grid corresponding to the MDCs [degrees].
668
+ angle_resolution : float
669
+ Angular step size or effective angular resolution [degrees].
670
+ enel : ndarray or float
671
+ Electron binding energies of the MDC slices [eV].
672
+ Can be a scalar for a single MDC.
673
+ hnuminphi : float
674
+ Photon energy minus work function, used to convert ``enel`` to
675
+ kinetic energy [eV].
676
+
677
+ Attributes
678
+ ----------
679
+ intensities : ndarray
680
+ MDC intensity data (same object as passed to the constructor).
681
+ angles : ndarray
682
+ Angular grid [degrees].
683
+ angle_resolution : float
684
+ Angular step size or resolution [degrees].
685
+ enel : ndarray or float
686
+ Electron binding energies [eV], as given at construction.
687
+ ekin : ndarray or float
688
+ Kinetic energies [eV], computed as ``enel + hnuminphi``.
689
+ hnuminphi : float
690
+ Photon energy minus work function [eV].
691
+ ekin_range : ndarray
692
+ Kinetic-energy values of the slices that were actually fitted.
693
+ Set by :meth:`fit_selection`.
694
+ individual_properties : dict
695
+ Nested mapping of fitted parameters and their uncertainties for each
696
+ component and each energy slice. Populated by :meth:`fit_selection`.
697
+
698
+ Notes
699
+ -----
700
+ After calling :meth:`fit_selection`, :attr:`individual_properties` has the
701
+ structure::
702
+
703
+ {
704
+ label: {
705
+ class_name: {
706
+ 'label': label,
707
+ '_class': class_name,
708
+ param: [values per energy slice],
709
+ param_sigma: [1σ per slice or None],
710
+ ...
711
+ }
712
+ }
713
+ }
714
+
715
+ where ``param`` is typically one of ``'offset'``, ``'slope'``,
716
+ ``'amplitude'``, ``'peak'``, ``'broadening'``, and ``param_sigma`` stores
717
+ the corresponding uncertainty for each slice.
718
+
719
+ """
720
+
721
+ def __init__(self, intensities, angles, angle_resolution, enel, hnuminphi):
722
+ # Core input data (read-only)
723
+ self._intensities = intensities
724
+ self._angles = angles
725
+ self._angle_resolution = angle_resolution
726
+ self._enel = enel
727
+ self._hnuminphi = hnuminphi
728
+
729
+ # Derived attributes (populated by fit_selection)
730
+ self._ekin_range = None
731
+ self._individual_properties = None # combined values + sigmas
732
+
733
+ # -------------------- Immutable physics inputs --------------------
734
+
735
+ @property
736
+ def angles(self):
737
+ """Angular axis for the MDCs."""
738
+ return self._angles
739
+
740
+ @property
741
+ def angle_resolution(self):
742
+ """Angular step size (float)."""
743
+ return self._angle_resolution
744
+
745
+ @property
746
+ def enel(self):
747
+ """Photoelectron binding energies (array-like). Read-only."""
748
+ return self._enel
749
+
750
+ @enel.setter
751
+ def enel(self, _):
752
+ raise AttributeError("`enel` is read-only; set it via the constructor.")
753
+
754
+ @property
755
+ def hnuminphi(self):
756
+ """Work-function/photon-energy offset. Read-only."""
757
+ return self._hnuminphi
758
+
759
+ @hnuminphi.setter
760
+ def hnuminphi(self, _):
761
+ raise AttributeError("`hnuminphi` is read-only; set it via the constructor.")
762
+
763
+ @property
764
+ def ekin(self):
765
+ """Kinetic energy array: enel + hnuminphi (computed on the fly)."""
766
+ return self._enel + self._hnuminphi
767
+
768
+ @ekin.setter
769
+ def ekin(self, _):
770
+ raise AttributeError("`ekin` is derived and read-only.")
771
+
772
+ # -------------------- Data arrays --------------------
773
+
774
+ @property
775
+ def intensities(self):
776
+ """2D or 3D intensity map (energy × angle)."""
777
+ return self._intensities
778
+
779
+ @intensities.setter
780
+ def intensities(self, x):
781
+ self._intensities = x
782
+
783
+ # -------------------- Results populated by fit_selection --------------------
784
+
785
+ @property
786
+ def ekin_range(self):
787
+ """Kinetic-energy slices that were fitted."""
788
+ if self._ekin_range is None:
789
+ raise AttributeError("`ekin_range` not yet set. Run `.fit_selection()` first.")
790
+ return self._ekin_range
791
+
792
+ @property
793
+ def individual_properties(self):
794
+ """
795
+ Aggregated fitted parameter values and uncertainties per component.
796
+
797
+ Returns
798
+ -------
799
+ dict
800
+ Nested mapping::
801
+
802
+ {
803
+ label: {
804
+ class_name: {
805
+ 'label': label,
806
+ '_class': class_name,
807
+ <param>: [values per slice],
808
+ <param>_sigma: [1σ per slice or None],
809
+ ...
810
+ }
811
+ }
812
+ }
813
+ """
814
+ if self._individual_properties is None:
815
+ raise AttributeError(
816
+ "`individual_properties` not yet set. Run `.fit_selection()` first."
817
+ )
818
+ return self._individual_properties
819
+
820
+ def energy_check(self, energy_value):
821
+ r"""
822
+ """
823
+ if np.isscalar(self.ekin):
824
+ if energy_value is not None:
825
+ raise ValueError("This dataset contains only one " \
826
+ "momentum-distribution curve; do not provide energy_value.")
827
+ else:
828
+ kinergy = self.ekin
829
+ counts = self.intensities
830
+ else:
831
+ if energy_value is None:
832
+ raise ValueError("This dataset contains multiple " \
833
+ "momentum-distribution curves. Please provide an energy_value "
834
+ "for which to plot the MDCs.")
835
+ else:
836
+ energy_index = np.abs(self.enel - energy_value).argmin()
837
+ kinergy = self.ekin[energy_index]
838
+ counts = self.intensities[energy_index, :]
839
+
840
+ if not (self.enel.min() <= energy_value <= self.enel.max()):
841
+ raise ValueError(
842
+ f"Selected energy_value={energy_value:.3f} "
843
+ f"is outside the available energy range "
844
+ f"({self.enel.min():.3f} – {self.enel.max():.3f}) "
845
+ "of the MDC collection."
846
+ )
847
+
848
+ return counts, kinergy
849
+
850
+
851
+ def plot(self, energy_value=None, energy_range=None, ax=None, **kwargs):
852
+ """
853
+ Interactive or static plot with optional slider and full wrapper
854
+ support. Behavior consistent with Jupyter and CLI based on show /
855
+ fig_close.
856
+ """
857
+ import matplotlib.pyplot as plt
858
+ from matplotlib.widgets import Slider
859
+ import string
860
+ import sys
861
+ import warnings
862
+
863
+ # Wrapper kwargs
864
+ title = kwargs.pop("title", None)
865
+ savefig = kwargs.pop("savefig", None)
866
+ show = kwargs.pop("show", True)
867
+ fig_close = kwargs.pop("fig_close", False)
868
+ tight_layout = kwargs.pop("tight_layout", False)
869
+ ax_grid = kwargs.pop("ax_grid", None)
870
+ ax_annotate = kwargs.pop("ax_annotate", False)
871
+ size_kwargs = kwargs.pop("size_kwargs", None)
872
+
873
+ if energy_value is not None and energy_range is not None:
874
+ raise ValueError(
875
+ "Provide at most energy_value or energy_range, not both.")
876
+
877
+ ax, fig, plt = get_ax_fig_plt(ax=ax)
878
+
879
+ angles = self.angles
880
+ energies = self.enel
881
+
882
+ if np.isscalar(energies):
883
+ if energy_value is not None or energy_range is not None:
884
+ raise ValueError(
885
+ "This dataset contains only one momentum-distribution "
886
+ "curve; do not provide energy_value or energy_range."
887
+ )
888
+
889
+ intensities = self.intensities
890
+ ax.scatter(angles, intensities, label="Data")
891
+ ax.set_title(f"Energy slice: {energies * kilo:.3f} meV")
892
+
893
+ # --- y-only autoscale, preserve x ---
894
+ x0, x1 = ax.get_xlim() # keep current x-range
895
+ ax.relim(visible_only=True) # recompute data limits
896
+ ax.autoscale_view(scalex=False, scaley=True)
897
+ ax.set_xlim(x0, x1) # restore x (belt-and-suspenders)
898
+
899
+ else:
900
+ if (energy_value is not None) and (energy_range is not None):
901
+ raise ValueError("Provide either energy_value or energy_range, not both.")
902
+
903
+ emin, emax = energies.min(), energies.max()
904
+
905
+ # ---- Single-slice path (no slider) ----
906
+ if energy_value is not None:
907
+ if energy_value < emin or energy_value > emax:
908
+ raise ValueError(
909
+ f"Requested energy_value {energy_value:.3f} eV is "
910
+ f"outside the available energy range "
911
+ f"[{emin:.3f}, {emax:.3f}] eV."
912
+ )
913
+ idx = int(np.abs(energies - energy_value).argmin())
914
+ intensities = self.intensities[idx]
915
+ ax.scatter(angles, intensities, label="Data")
916
+ ax.set_title(f"Energy slice: {energies[idx] * kilo:.3f} meV")
917
+
918
+ # --- y-only autoscale, preserve x ---
919
+ x0, x1 = ax.get_xlim() # keep current x-range
920
+ ax.relim(visible_only=True) # recompute data limits
921
+ ax.autoscale_view(scalex=False, scaley=True)
922
+ ax.set_xlim(x0, x1) # restore x (belt-and-suspenders)
923
+
924
+ # ---- Multi-slice path (slider) ----
925
+ else:
926
+ if energy_range is not None:
927
+ e_min, e_max = energy_range
928
+ mask = (energies >= e_min) & (energies <= e_max)
929
+ else:
930
+ mask = np.ones_like(energies, dtype=bool)
931
+
932
+ indices = np.where(mask)[0]
933
+ if len(indices) == 0:
934
+ raise ValueError("No energies found in the specified selection.")
935
+
936
+ intensities = self.intensities[indices]
937
+
938
+ fig.subplots_adjust(bottom=0.25)
939
+ idx = 0
940
+ scatter = ax.scatter(angles, intensities[idx], label="Data")
941
+ ax.set_title(f"Energy slice: "
942
+ f"{energies[indices[idx]] * kilo:.3f} meV")
943
+
944
+ # Suppress single-point slider warning (when len(indices) == 1)
945
+ warnings.filterwarnings(
946
+ "ignore",
947
+ message="Attempting to set identical left == right",
948
+ category=UserWarning
949
+ )
950
+
951
+ slider_ax = fig.add_axes([0.2, 0.08, 0.6, 0.04])
952
+ slider = Slider(
953
+ slider_ax, "Index", 0, len(indices) - 1,
954
+ valinit=idx, valstep=1
955
+ )
956
+
957
+ def update(val):
958
+ i = int(slider.val)
959
+ yi = intensities[i]
960
+
961
+ scatter.set_offsets(np.c_[angles, yi])
962
+
963
+ x0, x1 = ax.get_xlim()
964
+
965
+ yv = np.asarray(yi, dtype=float).ravel()
966
+ mask = np.isfinite(yv)
967
+ if mask.any():
968
+ y_min = float(yv[mask].min())
969
+ y_max = float(yv[mask].max())
970
+ span = y_max - y_min
971
+ frac = plt.rcParams['axes.ymargin']
972
+
973
+ if span <= 0 or not np.isfinite(span):
974
+ scale = max(abs(y_max), 1.0)
975
+ pad = frac * scale
976
+ else:
977
+ pad = frac * span
978
+
979
+ ax.set_ylim(y_min - pad, y_max + pad)
980
+
981
+ # Keep x unchanged
982
+ ax.set_xlim(x0, x1)
983
+
984
+ # Update title and redraw
985
+ ax.set_title(f"Energy slice: "
986
+ f"{energies[indices[i]] * kilo:.3f} meV")
987
+ fig.canvas.draw_idle()
988
+
989
+ slider.on_changed(update)
990
+ self._slider = slider
991
+ self._line = scatter
992
+
993
+ ax.set_xlabel("Angle (°)")
994
+ ax.set_ylabel("Counts (-)")
995
+ ax.legend()
996
+ self._fig = fig
997
+
998
+ if size_kwargs:
999
+ fig.set_size_inches(size_kwargs.pop("w"),
1000
+ size_kwargs.pop("h"), **size_kwargs)
1001
+ if title:
1002
+ fig.suptitle(title)
1003
+ if tight_layout:
1004
+ fig.tight_layout()
1005
+ if savefig:
1006
+ fig.savefig(savefig)
1007
+ if ax_grid is not None:
1008
+ for axis in fig.axes:
1009
+ axis.grid(bool(ax_grid))
1010
+ if ax_annotate:
1011
+ tags = string.ascii_lowercase
1012
+ for i, axis in enumerate(fig.axes):
1013
+ axis.annotate(f"({tags[i]})", xy=(0.05, 0.95),
1014
+ xycoords="axes fraction")
1015
+
1016
+ is_interactive = hasattr(sys, 'ps1') or 'ipykernel' in sys.modules
1017
+ is_cli = not is_interactive
1018
+
1019
+ if show:
1020
+ if is_cli:
1021
+ plt.show()
1022
+ if fig_close:
1023
+ plt.close(fig)
1024
+
1025
+ if not show and (fig_close or is_cli):
1026
+ return None
1027
+ return fig
1028
+
1029
+
1030
+ @add_fig_kwargs
1031
+ def visualize_guess(self, distributions, energy_value=None,
1032
+ matrix_element=None, matrix_args=None,
1033
+ ax=None, **kwargs):
1034
+ r"""
1035
+ """
1036
+
1037
+ counts, kinergy = self.energy_check(energy_value)
1038
+
1039
+ ax, fig, plt = get_ax_fig_plt(ax=ax)
1040
+
1041
+ ax.set_xlabel('Angle ($\\degree$)')
1042
+ ax.set_ylabel('Counts (-)')
1043
+ ax.set_title(f"Energy slice: "
1044
+ f"{(kinergy - self.hnuminphi) * kilo:.3f} meV")
1045
+ ax.scatter(self.angles, counts, label='Data')
1046
+
1047
+ final_result = self._merge_and_plot(ax=ax,
1048
+ distributions=distributions, kinetic_energy=kinergy,
1049
+ matrix_element=matrix_element,
1050
+ matrix_args=dict(matrix_args) if matrix_args else None,
1051
+ plot_individual=True,
1052
+ )
1053
+
1054
+ residual = counts - final_result
1055
+ ax.scatter(self.angles, residual, label='Residual')
1056
+ ax.legend()
1057
+
1058
+ return fig
1059
+
1060
+
1061
+ def fit_selection(self, distributions, energy_value=None, energy_range=None,
1062
+ matrix_element=None, matrix_args=None, ax=None, **kwargs):
1063
+ r"""
1064
+ """
1065
+ import matplotlib.pyplot as plt
1066
+ from matplotlib.widgets import Slider
1067
+ from copy import deepcopy
1068
+ import string
1069
+ import sys
1070
+ import warnings
1071
+ from lmfit import Minimizer
1072
+ from scipy.ndimage import gaussian_filter
1073
+ from .functions import construct_parameters, build_distributions, \
1074
+ residual, resolve_param_name
1075
+
1076
+ # Wrapper kwargs
1077
+ title = kwargs.pop("title", None)
1078
+ savefig = kwargs.pop("savefig", None)
1079
+ show = kwargs.pop("show", True)
1080
+ fig_close = kwargs.pop("fig_close", False)
1081
+ tight_layout = kwargs.pop("tight_layout", False)
1082
+ ax_grid = kwargs.pop("ax_grid", None)
1083
+ ax_annotate = kwargs.pop("ax_annotate", False)
1084
+ size_kwargs = kwargs.pop("size_kwargs", None)
1085
+
1086
+ ax, fig, plt = get_ax_fig_plt(ax=ax)
1087
+
1088
+ energies = self.enel
1089
+ new_distributions = deepcopy(distributions)
1090
+
1091
+ if energy_value is not None and energy_range is not None:
1092
+ raise ValueError(
1093
+ "Provide at most energy_value or energy_range, not both.")
1094
+
1095
+ if np.isscalar(energies):
1096
+ if energy_value is not None or energy_range is not None:
1097
+ raise ValueError(
1098
+ "This dataset contains only one momentum-distribution "
1099
+ "curve; do not provide energy_value or energy_range."
1100
+ )
1101
+ kinergies = np.atleast_1d(self.ekin)
1102
+ intensities = np.atleast_2d(self.intensities)
1103
+
1104
+ else:
1105
+ if energy_value is not None:
1106
+ if (energy_value < energies.min() or energy_value > energies.max()):
1107
+ raise ValueError( f"Requested energy_value {energy_value:.3f} eV is "
1108
+ f"outside the available energy range "
1109
+ f"[{energies.min():.3f}, {energies.max():.3f}] eV." )
1110
+ idx = np.abs(energies - energy_value).argmin()
1111
+ indices = np.atleast_1d(idx)
1112
+ kinergies = self.ekin[indices]
1113
+ intensities = self.intensities[indices, :]
1114
+
1115
+ elif energy_range is not None:
1116
+ e_min, e_max = energy_range
1117
+ indices = np.where((energies >= e_min) & (energies <= e_max))[0]
1118
+ if len(indices) == 0:
1119
+ raise ValueError("No energies found in the specified energy_range.")
1120
+ kinergies = self.ekin[indices]
1121
+ intensities = self.intensities[indices, :]
1122
+
1123
+ else: # Without specifying a range, all MDCs are plotted
1124
+ kinergies = self.ekin
1125
+ intensities = self.intensities
1126
+
1127
+ # Final shape guard
1128
+ kinergies = np.atleast_1d(kinergies)
1129
+ intensities = np.atleast_2d(intensities)
1130
+
1131
+ all_final_results = []
1132
+ all_residuals = []
1133
+ all_individual_results = [] # List of (n_individuals, n_angles)
1134
+
1135
+ aggregated_properties = {}
1136
+
1137
+ # map class_name -> parameter names to extract
1138
+ param_spec = {
1139
+ 'Constant': ('offset',),
1140
+ 'Linear': ('offset', 'slope'),
1141
+ 'SpectralLinear': ('amplitude', 'peak', 'broadening'),
1142
+ 'SpectralQuadratic': ('amplitude', 'peak', 'broadening'),
1143
+ }
1144
+
1145
+ order = np.argsort(kinergies)[::-1]
1146
+ for idx in order:
1147
+ kinergy = kinergies[idx]
1148
+ intensity = intensities[idx]
1149
+ if matrix_element is not None:
1150
+ parameters, element_names = construct_parameters(
1151
+ new_distributions, matrix_args)
1152
+ new_distributions = build_distributions(new_distributions, parameters)
1153
+ mini = Minimizer(
1154
+ residual, parameters,
1155
+ fcn_args=(self.angles, intensity, self.angle_resolution,
1156
+ new_distributions, kinergy, self.hnuminphi,
1157
+ matrix_element, element_names)
1158
+ )
1159
+ else:
1160
+ parameters = construct_parameters(new_distributions)
1161
+ new_distributions = build_distributions(new_distributions, parameters)
1162
+ mini = Minimizer(
1163
+ residual, parameters,
1164
+ fcn_args=(self.angles, intensity, self.angle_resolution,
1165
+ new_distributions, kinergy, self.hnuminphi)
1166
+ )
1167
+
1168
+ outcome = mini.minimize('least_squares')
1169
+
1170
+ pcov = outcome.covar
1171
+
1172
+ var_names = getattr(outcome, 'var_names', None)
1173
+ if not var_names:
1174
+ var_names = [n for n, p in outcome.params.items() if p.vary]
1175
+ var_idx = {n: i for i, n in enumerate(var_names)}
1176
+
1177
+ param_sigma_full = {}
1178
+ for name, par in outcome.params.items():
1179
+ sigma = None
1180
+ if pcov is not None and name in var_idx:
1181
+ d = pcov[var_idx[name], var_idx[name]]
1182
+ if np.isfinite(d) and d >= 0:
1183
+ sigma = float(np.sqrt(d))
1184
+ if sigma is None:
1185
+ s = getattr(par, 'stderr', None)
1186
+ sigma = float(s) if s is not None else None
1187
+ param_sigma_full[name] = sigma
1188
+
1189
+ # Rebuild the *fitted* distributions from optimized params
1190
+ fitted_distributions = build_distributions(new_distributions, outcome.params)
1191
+
1192
+ # If using a matrix element, extract slice-specific args from the fit
1193
+ if matrix_element is not None:
1194
+ new_matrix_args = {key: outcome.params[key].value for key in matrix_args}
1195
+ else:
1196
+ new_matrix_args = None
1197
+
1198
+ # individual curves (smoothed, cropped) and final sum (no plotting here)
1199
+ extend, step, numb = extend_function(self.angles, self.angle_resolution)
1200
+
1201
+ total_result_ext = np.zeros_like(extend)
1202
+ indiv_rows = [] # (n_individuals, n_angles)
1203
+ individual_labels = []
1204
+
1205
+ for dist in fitted_distributions:
1206
+ # evaluate each component on the extended grid
1207
+ if getattr(dist, 'class_name', None) == 'SpectralQuadratic':
1208
+ if (getattr(dist, 'center_angle', None) is not None) and (
1209
+ kinergy is None or self.hnuminphi is None
1210
+ ):
1211
+ raise ValueError(
1212
+ 'Spectral quadratic function is defined in terms '
1213
+ 'of a center angle. Please provide a kinetic energy '
1214
+ 'and hnuminphi.'
1215
+ )
1216
+ extended_result = dist.evaluate(extend, kinergy, self.hnuminphi)
1217
+ else:
1218
+ extended_result = dist.evaluate(extend)
1219
+
1220
+ if matrix_element is not None and hasattr(dist, 'index'):
1221
+ args = new_matrix_args or {}
1222
+ extended_result *= matrix_element(extend, **args)
1223
+
1224
+ total_result_ext += extended_result
1225
+
1226
+ # smoothed & cropped individual
1227
+ individual_curve = gaussian_filter(extended_result, sigma=step)[
1228
+ numb:-numb if numb else None
1229
+ ]
1230
+ indiv_rows.append(np.asarray(individual_curve))
1231
+
1232
+ # label
1233
+ label = getattr(dist, 'label', str(dist))
1234
+ individual_labels.append(label)
1235
+
1236
+ # ---- collect parameters for this distribution
1237
+ # (Aggregated over slices)
1238
+ cls = getattr(dist, 'class_name', None)
1239
+ wanted = param_spec.get(cls, ())
1240
+
1241
+ # ensure dicts exist
1242
+ label_bucket = aggregated_properties.setdefault(label, {})
1243
+ class_bucket = label_bucket.setdefault(cls, {'label': label, '_class': cls})
1244
+
1245
+ # ensure keys for both values and sigmas
1246
+ for pname in wanted:
1247
+ class_bucket.setdefault(pname, [])
1248
+ class_bucket.setdefault(f"{pname}_sigma", [])
1249
+
1250
+ # append values and sigmas in the order of slices
1251
+ for pname in wanted:
1252
+ param_key = resolve_param_name(outcome.params, label, pname)
1253
+
1254
+ if param_key is not None and param_key in outcome.params:
1255
+ class_bucket[pname].append(outcome.params[param_key].value)
1256
+ class_bucket[f"{pname}_sigma"].append(param_sigma_full.get(param_key, None))
1257
+ else:
1258
+ # Not fitted in this slice → keep the value if present on the dist, sigma=None
1259
+ class_bucket[pname].append(getattr(dist, pname, None))
1260
+ class_bucket[f"{pname}_sigma"].append(None)
1261
+
1262
+ # final (sum) curve, smoothed & cropped
1263
+ final_result_i = gaussian_filter(total_result_ext, sigma=step)[
1264
+ numb:-numb if numb else None]
1265
+ final_result_i = np.asarray(final_result_i)
1266
+
1267
+ # Residual for this slice
1268
+ residual_i = np.asarray(intensity) - final_result_i
1269
+
1270
+ # Store per-slice results
1271
+ all_final_results.append(final_result_i)
1272
+ all_residuals.append(residual_i)
1273
+ all_individual_results.append(np.vstack(indiv_rows))
1274
+
1275
+ # --- after the reversed-order loop, restore original (ascending) order ---
1276
+ inverse_order = np.argsort(np.argsort(kinergies)[::-1])
1277
+
1278
+ # Reorder per-slice arrays/lists computed in the loop
1279
+ all_final_results[:] = [all_final_results[i] for i in inverse_order]
1280
+ all_residuals[:] = [all_residuals[i] for i in inverse_order]
1281
+ all_individual_results[:] = [all_individual_results[i] for i in inverse_order]
1282
+
1283
+ # Reorder all per-slice lists in aggregated_properties
1284
+ for label_dict in aggregated_properties.values():
1285
+ for cls_dict in label_dict.values():
1286
+ for key, val in cls_dict.items():
1287
+ if isinstance(val, list) and len(val) == len(kinergies):
1288
+ cls_dict[key] = [val[i] for i in inverse_order]
1289
+
1290
+ self._ekin_range = kinergies
1291
+ self._individual_properties = aggregated_properties
1292
+
1293
+ if np.isscalar(energies):
1294
+ # One slice only: plot MDC, Fit, Residual, and Individuals
1295
+ ydata = np.asarray(intensities).squeeze()
1296
+ yfit = np.asarray(all_final_results[0]).squeeze()
1297
+ yres = np.asarray(all_residuals[0]).squeeze()
1298
+ yind = np.asarray(all_individual_results[0])
1299
+
1300
+ ax.scatter(self.angles, ydata, label="Data")
1301
+ # plot individuals with their labels
1302
+ for j, lab in enumerate(individual_labels or []):
1303
+ ax.plot(self.angles, yind[j], label=str(lab))
1304
+ ax.plot(self.angles, yfit, label="Fit")
1305
+ ax.scatter(self.angles, yres, label="Residual")
1306
+
1307
+ ax.set_title(f"Energy slice: {energies * kilo:.3f} meV")
1308
+ ax.relim() # recompute data limits from all artists
1309
+ ax.autoscale_view() # apply autoscaling + axes.ymargin padding
1310
+
1311
+ else:
1312
+ if energy_value is not None:
1313
+ _idx = int(np.abs(energies - energy_value).argmin())
1314
+ energies_sel = np.atleast_1d(energies[_idx])
1315
+ elif energy_range is not None:
1316
+ e_min, e_max = energy_range
1317
+ energies_sel = energies[(energies >= e_min)
1318
+ & (energies <= e_max)]
1319
+ else:
1320
+ energies_sel = energies
1321
+
1322
+ # Number of slices must match
1323
+ n_slices = len(all_final_results)
1324
+ assert intensities.shape[0] == n_slices == len(all_residuals) \
1325
+ == len(all_individual_results), (f"Mismatch: data \
1326
+ {intensities.shape[0]}, fits {len(all_final_results)}, "
1327
+ f"residuals {len(all_residuals)}, \
1328
+ individuals {len(all_individual_results)}."
1329
+ )
1330
+ n_individuals = all_individual_results[0].shape[0] \
1331
+ if n_slices else 0
1332
+
1333
+ fig.subplots_adjust(bottom=0.25)
1334
+ idx = 0
1335
+
1336
+ # Initial draw (MDC + Individuals + Fit + Residual) at slice 0
1337
+ scatter = ax.scatter(self.angles, intensities[idx], label="Data")
1338
+
1339
+ individual_lines = []
1340
+ if n_individuals:
1341
+ for j in range(n_individuals):
1342
+ if individual_labels and j < len(individual_labels):
1343
+ label = str(individual_labels[j])
1344
+ else:
1345
+ label = f"Comp {j}"
1346
+
1347
+ yvals = all_individual_results[idx][j]
1348
+ line, = ax.plot(self.angles, yvals, label=label)
1349
+ individual_lines.append(line)
1350
+
1351
+ result_line, = ax.plot(self.angles, all_final_results[idx],
1352
+ label="Fit")
1353
+ resid_scatter = ax.scatter(self.angles, all_residuals[idx],
1354
+ label="Residual")
1355
+
1356
+ # Title + limits (use only the currently shown slice)
1357
+ ax.set_title(f"Energy slice: {energies_sel[idx] * kilo:.3f} meV")
1358
+ ax.relim() # recompute data limits from all artists
1359
+ ax.autoscale_view() # apply autoscaling + axes.ymargin padding
1360
+
1361
+ # Suppress warning when a single MDC is plotted
1362
+ warnings.filterwarnings(
1363
+ "ignore",
1364
+ message="Attempting to set identical left == right",
1365
+ category=UserWarning
1366
+ )
1367
+
1368
+ # Slider over slice index (0..n_slices-1)
1369
+ slider_ax = fig.add_axes([0.2, 0.08, 0.6, 0.04])
1370
+ slider = Slider(
1371
+ slider_ax, "Index", 0, n_slices - 1,
1372
+ valinit=idx, valstep=1
1373
+ )
1374
+
1375
+ def update(val):
1376
+ i = int(slider.val)
1377
+ # Update MDC points
1378
+ scatter.set_offsets(np.c_[self.angles, intensities[i]])
1379
+
1380
+ # Update individuals
1381
+ if n_individuals:
1382
+ Yi = all_individual_results[i] # (n_individuals, n_angles)
1383
+ for j, ln in enumerate(individual_lines):
1384
+ ln.set_ydata(Yi[j])
1385
+
1386
+ # Update fit and residual
1387
+ result_line.set_ydata(all_final_results[i])
1388
+ resid_scatter.set_offsets(np.c_[self.angles, all_residuals[i]])
1389
+
1390
+ ax.relim()
1391
+ ax.autoscale_view()
1392
+
1393
+ # Update title and redraw
1394
+ ax.set_title(f"Energy slice: "
1395
+ f"{energies_sel[i] * kilo:.3f} meV")
1396
+ fig.canvas.draw_idle()
1397
+
1398
+ slider.on_changed(update)
1399
+ self._slider = slider
1400
+ self._line = scatter
1401
+ self._individual_lines = individual_lines
1402
+ self._result_line = result_line
1403
+ self._resid_scatter = resid_scatter
1404
+
1405
+ ax.set_xlabel("Angle (°)")
1406
+ ax.set_ylabel("Counts (-)")
1407
+ ax.legend()
1408
+ self._fig = fig
1409
+
1410
+ if size_kwargs:
1411
+ fig.set_size_inches(size_kwargs.pop("w"),
1412
+ size_kwargs.pop("h"), **size_kwargs)
1413
+ if title:
1414
+ fig.suptitle(title)
1415
+ if tight_layout:
1416
+ fig.tight_layout()
1417
+ if savefig:
1418
+ fig.savefig(savefig)
1419
+ if ax_grid is not None:
1420
+ for axis in fig.axes:
1421
+ axis.grid(bool(ax_grid))
1422
+ if ax_annotate:
1423
+ tags = string.ascii_lowercase
1424
+ for i, axis in enumerate(fig.axes):
1425
+ axis.annotate(f"({tags[i]})", xy=(0.05, 0.95),
1426
+ xycoords="axes fraction")
1427
+
1428
+ is_interactive = hasattr(sys, 'ps1') or 'ipykernel' in sys.modules
1429
+ is_cli = not is_interactive
1430
+
1431
+ if show:
1432
+ if is_cli:
1433
+ plt.show()
1434
+ if fig_close:
1435
+ plt.close(fig)
1436
+
1437
+ if not show and (fig_close or is_cli):
1438
+ return None
1439
+ return fig
1440
+
1441
+
1442
+ @add_fig_kwargs
1443
+ def fit(self, distributions, energy_value=None, matrix_element=None,
1444
+ matrix_args=None, ax=None, **kwargs):
1445
+ r"""
1446
+ """
1447
+ from copy import deepcopy
1448
+ from lmfit import Minimizer
1449
+ from .functions import construct_parameters, build_distributions, \
1450
+ residual
1451
+
1452
+ counts, kinergy = self.energy_check(energy_value)
1453
+
1454
+ ax, fig, plt = get_ax_fig_plt(ax=ax)
1455
+
1456
+ ax.set_xlabel('Angle ($\\degree$)')
1457
+ ax.set_ylabel('Counts (-)')
1458
+ ax.set_title(f"Energy slice: "
1459
+ f"{(kinergy - self.hnuminphi) * kilo:.3f} meV")
1460
+
1461
+ ax.scatter(self.angles, counts, label='Data')
1462
+
1463
+ new_distributions = deepcopy(distributions)
1464
+
1465
+ if matrix_element is not None:
1466
+ parameters, element_names = construct_parameters(distributions,
1467
+ matrix_args)
1468
+ new_distributions = build_distributions(new_distributions, \
1469
+ parameters)
1470
+ mini = Minimizer(
1471
+ residual, parameters,
1472
+ fcn_args=(self.angles, counts, self.angle_resolution,
1473
+ new_distributions, kinergy, self.hnuminphi,
1474
+ matrix_element, element_names))
1475
+ else:
1476
+ parameters = construct_parameters(distributions)
1477
+ new_distributions = build_distributions(new_distributions,
1478
+ parameters)
1479
+ mini = Minimizer(residual, parameters,
1480
+ fcn_args=(self.angles, counts, self.angle_resolution,
1481
+ new_distributions, kinergy, self.hnuminphi))
1482
+
1483
+ outcome = mini.minimize('least_squares')
1484
+ pcov = outcome.covar
1485
+
1486
+ # If matrix params were fitted, pass the fitted values to plotting
1487
+ if matrix_element is not None:
1488
+ new_matrix_args = {key: outcome.params[key].value for key in
1489
+ matrix_args}
1490
+ else:
1491
+ new_matrix_args = None
1492
+
1493
+ final_result = self._merge_and_plot(ax=ax,
1494
+ distributions=new_distributions, kinetic_energy=kinergy,
1495
+ matrix_element=matrix_element, matrix_args=new_matrix_args,
1496
+ plot_individual=True)
1497
+
1498
+ residual_vals = counts - final_result
1499
+ ax.scatter(self.angles, residual_vals, label='Residual')
1500
+ ax.legend()
1501
+ if matrix_element is not None:
1502
+ return fig, new_distributions, pcov, new_matrix_args
1503
+ else:
1504
+ return fig, new_distributions, pcov
1505
+
1506
+
1507
+ def _merge_and_plot(self, ax, distributions, kinetic_energy,
1508
+ matrix_element=None, matrix_args=None,
1509
+ plot_individual=True):
1510
+ r"""
1511
+ Evaluate distributions on the extended grid, apply optional matrix
1512
+ element, smooth, plot individuals and the summed curve.
1513
+
1514
+ Returns
1515
+ -------
1516
+ final_result : np.ndarray
1517
+ Smoothed, cropped total distribution aligned with self.angles.
1518
+ """
1519
+ from scipy.ndimage import gaussian_filter
1520
+
1521
+ # Build extended grid
1522
+ extend, step, numb = extend_function(self.angles, self.angle_resolution)
1523
+ total_result = np.zeros_like(extend)
1524
+
1525
+ for dist in distributions:
1526
+ # Special handling for SpectralQuadratic
1527
+ if getattr(dist, 'class_name', None) == 'SpectralQuadratic':
1528
+ if (getattr(dist, 'center_angle', None) is not None) and (
1529
+ kinetic_energy is None or self.hnuminphi is None
1530
+ ):
1531
+ raise ValueError(
1532
+ 'Spectral quadratic function is defined in terms '
1533
+ 'of a center angle. Please provide a kinetic energy '
1534
+ 'and hnuminphi.'
1535
+ )
1536
+ extended_result = dist.evaluate(extend, kinetic_energy, \
1537
+ self.hnuminphi)
1538
+ else:
1539
+ extended_result = dist.evaluate(extend)
1540
+
1541
+ # Optional matrix element (only for components that advertise an index)
1542
+ if matrix_element is not None and hasattr(dist, 'index'):
1543
+ args = matrix_args or {}
1544
+ extended_result *= matrix_element(extend, **args)
1545
+
1546
+ total_result += extended_result
1547
+
1548
+ if plot_individual and ax:
1549
+ individual = gaussian_filter(extended_result, sigma=step)\
1550
+ [numb:-numb if numb else None]
1551
+ ax.plot(self.angles, individual, label=getattr(dist, \
1552
+ 'label', str(dist)))
1553
+
1554
+ # Smoothed, cropped total curve aligned to self.angles
1555
+ final_result = gaussian_filter(total_result, sigma=step)[numb:-numb \
1556
+ if numb else None]
1557
+ if ax:
1558
+ ax.plot(self.angles, final_result, label='Distribution sum')
1559
+
1560
+ return final_result
1561
+
1562
+
1563
+ def expose_parameters(self, select_label, fermi_wavevector=None,
1564
+ fermi_velocity=None, bare_mass=None, side=None):
1565
+ r"""
1566
+ Select and return fitted parameters for a given component label, plus a
1567
+ flat export dictionary containing values **and** 1σ uncertainties.
1568
+
1569
+ Parameters
1570
+ ----------
1571
+ select_label : str
1572
+ Label to look for among the fitted distributions.
1573
+ fermi_wavevector : float, optional
1574
+ Optional Fermi wave vector to include.
1575
+ fermi_velocity : float, optional
1576
+ Optional Fermi velocity to include.
1577
+ bare_mass : float, optional
1578
+ Optional bare mass to include (used for SpectralQuadratic dispersions).
1579
+ side : {'left','right'}, optional
1580
+ Optional side selector for SpectralQuadratic dispersions.
1581
+
1582
+ Returns
1583
+ -------
1584
+ ekin_range : np.ndarray
1585
+ Kinetic-energy grid corresponding to the selected label.
1586
+ hnuminphi : float
1587
+ Photoelectron work-function offset.
1588
+ label : str
1589
+ Label of the selected distribution.
1590
+ selected_properties : dict or list of dict
1591
+ Nested dictionary (or list thereof) containing <param> and
1592
+ <param>_sigma arrays.
1593
+ exported_parameters : dict
1594
+ Flat dictionary of parameters and their uncertainties, plus optional
1595
+ Fermi quantities and `side`.
1596
+ """
1597
+
1598
+ if self._ekin_range is None:
1599
+ raise AttributeError("ekin_range not yet set. Run `.fit_selection()` first.")
1600
+
1601
+ store = getattr(self, "_individual_properties", None)
1602
+ if not store or select_label not in store:
1603
+ all_labels = (sorted(store.keys()) if isinstance(store, dict) else [])
1604
+ raise ValueError(
1605
+ f"Label '{select_label}' not found in available labels: {all_labels}"
1606
+ )
1607
+
1608
+ # Convert lists → numpy arrays within the selected label’s classes
1609
+ per_class_dicts = []
1610
+ for cls, bucket in store[select_label].items():
1611
+ dct = {}
1612
+ for k, v in bucket.items():
1613
+ if k in ("label", "_class"):
1614
+ dct[k] = v
1615
+ else:
1616
+ dct[k] = np.asarray(v)
1617
+ per_class_dicts.append(dct)
1618
+
1619
+ selected_properties = (
1620
+ per_class_dicts[0] if len(per_class_dicts) == 1 else per_class_dicts
1621
+ )
1622
+
1623
+ # Flat export dict: simple keys, includes optional extras
1624
+ exported_parameters = {
1625
+ "fermi_wavevector": fermi_wavevector,
1626
+ "fermi_velocity": fermi_velocity,
1627
+ "bare_mass": bare_mass,
1628
+ "side": side,
1629
+ }
1630
+
1631
+ # Collect parameters without prefixing by class
1632
+ if isinstance(selected_properties, dict):
1633
+ for key, val in selected_properties.items():
1634
+ if key not in ("label", "_class"):
1635
+ exported_parameters[key] = val
1636
+ else:
1637
+ # If multiple classes, merge sequentially (last overwrites same-name keys)
1638
+ for cls_bucket in selected_properties:
1639
+ for key, val in cls_bucket.items():
1640
+ if key not in ("label", "_class"):
1641
+ exported_parameters[key] = val
1642
+
1643
+ return self._ekin_range, self.hnuminphi, select_label, \
1644
+ selected_properties, exported_parameters
1645
+
1646
+
1647
+ class SelfEnergy:
1648
+ r"""Self-energy (ekin-leading; hnuminphi/ekin are read-only)."""
1649
+
1650
+ def __init__(self, ekin_range, hnuminphi, label, properties, parameters):
1651
+ # core read-only state
1652
+ self._ekin_range = ekin_range
1653
+ self._hnuminphi = hnuminphi
1654
+ self._label = label
1655
+
1656
+ # accept either a dict or a single-element list of dicts
1657
+ if isinstance(properties, list):
1658
+ if len(properties) == 1:
1659
+ properties = properties[0]
1660
+ else:
1661
+ raise ValueError("`properties` must be a dict or a single " \
1662
+ "dict in a list.")
1663
+
1664
+ # single source of truth for all params (+ their *_sigma)
1665
+ self._properties = dict(properties or {})
1666
+ self._class = self._properties.get("_class", None)
1667
+
1668
+ # ---- enforce supported classes at construction
1669
+ if self._class not in ("SpectralLinear", "SpectralQuadratic"):
1670
+ raise ValueError(
1671
+ f"Unsupported spectral class '{self._class}'. "
1672
+ "Only 'SpectralLinear' or 'SpectralQuadratic' are allowed."
1673
+ )
1674
+
1675
+ # grab user parameters
1676
+ self._parameters = dict(parameters or {})
1677
+ self._fermi_wavevector = self._parameters.get("fermi_wavevector")
1678
+ self._fermi_velocity = self._parameters.get("fermi_velocity")
1679
+ self._bare_mass = self._parameters.get("bare_mass")
1680
+ self._side = self._parameters.get("side", None)
1681
+
1682
+ # ---- class-specific parameter constraints
1683
+ if self._class == "SpectralLinear" and (self._bare_mass is not None):
1684
+ raise ValueError("`bare_mass` cannot be set for SpectralLinear.")
1685
+ if self._class == "SpectralQuadratic" and (self._fermi_velocity is not None):
1686
+ raise ValueError("`fermi_velocity` cannot be set for SpectralQuadratic.")
1687
+
1688
+ if self._side is not None and self._side not in ("left", "right"):
1689
+ raise ValueError("`side` must be 'left' or 'right' if provided.")
1690
+ if self._side is not None:
1691
+ self._parameters["side"] = self._side
1692
+
1693
+ # convenience attributes (read from properties)
1694
+ self._amplitude = self._properties.get("amplitude")
1695
+ self._amplitude_sigma = self._properties.get("amplitude_sigma")
1696
+ self._peak = self._properties.get("peak")
1697
+ self._peak_sigma = self._properties.get("peak_sigma")
1698
+ self._broadening = self._properties.get("broadening")
1699
+ self._broadening_sigma = self._properties.get("broadening_sigma")
1700
+
1701
+ # lazy caches
1702
+ self._peak_positions = None
1703
+ self._peak_positions_sigma = None
1704
+ self._real = None
1705
+ self._real_sigma = None
1706
+ self._imag = None
1707
+ self._imag_sigma = None
1708
+
1709
+ def _check_mass_velocity_exclusivity(self):
1710
+ """Ensure that fermi_velocity and bare_mass are not both set."""
1711
+ if (self._fermi_velocity is not None) and (self._bare_mass is not None):
1712
+ raise ValueError(
1713
+ "Cannot set both `fermi_velocity` and `bare_mass`: "
1714
+ "choose one physical parametrization (SpectralLinear or SpectralQuadratic)."
1715
+ )
1716
+
1717
+ # ---------------- core read-only axes ----------------
1718
+ @property
1719
+ def ekin_range(self):
1720
+ return self._ekin_range
1721
+
1722
+ @property
1723
+ def enel_range(self):
1724
+ if self._ekin_range is None:
1725
+ return None
1726
+ hnp = 0.0 if self._hnuminphi is None else self._hnuminphi
1727
+ return np.asarray(self._ekin_range) - hnp
1728
+
1729
+ @property
1730
+ def hnuminphi(self):
1731
+ return self._hnuminphi
1732
+
1733
+ # ---------------- identifiers ----------------
1734
+ @property
1735
+ def label(self):
1736
+ return self._label
1737
+
1738
+ @label.setter
1739
+ def label(self, x):
1740
+ self._label = x
1741
+
1742
+ # ---------------- exported user parameters ----------------
1743
+ @property
1744
+ def parameters(self):
1745
+ """Dictionary with user-supplied parameters (read-only view)."""
1746
+ return self._parameters
1747
+
1748
+ @property
1749
+ def side(self):
1750
+ """Optional side selector: 'left' or 'right'."""
1751
+ return self._side
1752
+
1753
+ @side.setter
1754
+ def side(self, x):
1755
+ if x is not None and x not in ("left", "right"):
1756
+ raise ValueError("`side` must be 'left' or 'right' if provided.")
1757
+ self._side = x
1758
+ if x is not None:
1759
+ self._parameters["side"] = x
1760
+ else:
1761
+ self._parameters.pop("side", None)
1762
+ # affects sign of peak_positions and thus `real`
1763
+ self._peak_positions = None
1764
+ self._real = None
1765
+ self._real_sigma = None
1766
+
1767
+ @property
1768
+ def fermi_wavevector(self):
1769
+ """Optional k_F; can be set later."""
1770
+ return self._fermi_wavevector
1771
+
1772
+ @fermi_wavevector.setter
1773
+ def fermi_wavevector(self, x):
1774
+ self._fermi_wavevector = x
1775
+ self._parameters["fermi_wavevector"] = x
1776
+ # invalidate dependent cache
1777
+ self._real = None
1778
+ self._real_sigma = None
1779
+
1780
+ @property
1781
+ def fermi_velocity(self):
1782
+ """Optional v_F; can be set later."""
1783
+ return self._fermi_velocity
1784
+
1785
+ @fermi_velocity.setter
1786
+ def fermi_velocity(self, x):
1787
+ if self._class == "SpectralQuadratic":
1788
+ raise ValueError("`fermi_velocity` cannot be set for" \
1789
+ " SpectralQuadratic.")
1790
+ self._fermi_velocity = x
1791
+ self._parameters["fermi_velocity"] = x
1792
+ # invalidate dependents
1793
+ self._imag = None; self._imag_sigma = None
1794
+ self._real = None; self._real_sigma = None
1795
+
1796
+ @property
1797
+ def bare_mass(self):
1798
+ """Optional bare mass; used by SpectralQuadratic formulas."""
1799
+ return self._bare_mass
1800
+
1801
+ @bare_mass.setter
1802
+ def bare_mass(self, x):
1803
+ if self._class == "SpectralLinear":
1804
+ raise ValueError("`bare_mass` cannot be set for SpectralLinear.")
1805
+ self._bare_mass = x
1806
+ self._parameters["bare_mass"] = x
1807
+ # invalidate dependents
1808
+ self._imag = None; self._imag_sigma = None
1809
+ self._real = None; self._real_sigma = None
1810
+
1811
+ # ---------------- optional fit parameters (convenience) ----------------
1812
+ @property
1813
+ def amplitude(self):
1814
+ return self._amplitude
1815
+
1816
+ @amplitude.setter
1817
+ def amplitude(self, x):
1818
+ self._amplitude = x
1819
+ self._properties["amplitude"] = x
1820
+
1821
+ @property
1822
+ def amplitude_sigma(self):
1823
+ return self._amplitude_sigma
1824
+
1825
+ @amplitude_sigma.setter
1826
+ def amplitude_sigma(self, x):
1827
+ self._amplitude_sigma = x
1828
+ self._properties["amplitude_sigma"] = x
1829
+
1830
+ @property
1831
+ def peak(self):
1832
+ return self._peak
1833
+
1834
+ @peak.setter
1835
+ def peak(self, x):
1836
+ self._peak = x
1837
+ self._properties["peak"] = x
1838
+ # invalidate dependent cache
1839
+ self._peak_positions = None
1840
+ self._real = None
1841
+
1842
+ @property
1843
+ def peak_sigma(self):
1844
+ return self._peak_sigma
1845
+
1846
+ @peak_sigma.setter
1847
+ def peak_sigma(self, x):
1848
+ self._peak_sigma = x
1849
+ self._properties["peak_sigma"] = x
1850
+ self._peak_positions_sigma = None
1851
+ self._real_sigma = None
1852
+
1853
+ @property
1854
+ def broadening(self):
1855
+ return self._broadening
1856
+
1857
+ @broadening.setter
1858
+ def broadening(self, x):
1859
+ self._broadening = x
1860
+ self._properties["broadening"] = x
1861
+ self._imag = None
1862
+
1863
+ @property
1864
+ def broadening_sigma(self):
1865
+ return self._broadening_sigma
1866
+
1867
+ @broadening_sigma.setter
1868
+ def broadening_sigma(self, x):
1869
+ self._broadening_sigma = x
1870
+ self._properties["broadening_sigma"] = x
1871
+ self._imag_sigma = None
1872
+
1873
+ # ---------------- derived outputs ----------------
1874
+ @property
1875
+ def peak_positions(self):
1876
+ r"""k_parallel = peak * dtor * sqrt(ekin_range / pref) (lazy)."""
1877
+ if self._peak_positions is None:
1878
+ if self._peak is None or self._ekin_range is None:
1879
+ return None
1880
+ if self._class == "SpectralQuadratic":
1881
+ if self._side is None:
1882
+ raise AttributeError(
1883
+ "For SpectralQuadratic, set `side` ('left'/'right') "
1884
+ "before accessing peak_positions and quantities that "
1885
+ "depend on the latter."
1886
+ )
1887
+ kpar_mag = np.sqrt(self._ekin_range / pref) * \
1888
+ np.sin(np.abs(self._peak) * dtor)
1889
+ self._peak_positions = (-1.0 if self._side == "left" \
1890
+ else 1.0) * kpar_mag
1891
+ else: # SpectralLinear
1892
+ self._peak_positions = np.sqrt(self._ekin_range / pref) \
1893
+ * np.sin(self._peak * dtor)
1894
+ return self._peak_positions
1895
+
1896
+ @property
1897
+ def peak_positions_sigma(self):
1898
+ r"""Std. dev. of k_parallel (lazy)."""
1899
+ if self._peak_positions_sigma is None:
1900
+ if self._peak_sigma is None or self._ekin_range is None:
1901
+ return None
1902
+ self._peak_positions_sigma = (np.sqrt(self._ekin_range / pref)
1903
+ * np.cos(self._peak * dtor)
1904
+ * self._peak_sigma * dtor)
1905
+ return self._peak_positions_sigma
1906
+
1907
+ @property
1908
+ def imag(self):
1909
+ r"""-Σ'' (lazy)."""
1910
+ if self._imag is None:
1911
+ if self._broadening is None or self._ekin_range is None:
1912
+ return None
1913
+ if self._class == "SpectralLinear":
1914
+ if self._fermi_velocity is None:
1915
+ raise AttributeError("Cannot compute `imag` "
1916
+ "(SpectralLinear): set `fermi_velocity` first.")
1917
+ self._imag = self._fermi_velocity * np.sqrt(self._ekin_range \
1918
+ / pref) * self._broadening
1919
+ else: # SpectralQuadratic
1920
+ if self._bare_mass is None:
1921
+ raise AttributeError("Cannot compute `imag` "
1922
+ "(SpectralQuadratic): set `bare_mass` first.")
1923
+ self._imag = (self._ekin_range * self._broadening) \
1924
+ / np.abs(self._bare_mass)
1925
+ return self._imag
1926
+
1927
+ @property
1928
+ def imag_sigma(self):
1929
+ r"""Std. dev. of -Σ'' (lazy)."""
1930
+ if self._imag_sigma is None:
1931
+ if self._broadening_sigma is None or self._ekin_range is None:
1932
+ return None
1933
+ if self._class == "SpectralLinear":
1934
+ if self._fermi_velocity is None:
1935
+ raise AttributeError("Cannot compute `imag_sigma` "
1936
+ "(SpectralLinear): set `fermi_velocity` first.")
1937
+ self._imag_sigma = self._fermi_velocity * \
1938
+ np.sqrt(self._ekin_range / pref) * self._broadening_sigma
1939
+ else: # SpectralQuadratic
1940
+ if self._bare_mass is None:
1941
+ raise AttributeError("Cannot compute `imag_sigma` "
1942
+ "(SpectralQuadratic): set `bare_mass` first.")
1943
+ self._imag_sigma = (self._ekin_range * \
1944
+ self._broadening_sigma) / np.abs(self._bare_mass)
1945
+ return self._imag_sigma
1946
+
1947
+ @property
1948
+ def real(self):
1949
+ r"""Σ' (lazy)."""
1950
+ if self._real is None:
1951
+ if self._peak is None or self._ekin_range is None:
1952
+ return None
1953
+ if self._class == "SpectralLinear":
1954
+ if self._fermi_velocity is None or self._fermi_wavevector is None:
1955
+ raise AttributeError("Cannot compute `real` "
1956
+ "(SpectralLinear): set `fermi_velocity` and " \
1957
+ "`fermi_wavevector` first.")
1958
+ self._real = self.enel_range - self._fermi_velocity * \
1959
+ (self.peak_positions - self._fermi_wavevector)
1960
+ else: # SpectralQuadratic
1961
+ if self._bare_mass is None or self._fermi_wavevector is None:
1962
+ raise AttributeError("Cannot compute `real` "
1963
+ "(SpectralQuadratic): set `bare_mass` and " \
1964
+ "`fermi_wavevector` first.")
1965
+ self._real = self.enel_range - (pref / \
1966
+ np.abs(self._bare_mass)) * (self.peak_positions**2 \
1967
+ - self._fermi_wavevector**2)
1968
+ return self._real
1969
+
1970
+ @property
1971
+ def real_sigma(self):
1972
+ r"""Std. dev. of Σ' (lazy)."""
1973
+ if self._real_sigma is None:
1974
+ if self._peak_sigma is None or self._ekin_range is None:
1975
+ return None
1976
+ if self._class == "SpectralLinear":
1977
+ if self._fermi_velocity is None:
1978
+ raise AttributeError("Cannot compute `real_sigma` "
1979
+ "(SpectralLinear): set `fermi_velocity` first.")
1980
+ self._real_sigma = self._fermi_velocity * self.peak_positions_sigma
1981
+ else: # SpectralQuadratic
1982
+ if self._bare_mass is None or self._fermi_wavevector is None:
1983
+ raise AttributeError("Cannot compute `real_sigma` "
1984
+ "(SpectralQuadratic): set `bare_mass` and " \
1985
+ "`fermi_wavevector` first.")
1986
+ self._real_sigma = 2 * pref * self.peak_positions_sigma \
1987
+ * np.abs(self.peak_positions / self._bare_mass)
1988
+ return self._real_sigma
1989
+
1990
+
1991
+ class CreateSelfEnergies:
1992
+ r"""
1993
+ Thin container for self-energies with leaf-aware utilities.
1994
+ All items are assumed to be leaf self-energy objects with
1995
+ a `.label` attribute for identification.
1996
+ """
1997
+
1998
+ def __init__(self, self_energies):
1999
+ self.self_energies = self_energies
2000
+
2001
+ # ------ Basic container protocol ------
2002
+ def __call__(self):
2003
+ return self.self_energies
2004
+
2005
+ @property
2006
+ def self_energies(self):
2007
+ return self._self_energies
2008
+
2009
+ @self_energies.setter
2010
+ def self_energies(self, x):
2011
+ self._self_energies = x
2012
+
2013
+ def __iter__(self):
2014
+ return iter(self.self_energies)
2015
+
2016
+ def __getitem__(self, index):
2017
+ return self.self_energies[index]
2018
+
2019
+ def __setitem__(self, index, value):
2020
+ self.self_energies[index] = value
2021
+
2022
+ def __len__(self):
2023
+ return len(self.self_energies)
2024
+
2025
+ def __deepcopy__(self, memo):
2026
+ import copy
2027
+ return type(self)(copy.deepcopy(self.self_energies, memo))
2028
+
2029
+ # ------ Label-based utilities ------
2030
+ def get_by_label(self, label):
2031
+ r"""
2032
+ Return the self-energy object with the given label.
2033
+
2034
+ Parameters
2035
+ ----------
2036
+ label : str
2037
+ Label of the self-energy to retrieve.
2038
+
2039
+ Returns
2040
+ -------
2041
+ obj : SelfEnergy
2042
+ The corresponding self-energy instance.
2043
+
2044
+ Raises
2045
+ ------
2046
+ KeyError
2047
+ If no self-energy with the given label exists.
2048
+ """
2049
+ for se in self.self_energies:
2050
+ if getattr(se, "label", None) == label:
2051
+ return se
2052
+ raise KeyError(
2053
+ f"No self-energy with label {label!r} found in container."
2054
+ )
2055
+
2056
+ def labels(self):
2057
+ r"""
2058
+ Return a list of all labels.
2059
+ """
2060
+ return [getattr(se, "label", None) for se in self.self_energies]
2061
+
2062
+ def as_dict(self):
2063
+ r"""
2064
+ Return a {label: self_energy} dictionary for convenient access.
2065
+ """
2066
+ return {se.label: se for se in self.self_energies}
2067
+