xarpes 0.2.4__py3-none-any.whl → 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
xarpes/selfenergies.py ADDED
@@ -0,0 +1,1816 @@
1
+ # Copyright (C) 2025 xARPES Developers
2
+ # This program is free software under the terms of the GNU GPLv3 license.
3
+
4
+ # get_ax_fig_plt and add_fig_kwargs originate from pymatgen/util/plotting.py.
5
+ # Copyright (C) 2011-2024 Shyue Ping Ong and the pymatgen Development Team
6
+ # Pymatgen is released under the MIT License.
7
+
8
+ # See also abipy/tools/plotting.py.
9
+ # Copyright (C) 2021 Matteo Giantomassi and the AbiPy Group
10
+ # AbiPy is free software under the terms of the GNU GPLv2 license.
11
+
12
+ """File containing the band map class."""
13
+
14
+ import numpy as np
15
+ from .plotting import get_ax_fig_plt, add_fig_kwargs
16
+ from .constants import PREF, KILO, K_B
17
+
18
+ class SelfEnergy:
19
+ r"""Self-energy"""
20
+
21
+ def __init__(self, ekin_range, hnuminPhi, energy_resolution,
22
+ temperature, label, properties, parameters):
23
+ # core read-only state
24
+ self._ekin_range = ekin_range
25
+ self._hnuminPhi = hnuminPhi
26
+ self._energy_resolution = energy_resolution
27
+ self._temperature = temperature
28
+ self._label = label
29
+
30
+ # accept either a dict or a single-element list of dicts
31
+ if isinstance(properties, list):
32
+ if len(properties) == 1:
33
+ properties = properties[0]
34
+ else:
35
+ raise ValueError(
36
+ "`properties` must be a dict or a single dict in a list."
37
+ )
38
+
39
+ # single source of truth for all params (+ their *_sigma)
40
+ self._properties = dict(properties or {})
41
+ self._class = self._properties.get("_class", None)
42
+
43
+ # ---- enforce supported classes at construction
44
+ if self._class not in ("SpectralLinear", "SpectralQuadratic"):
45
+ raise ValueError(
46
+ f"Unsupported spectral class '{self._class}'. "
47
+ "Only 'SpectralLinear' or 'SpectralQuadratic' are allowed."
48
+ )
49
+
50
+ # grab user parameters
51
+ self._parameters = dict(parameters or {})
52
+ self._fermi_wavevector = self._parameters.get("fermi_wavevector")
53
+ self._fermi_velocity = self._parameters.get("fermi_velocity")
54
+ self._bare_mass = self._parameters.get("bare_mass")
55
+ self._side = self._parameters.get("side", None)
56
+
57
+ # ---- class-specific parameter constraints
58
+ if self._class == "SpectralLinear" and (self._bare_mass is not None):
59
+ raise ValueError("`bare_mass` cannot be set for SpectralLinear.")
60
+ if self._class == "SpectralQuadratic" and (self._fermi_velocity is not None):
61
+ raise ValueError(
62
+ "`fermi_velocity` cannot be set for SpectralQuadratic."
63
+ )
64
+
65
+ if self._side is not None and self._side not in ("left", "right"):
66
+ raise ValueError("`side` must be 'left' or 'right' if provided.")
67
+ if self._side is not None:
68
+ self._parameters["side"] = self._side
69
+
70
+ # convenience attributes (read from properties)
71
+ self._amplitude = self._properties.get("amplitude")
72
+ self._amplitude_sigma = self._properties.get("amplitude_sigma")
73
+ self._peak = self._properties.get("peak")
74
+ self._peak_sigma = self._properties.get("peak_sigma")
75
+ self._broadening = self._properties.get("broadening")
76
+ self._broadening_sigma = self._properties.get("broadening_sigma")
77
+ self._center_wavevector = self._properties.get("center_wavevector")
78
+
79
+ # lazy caches
80
+ self._peak_positions = None
81
+ self._peak_positions_sigma = None
82
+ self._real = None
83
+ self._real_sigma = None
84
+ self._imag = None
85
+ self._imag_sigma = None
86
+
87
+ def _check_mass_velocity_exclusivity(self):
88
+ """Ensure that fermi_velocity and bare_mass are not both set."""
89
+ if (self._fermi_velocity is not None) and (self._bare_mass is not None):
90
+ raise ValueError(
91
+ "Cannot set both `fermi_velocity` and `bare_mass`: choose one "
92
+ "physical parametrization (SpectralLinear or SpectralQuadratic)."
93
+ )
94
+
95
+ # ---------------- core read-only axes ----------------
96
+ @property
97
+ def ekin_range(self):
98
+ return self._ekin_range
99
+
100
+ @property
101
+ def enel_range(self):
102
+ if self._ekin_range is None:
103
+ return None
104
+ hnp = 0.0 if self._hnuminPhi is None else self._hnuminPhi
105
+ return np.asarray(self._ekin_range) - hnp
106
+
107
+ @property
108
+ def hnuminPhi(self):
109
+ return self._hnuminPhi
110
+
111
+ @property
112
+ def energy_resolution(self):
113
+ """Energy resolution associated with the self-energy."""
114
+ return self._energy_resolution
115
+
116
+ @property
117
+ def temperature(self):
118
+ """Temperature associated with the self-energy [K]."""
119
+ return self._temperature
120
+
121
+ # ---------------- identifiers ----------------
122
+ @property
123
+ def label(self):
124
+ return self._label
125
+
126
+ @label.setter
127
+ def label(self, x):
128
+ self._label = x
129
+
130
+ # ---------------- exported user parameters ----------------
131
+ @property
132
+ def parameters(self):
133
+ """Dictionary with user-supplied parameters (read-only view)."""
134
+ return self._parameters
135
+
136
+ @property
137
+ def side(self):
138
+ """Optional side selector: 'left' or 'right'."""
139
+ return self._side
140
+
141
+ @side.setter
142
+ def side(self, x):
143
+ if x is not None and x not in ("left", "right"):
144
+ raise ValueError("`side` must be 'left' or 'right' if provided.")
145
+ self._side = x
146
+ if x is not None:
147
+ self._parameters["side"] = x
148
+ else:
149
+ self._parameters.pop("side", None)
150
+ # affects sign of peak_positions and thus `real`
151
+ self._peak_positions = None
152
+ self._real = None
153
+ self._real_sigma = None
154
+ self._mdc_maxima = None
155
+
156
+ @property
157
+ def fermi_wavevector(self):
158
+ """Optional k_F; can be set later."""
159
+ return self._fermi_wavevector
160
+
161
+ @fermi_wavevector.setter
162
+ def fermi_wavevector(self, x):
163
+ self._fermi_wavevector = x
164
+ self._parameters["fermi_wavevector"] = x
165
+ # invalidate dependent cache
166
+ self._real = None
167
+ self._real_sigma = None
168
+
169
+ @property
170
+ def fermi_velocity(self):
171
+ """Optional v_F; can be set later."""
172
+ return self._fermi_velocity
173
+
174
+ @fermi_velocity.setter
175
+ def fermi_velocity(self, x):
176
+ if self._class == "SpectralQuadratic":
177
+ raise ValueError(
178
+ "`fermi_velocity` cannot be set for SpectralQuadratic."
179
+ )
180
+ self._fermi_velocity = x
181
+ self._parameters["fermi_velocity"] = x
182
+ # invalidate dependents
183
+ self._imag = None; self._imag_sigma = None
184
+ self._real = None; self._real_sigma = None
185
+
186
+ @property
187
+ def bare_mass(self):
188
+ """Optional bare mass; used by SpectralQuadratic formulas."""
189
+ return self._bare_mass
190
+
191
+ @bare_mass.setter
192
+ def bare_mass(self, x):
193
+ if self._class == "SpectralLinear":
194
+ raise ValueError(
195
+ "`bare_mass` cannot be set for SpectralLinear."
196
+ )
197
+ self._bare_mass = x
198
+ self._parameters["bare_mass"] = x
199
+ # invalidate dependents
200
+ self._imag = None; self._imag_sigma = None
201
+ self._real = None; self._real_sigma = None
202
+
203
+ # ---------------- optional fit parameters (convenience) ----------------
204
+ @property
205
+ def amplitude(self):
206
+ return self._amplitude
207
+
208
+ @amplitude.setter
209
+ def amplitude(self, x):
210
+ self._amplitude = x
211
+ self._properties["amplitude"] = x
212
+
213
+ @property
214
+ def amplitude_sigma(self):
215
+ return self._amplitude_sigma
216
+
217
+ @amplitude_sigma.setter
218
+ def amplitude_sigma(self, x):
219
+ self._amplitude_sigma = x
220
+ self._properties["amplitude_sigma"] = x
221
+
222
+ @property
223
+ def peak(self):
224
+ return self._peak
225
+
226
+ @peak.setter
227
+ def peak(self, x):
228
+ self._peak = x
229
+ self._properties["peak"] = x
230
+ # invalidate dependent cache
231
+ self._peak_positions = None
232
+ self._real = None
233
+ self._mdc_maxima = None
234
+
235
+ @property
236
+ def peak_sigma(self):
237
+ return self._peak_sigma
238
+
239
+ @peak_sigma.setter
240
+ def peak_sigma(self, x):
241
+ self._peak_sigma = x
242
+ self._properties["peak_sigma"] = x
243
+ self._peak_positions_sigma = None
244
+ self._real_sigma = None
245
+
246
+ @property
247
+ def broadening(self):
248
+ return self._broadening
249
+
250
+ @broadening.setter
251
+ def broadening(self, x):
252
+ self._broadening = x
253
+ self._properties["broadening"] = x
254
+ self._imag = None
255
+
256
+ @property
257
+ def broadening_sigma(self):
258
+ return self._broadening_sigma
259
+
260
+ @broadening_sigma.setter
261
+ def broadening_sigma(self, x):
262
+ self._broadening_sigma = x
263
+ self._properties["broadening_sigma"] = x
264
+ self._imag_sigma = None
265
+
266
+ @property
267
+ def center_wavevector(self):
268
+ """Read-only center wavevector (SpectralQuadratic, if present)."""
269
+ return self._center_wavevector
270
+
271
+ # ---------------- derived outputs ----------------
272
+ @property
273
+ def peak_positions(self):
274
+ r"""k_parallel = peak * dtor * sqrt(ekin_range / PREF) (lazy)."""
275
+ if self._peak_positions is None:
276
+ if self._peak is None or self._ekin_range is None:
277
+ return None
278
+ if self._class == "SpectralQuadratic":
279
+ if self._side is None:
280
+ raise AttributeError(
281
+ "For SpectralQuadratic, set `side` ('left'/'right') "
282
+ "before accessing peak_positions and quantities that "
283
+ "depend on the latter."
284
+ )
285
+ kpar_mag = (
286
+ np.sqrt(self._ekin_range / PREF)
287
+ * np.sin(np.deg2rad(np.abs(self._peak)))
288
+ )
289
+ self._peak_positions = ((-1.0 if self._side == "left"
290
+ else 1.0) * kpar_mag)
291
+ else:
292
+ self._peak_positions = (np.sqrt(self._ekin_range / PREF)
293
+ * np.sin(np.deg2rad(self._peak)))
294
+ return self._peak_positions
295
+
296
+
297
+ @property
298
+ def peak_positions_sigma(self):
299
+ r"""Std. dev. of k_parallel (lazy)."""
300
+ if self._peak_positions_sigma is None:
301
+ if self._peak_sigma is None or self._ekin_range is None:
302
+ return None
303
+ self._peak_positions_sigma = (
304
+ np.sqrt(self._ekin_range / PREF)
305
+ * np.abs(np.cos(np.deg2rad(self._peak)))
306
+ * np.deg2rad(self._peak_sigma)
307
+ )
308
+ return self._peak_positions_sigma
309
+
310
+
311
+ @property
312
+ def imag(self):
313
+ r"""-Σ'' (lazy)."""
314
+ if self._imag is None:
315
+ if self._broadening is None or self._ekin_range is None:
316
+ return None
317
+ self._imag = self._compute_imag()
318
+ return self._imag
319
+
320
+
321
+ @property
322
+ def imag_sigma(self):
323
+ r"""Std. dev. of -Σ'' (lazy)."""
324
+ if self._imag_sigma is None:
325
+ if self._broadening_sigma is None or self._ekin_range is None:
326
+ return None
327
+ self._imag_sigma = self._compute_imag_sigma()
328
+ return self._imag_sigma
329
+
330
+
331
+ @property
332
+ def real(self):
333
+ r"""Σ' (lazy)."""
334
+ if self._real is None:
335
+ if self._peak is None or self._ekin_range is None:
336
+ return None
337
+ self._real = self._compute_real()
338
+ return self._real
339
+
340
+
341
+ @property
342
+ def real_sigma(self):
343
+ r"""Std. dev. of Σ' (lazy)."""
344
+ if self._real_sigma is None:
345
+ if self._peak_sigma is None or self._ekin_range is None:
346
+ return None
347
+ self._real_sigma = self._compute_real_sigma()
348
+ return self._real_sigma
349
+
350
+
351
+ def _compute_imag(self, fermi_velocity=None, bare_mass=None):
352
+ r"""Compute -Σ'' without touching caches."""
353
+ if self._broadening is None or self._ekin_range is None:
354
+ return None
355
+
356
+ ekin = np.asarray(self._ekin_range)
357
+ broad = self._broadening
358
+
359
+ if self._class == "SpectralLinear":
360
+ vF = self._fermi_velocity if fermi_velocity is None else fermi_velocity
361
+ if vF is None:
362
+ raise AttributeError(
363
+ "Cannot compute `imag` (SpectralLinear): set `fermi_velocity` "
364
+ "first."
365
+ )
366
+ return np.abs(vF) * np.sqrt(ekin / PREF) * broad
367
+
368
+ if self._class == "SpectralQuadratic":
369
+ mb = self._bare_mass if bare_mass is None else bare_mass
370
+ if mb is None:
371
+ raise AttributeError(
372
+ "Cannot compute `imag` (SpectralQuadratic): set `bare_mass` "
373
+ "first."
374
+ )
375
+ return (ekin * broad) / np.abs(mb)
376
+
377
+ raise NotImplementedError(
378
+ f"_compute_imag is not implemented for spectral class '{self._class}'."
379
+ )
380
+
381
+
382
+ def _compute_imag_sigma(self, fermi_velocity=None, bare_mass=None):
383
+ r"""Compute std. dev. of -Σ'' without touching caches."""
384
+ if self._broadening_sigma is None or self._ekin_range is None:
385
+ return None
386
+
387
+ ekin = np.asarray(self._ekin_range)
388
+ broad_sigma = self._broadening_sigma
389
+
390
+ if self._class == "SpectralLinear":
391
+ vF = self._fermi_velocity if fermi_velocity is None else fermi_velocity
392
+ if vF is None:
393
+ raise AttributeError(
394
+ "Cannot compute `imag_sigma` (SpectralLinear): set "
395
+ "`fermi_velocity` first."
396
+ )
397
+ return np.abs(vF) * np.sqrt(ekin / PREF) * broad_sigma
398
+
399
+ if self._class == "SpectralQuadratic":
400
+ mb = self._bare_mass if bare_mass is None else bare_mass
401
+ if mb is None:
402
+ raise AttributeError(
403
+ "Cannot compute `imag_sigma` (SpectralQuadratic): set "
404
+ "`bare_mass` first."
405
+ )
406
+ return (ekin * broad_sigma) / np.abs(mb)
407
+
408
+ raise NotImplementedError(
409
+ f"_compute_imag_sigma is not implemented for spectral class "
410
+ f"'{self._class}'."
411
+ )
412
+
413
+
414
+ def _compute_real(self, fermi_velocity=None, fermi_wavevector=None,
415
+ bare_mass=None):
416
+ r"""Compute Σ' without touching caches."""
417
+ if self._peak is None or self._ekin_range is None:
418
+ return None
419
+
420
+ enel = self.enel_range
421
+ kpar = self.peak_positions
422
+ if kpar is None:
423
+ return None
424
+
425
+ if self._class == "SpectralLinear":
426
+ vF = self._fermi_velocity if fermi_velocity is None else fermi_velocity
427
+ kF = (self._fermi_wavevector if fermi_wavevector is None
428
+ else fermi_wavevector)
429
+ if vF is None or kF is None:
430
+ raise AttributeError(
431
+ "Cannot compute `real` (SpectralLinear): set `fermi_velocity` "
432
+ "and `fermi_wavevector` first."
433
+ )
434
+ return enel - vF * (kpar - kF)
435
+
436
+ if self._class == "SpectralQuadratic":
437
+ mb = self._bare_mass if bare_mass is None else bare_mass
438
+ kF = (self._fermi_wavevector if fermi_wavevector is None
439
+ else fermi_wavevector)
440
+ if mb is None or kF is None:
441
+ raise AttributeError(
442
+ "Cannot compute `real` (SpectralQuadratic): set `bare_mass` "
443
+ "and `fermi_wavevector` first."
444
+ )
445
+ return enel - (PREF / mb) * (kpar**2 - kF**2)
446
+
447
+ raise NotImplementedError(
448
+ f"_compute_real is not implemented for spectral class '{self._class}'."
449
+ )
450
+
451
+ def _compute_real_sigma(self, fermi_velocity=None, fermi_wavevector=None,
452
+ bare_mass=None):
453
+ r"""Compute std. dev. of Σ' without touching caches."""
454
+ if self._peak_sigma is None or self._ekin_range is None:
455
+ return None
456
+
457
+ kpar_sigma = self.peak_positions_sigma
458
+ if kpar_sigma is None:
459
+ return None
460
+
461
+ if self._class == "SpectralLinear":
462
+ vF = self._fermi_velocity if fermi_velocity is None else fermi_velocity
463
+ if vF is None:
464
+ raise AttributeError(
465
+ "Cannot compute `real_sigma` (SpectralLinear): set "
466
+ "`fermi_velocity` first."
467
+ )
468
+ return np.abs(vF) * kpar_sigma
469
+
470
+ if self._class == "SpectralQuadratic":
471
+ mb = self._bare_mass if bare_mass is None else bare_mass
472
+ kF = (self._fermi_wavevector if fermi_wavevector is None
473
+ else fermi_wavevector)
474
+ if mb is None or kF is None:
475
+ raise AttributeError(
476
+ "Cannot compute `real_sigma` (SpectralQuadratic): set "
477
+ "`bare_mass` and `fermi_wavevector` first."
478
+ )
479
+
480
+ kpar = self.peak_positions
481
+ if kpar is None:
482
+ return None
483
+ return 2.0 * PREF * kpar_sigma * np.abs(kpar / mb)
484
+
485
+ raise ValueError(
486
+ f"Unsupported spectral class '{self._class}' in "
487
+ "_compute_real_sigma."
488
+ )
489
+
490
+
491
+ def _evaluate_self_energy_arrays(self, fermi_velocity=None, fermi_wavevector=None,
492
+ bare_mass=None):
493
+ r"""Evaluate Σ' / -Σ'' and 1σ uncertainties without mutating caches."""
494
+ real = self._compute_real(
495
+ fermi_velocity=fermi_velocity,
496
+ fermi_wavevector=fermi_wavevector,
497
+ bare_mass=bare_mass,
498
+ )
499
+ real_sigma = self._compute_real_sigma(
500
+ fermi_velocity=fermi_velocity,
501
+ fermi_wavevector=fermi_wavevector,
502
+ bare_mass=bare_mass,
503
+ )
504
+ imag = self._compute_imag(fermi_velocity=fermi_velocity, bare_mass=bare_mass)
505
+ imag_sigma = self._compute_imag_sigma(
506
+ fermi_velocity=fermi_velocity, bare_mass=bare_mass
507
+ )
508
+ return real, real_sigma, imag, imag_sigma
509
+
510
+
511
+ @property
512
+ def mdc_maxima(self):
513
+ """
514
+ MDC maxima (lazy).
515
+
516
+ SpectralLinear:
517
+ identical to peak_positions
518
+
519
+ SpectralQuadratic:
520
+ peak_positions + center_wavevector
521
+ """
522
+ if getattr(self, "_mdc_maxima", None) is None:
523
+ if self.peak_positions is None:
524
+ return None
525
+
526
+ if self._class == "SpectralLinear":
527
+ self._mdc_maxima = self.peak_positions
528
+ elif self._class == "SpectralQuadratic":
529
+ self._mdc_maxima = (
530
+ self.peak_positions + self._center_wavevector
531
+ )
532
+
533
+ return self._mdc_maxima
534
+
535
+ def _se_legend_labels(self):
536
+ """Return (real_label, imag_label) for legend with safe subscripts."""
537
+ se_label = getattr(self, "label", None)
538
+
539
+ if se_label is None:
540
+ real_label = r"$\Sigma'(E)$"
541
+ imag_label = r"$-\Sigma''(E)$"
542
+ return real_label, imag_label
543
+
544
+ safe_label = str(se_label).replace("_", r"\_")
545
+
546
+ # If the label is empty after conversion, fall back
547
+ if safe_label == "":
548
+ real_label = r"$\Sigma'(E)$"
549
+ imag_label = r"$-\Sigma''(E)$"
550
+ return real_label, imag_label
551
+
552
+ real_label = rf"$\Sigma_{{\mathrm{{{safe_label}}}}}'(E)$"
553
+ imag_label = rf"$-\Sigma_{{\mathrm{{{safe_label}}}}}''(E)$"
554
+
555
+ return real_label, imag_label
556
+
557
+ @add_fig_kwargs
558
+ def plot_real(self, ax=None, **kwargs):
559
+ r"""Plot the real part Σ' of the self-energy as a function of E-μ.
560
+
561
+ Parameters
562
+ ----------
563
+ ax : Matplotlib-Axes or None
564
+ Axis to plot on. Created if not provided by the user.
565
+ **kwargs :
566
+ Additional keyword arguments passed to ``ax.errorbar``.
567
+
568
+ Returns
569
+ -------
570
+ fig : Matplotlib-Figure
571
+ Figure containing the Σ'(E) plot.
572
+ """
573
+ from . import settings_parameters as xprs
574
+
575
+ ax, fig, plt = get_ax_fig_plt(ax=ax)
576
+
577
+ x = self.enel_range
578
+ y = self.real
579
+ y_sigma = self.real_sigma
580
+
581
+ real_label, _ = self._se_legend_labels()
582
+ kwargs.setdefault("label", real_label)
583
+
584
+ if y_sigma is not None:
585
+ if np.isnan(y_sigma).any():
586
+ print(
587
+ "Warning: some Σ'(E) uncertainty values are missing. "
588
+ "Error bars omitted at those energies."
589
+ )
590
+ kwargs.setdefault("yerr", xprs.sigma_confidence * y_sigma)
591
+
592
+ ax.errorbar(x, y, **kwargs)
593
+ ax.set_xlabel(r"$E-\mu$ (eV)")
594
+ ax.set_ylabel(r"$\Sigma'(E)$ (eV)")
595
+ ax.legend()
596
+
597
+ return fig
598
+
599
+ @add_fig_kwargs
600
+ def plot_imag(self, ax=None, **kwargs):
601
+ r"""Plot the imaginary part -Σ'' of the self-energy vs. E-μ.
602
+
603
+ Parameters
604
+ ----------
605
+ ax : Matplotlib-Axes or None
606
+ Axis to plot on. Created if not provided by the user.
607
+ **kwargs :
608
+ Additional keyword arguments passed to ``ax.errorbar``.
609
+
610
+ Returns
611
+ -------
612
+ fig : Matplotlib-Figure
613
+ Figure containing the -Σ''(E) plot.
614
+ """
615
+ from . import settings_parameters as xprs
616
+
617
+ ax, fig, plt = get_ax_fig_plt(ax=ax)
618
+
619
+ x = self.enel_range
620
+ y = self.imag
621
+ y_sigma = self.imag_sigma
622
+
623
+ _, imag_label = self._se_legend_labels()
624
+ kwargs.setdefault("label", imag_label)
625
+
626
+ if y_sigma is not None:
627
+ if np.isnan(y_sigma).any():
628
+ print(
629
+ "Warning: some -Σ''(E) uncertainty values are missing. "
630
+ "Error bars omitted at those energies."
631
+ )
632
+ kwargs.setdefault("yerr", xprs.sigma_confidence * y_sigma)
633
+
634
+ ax.errorbar(x, y, **kwargs)
635
+ ax.set_xlabel(r"$E-\mu$ (eV)")
636
+ ax.set_ylabel(r"$-\Sigma''(E)$ (eV)")
637
+ ax.legend()
638
+
639
+ return fig
640
+
641
+ @add_fig_kwargs
642
+ def plot_both(self, ax=None, **kwargs):
643
+ r"""Plot Σ'(E) and -Σ''(E) vs. E-μ on the same axis."""
644
+ from . import settings_parameters as xprs
645
+
646
+ ax, fig, plt = get_ax_fig_plt(ax=ax)
647
+
648
+ x = self.enel_range
649
+ real = self.real
650
+ imag = self.imag
651
+ real_sigma = self.real_sigma
652
+ imag_sigma = self.imag_sigma
653
+
654
+ real_label, imag_label = self._se_legend_labels()
655
+
656
+ # --- plot Σ'
657
+ kw_real = dict(kwargs)
658
+ if real_sigma is not None:
659
+ if np.isnan(real_sigma).any():
660
+ print(
661
+ "Warning: some Σ'(E) uncertainty values are missing. "
662
+ "Error bars omitted at those energies."
663
+ )
664
+ kw_real.setdefault("yerr", xprs.sigma_confidence * real_sigma)
665
+ kw_real.setdefault("label", real_label)
666
+ ax.errorbar(x, real, **kw_real)
667
+
668
+ # --- plot -Σ''
669
+ kw_imag = dict(kwargs)
670
+ if imag_sigma is not None:
671
+ if np.isnan(imag_sigma).any():
672
+ print(
673
+ "Warning: some -Σ''(E) uncertainty values are missing. "
674
+ "Error bars omitted at those energies."
675
+ )
676
+ kw_imag.setdefault("yerr", xprs.sigma_confidence * imag_sigma)
677
+ kw_imag.setdefault("label", imag_label)
678
+ ax.errorbar(x, imag, **kw_imag)
679
+
680
+ ax.set_xlabel(r"$E-\mu$ (eV)")
681
+ ax.set_ylabel(r"$\Sigma'(E),\ -\Sigma''(E)$ (eV)")
682
+ ax.legend()
683
+
684
+ return fig
685
+
686
+
687
+ def extract_a2f(self, *, omega_min, omega_max, omega_num, omega_I, omega_M,
688
+ mem=None, **mem_kwargs):
689
+ r"""
690
+ Extract Eliashberg function α²F(ω) from the self-energy. While working
691
+ with band maps and MDCs is more intuitive in eV, the self-energy
692
+ extraction is performed in eV.
693
+
694
+ """
695
+ from . import settings_parameters as xprs
696
+
697
+ mem_cfg = self._merge_defaults(xprs.mem_defaults, mem, mem_kwargs)
698
+
699
+ method = mem_cfg["method"]
700
+ parts = mem_cfg["parts"]
701
+ iter_max = int(mem_cfg["iter_max"])
702
+ alpha_min = float(mem_cfg["alpha_min"])
703
+ alpha_max = float(mem_cfg["alpha_max"])
704
+ alpha_num = int(mem_cfg["alpha_num"])
705
+ ecut_left = float(mem_cfg["ecut_left"])
706
+ ecut_right = mem_cfg["ecut_right"]
707
+ omega_S = float(mem_cfg["omega_S"])
708
+ f_chi_squared = mem_cfg["f_chi_squared"]
709
+ sigma_svd = float(mem_cfg["sigma_svd"])
710
+ t_criterion = float(mem_cfg["t_criterion"])
711
+ mu = float(mem_cfg["mu"])
712
+ a_guess = float(mem_cfg["a_guess"])
713
+ b_guess = float(mem_cfg["b_guess"])
714
+ c_guess = float(mem_cfg["c_guess"])
715
+ d_guess = float(mem_cfg["d_guess"])
716
+ power = int(mem_cfg["power"])
717
+ lambda_el = float(mem_cfg["lambda_el"])
718
+ impurity_magnitude = float(mem_cfg["impurity_magnitude"])
719
+ W = mem_cfg.get("W", None)
720
+
721
+ if omega_S < 0.0:
722
+ raise ValueError("omega_S must be >= 0.")
723
+ if f_chi_squared is None:
724
+ f_chi_squared = 2.5 if parts == "both" else 2.0
725
+ else:
726
+ f_chi_squared = float(f_chi_squared)
727
+
728
+ h_n = mem_cfg.get("h_n", None)
729
+ if h_n is None:
730
+ raise ValueError(
731
+ "`h_n` must be provided explicitly (h_n=... or mem={'h_n': ...}). "
732
+ "No default is assumed."
733
+ )
734
+
735
+ from . import (create_model_function, create_kernel_function,
736
+ singular_value_decomposition, MEM_core)
737
+
738
+ omega_range = np.linspace(omega_min, omega_max, omega_num)
739
+
740
+ model = create_model_function(omega_range, omega_I, omega_M, omega_S,
741
+ h_n)
742
+
743
+ delta_omega = (omega_max - omega_min) / (omega_num - 1)
744
+ model_in = model * delta_omega
745
+
746
+ energies_eV = self.enel_range
747
+
748
+ ecut_left_eV = ecut_left / KILO
749
+ if ecut_right is None:
750
+ ecut_right_eV = self.energy_resolution
751
+ else:
752
+ ecut_right_eV = float(ecut_right) / KILO
753
+
754
+ Emin = np.min(energies_eV)
755
+ Elow = Emin + ecut_left_eV
756
+ Ehigh = -ecut_right_eV
757
+ mE = (energies_eV >= Elow) & (energies_eV <= Ehigh)
758
+
759
+ if not np.any(mE):
760
+ raise ValueError(
761
+ "Energy cutoffs removed all points; adjust ecut_left/right."
762
+ )
763
+
764
+ energies_eV_masked = energies_eV[mE]
765
+ energies = energies_eV_masked * KILO
766
+
767
+ k_BT = K_B * self.temperature * KILO
768
+
769
+ kernel = create_kernel_function(energies, omega_range, k_BT)
770
+
771
+ if lambda_el:
772
+ if W is None:
773
+ if self._class == "SpectralQuadratic":
774
+ W = (
775
+ PREF * self._fermi_wavevector**2 / self._bare_mass
776
+ ) * KILO
777
+ else:
778
+ raise ValueError(
779
+ "lambda_el was provided, but W is None. For a linearised "
780
+ "band (SpectralLinear), you must also provide W in meV: "
781
+ "the electron–electron interaction scale."
782
+ )
783
+
784
+
785
+ energies_el = energies_eV_masked * KILO
786
+ real_el, imag_el = self._el_el_self_energy(
787
+ energies_el, k_BT, lambda_el, W, power
788
+ )
789
+ else:
790
+ real_el = 0.0
791
+ imag_el = 0.0
792
+
793
+ if parts == "both":
794
+ real = self.real[mE] * KILO - real_el
795
+ real_sigma = self.real_sigma[mE] * KILO
796
+ imag = self.imag[mE] * KILO - impurity_magnitude - imag_el
797
+ imag_sigma = self.imag_sigma[mE] * KILO
798
+ dvec = np.concatenate((real, imag))
799
+ wvec = np.concatenate((real_sigma**(-2), imag_sigma**(-2)))
800
+ H = np.concatenate((np.real(kernel), -np.imag(kernel)))
801
+
802
+ elif parts == "real":
803
+ real = self.real[mE] * KILO - real_el
804
+ real_sigma = self.real_sigma[mE] * KILO
805
+ dvec = real
806
+ wvec = real_sigma**(-2)
807
+ H = np.real(kernel)
808
+
809
+ else: # parts == "imag"
810
+ imag = self.imag[mE] * KILO - impurity_magnitude - imag_el
811
+ imag_sigma = self.imag_sigma[mE] * KILO
812
+ dvec = imag
813
+ wvec = imag_sigma**(-2)
814
+ H = -np.imag(kernel)
815
+
816
+ V_Sigma, U, uvec = singular_value_decomposition(H, sigma_svd)
817
+
818
+ if method == "chi2kink":
819
+ spectrum_in, _ = self._chi2kink_a2f(
820
+ dvec, model_in, uvec, mu, wvec, V_Sigma, U, alpha_min,
821
+ alpha_max, alpha_num, a_guess, b_guess, c_guess, d_guess,
822
+ f_chi_squared, t_criterion, iter_max, MEM_core
823
+ )
824
+
825
+ spectrum = spectrum_in * omega_num / omega_max
826
+ return spectrum, model
827
+
828
+
829
+ def bayesian_loop(self, *, omega_min, omega_max, omega_num, omega_I,
830
+ omega_M, fermi_velocity=None,
831
+ fermi_wavevector=None, bare_mass=None, vary=(),
832
+ opt_method="Nelder-Mead", opt_options=None,
833
+ mem=None, loop=None, **mem_kwargs):
834
+ r"""
835
+ Bayesian outer loop calling `_cost_function()`.
836
+
837
+ If `vary` is non-empty, runs a SciPy optimization over the selected
838
+ parameters in `vary`.
839
+
840
+ Supported entries in `vary` depend on `self._class`:
841
+
842
+ - Common: "fermi_wavevector", "impurity_magnitude", "lambda_el", "h_n"
843
+ - SpectralLinear: additionally "fermi_velocity"
844
+ - SpectralQuadratic: additionally "bare_mass"
845
+
846
+ Notes
847
+ -----
848
+ **Convergence behaviour**
849
+
850
+ By default, convergence is controlled by a *custom patience criterion*:
851
+ the optimization terminates when the absolute difference between the
852
+ current cost and the best cost seen so far is smaller than `tole` for
853
+ `converge_iters` consecutive iterations.
854
+
855
+ To instead rely on SciPy's native convergence criteria (e.g. Nelder–Mead
856
+ `xatol` / `fatol`), disable the custom criterion by setting
857
+ `converge_iters=0` or `tole=None`. In that case, SciPy termination options
858
+ supplied via `opt_options` are used.
859
+
860
+ Parameters
861
+ ----------
862
+ opt_options : dict, optional
863
+ Options passed directly to `scipy.optimize.minimize`. These are only
864
+ used for convergence if the custom criterion is disabled (see Notes).
865
+ """
866
+
867
+ fermi_velocity, fermi_wavevector, bare_mass = self._prepare_bare(
868
+ fermi_velocity, fermi_wavevector, bare_mass)
869
+
870
+ vary = tuple(vary) if vary is not None else ()
871
+
872
+ allowed = {"fermi_wavevector", "impurity_magnitude", "lambda_el", "h_n"}
873
+
874
+ if self._class == "SpectralLinear":
875
+ allowed.add("fermi_velocity")
876
+ elif self._class == "SpectralQuadratic":
877
+ allowed.add("bare_mass")
878
+ else:
879
+ raise NotImplementedError(
880
+ f"bayesian_loop does not support spectral class '{self._class}'."
881
+ )
882
+
883
+ unknown = set(vary).difference(allowed)
884
+ if unknown:
885
+ raise ValueError(
886
+ f"Unsupported entries in vary: {sorted(unknown)}. "
887
+ f"Allowed: {sorted(allowed)}."
888
+ )
889
+
890
+ omega_num = int(omega_num)
891
+ if omega_num < 2:
892
+ raise ValueError("omega_num must be an integer >= 2.")
893
+
894
+ from . import settings_parameters as xprs
895
+
896
+ mem_cfg = self._merge_defaults(xprs.mem_defaults, mem, mem_kwargs)
897
+
898
+ parts = mem_cfg["parts"]
899
+ sigma_svd = float(mem_cfg["sigma_svd"])
900
+ ecut_left = float(mem_cfg["ecut_left"])
901
+ ecut_right = mem_cfg["ecut_right"]
902
+ omega_S = float(mem_cfg["omega_S"])
903
+ imp0 = float(mem_cfg["impurity_magnitude"])
904
+ lae0 = float(mem_cfg["lambda_el"])
905
+ h_n0 = float(mem_cfg["h_n"])
906
+ h_n_min = float(mem_cfg.get("h_n_min", 1e-8))
907
+
908
+ loop_overrides = {
909
+ key: val for key, val in mem_kwargs.items()
910
+ if (val is not None) and (key in xprs.loop_defaults)
911
+ }
912
+ loop_cfg = self._merge_defaults(xprs.loop_defaults, loop, loop_overrides)
913
+
914
+ tole = float(loop_cfg["tole"])
915
+ converge_iters = int(loop_cfg["converge_iters"])
916
+ opt_iter_max = int(loop_cfg["opt_iter_max"])
917
+ scale_vF = float(loop_cfg["scale_vF"])
918
+ scale_mb = float(loop_cfg["scale_mb"])
919
+ scale_imp = float(loop_cfg["scale_imp"])
920
+ scale_kF = float(loop_cfg["scale_kF"])
921
+ scale_lambda_el = float(loop_cfg["scale_lambda_el"])
922
+ scale_hn = float(loop_cfg["scale_hn"])
923
+
924
+ rollback_steps = int(loop_cfg.get("rollback_steps"))
925
+ max_retries = int(loop_cfg.get("max_retries"))
926
+ relative_best = float(loop_cfg.get("relative_best"))
927
+ min_steps_for_regression = int(loop_cfg.get("min_steps_for_regression"))
928
+
929
+ if rollback_steps < 0:
930
+ raise ValueError("rollback_steps must be >= 0.")
931
+ if max_retries < 0:
932
+ raise ValueError("max_retries must be >= 0.")
933
+ if relative_best <= 0.0:
934
+ raise ValueError("relative_best must be > 0.")
935
+ if min_steps_for_regression < 0:
936
+ raise ValueError("min_steps_for_regression must be >= 0.")
937
+
938
+ vF0 = float(fermi_velocity) if fermi_velocity is not None else None
939
+ kF0 = float(fermi_wavevector) if fermi_wavevector is not None else None
940
+ mb0 = float(bare_mass) if bare_mass is not None else None
941
+
942
+ if lae0 < 0.0:
943
+ raise ValueError("Initial lambda_el must be >= 0.")
944
+ if imp0 < 0.0:
945
+ raise ValueError("Initial impurity_magnitude must be >= 0.")
946
+ if omega_S < 0.0:
947
+ raise ValueError("omega_S must be >= 0.")
948
+ if h_n_min <= 0.0:
949
+ raise ValueError("h_n_min must be > 0.")
950
+ if h_n0 < h_n_min:
951
+ raise ValueError(
952
+ f"Initial h_n ({h_n0:g}) must be >= h_n_min ({h_n_min:g})."
953
+ )
954
+ if kF0 is None:
955
+ raise ValueError(
956
+ "bayesian_loop requires an initial fermi_wavevector."
957
+ )
958
+ if self._class == "SpectralLinear" and vF0 is None:
959
+ raise ValueError(
960
+ "bayesian_loop requires an initial fermi_velocity."
961
+ )
962
+ if self._class == "SpectralQuadratic" and mb0 is None:
963
+ raise ValueError("bayesian_loop requires an initial bare_mass.")
964
+
965
+ from scipy.optimize import minimize
966
+ from . import create_kernel_function, singular_value_decomposition
967
+
968
+ ecut_left = float(mem_cfg["ecut_left"])
969
+ ecut_right = mem_cfg["ecut_right"]
970
+
971
+ ecut_left_eV = ecut_left / KILO
972
+ if ecut_right is None:
973
+ ecut_right_eV = self.energy_resolution
974
+ else:
975
+ ecut_right_eV = float(ecut_right) / KILO
976
+
977
+ energies_eV = self.enel_range
978
+ Emin = np.min(energies_eV)
979
+ Elow = Emin + ecut_left_eV
980
+ Ehigh = -ecut_right_eV
981
+ mE = (energies_eV >= Elow) & (energies_eV <= Ehigh)
982
+
983
+ if not np.any(mE):
984
+ raise ValueError(
985
+ "Energy cutoffs removed all points; adjust ecut_left/right."
986
+ )
987
+
988
+ energies_eV_masked = energies_eV[mE]
989
+ energies = energies_eV_masked * KILO
990
+
991
+ k_BT = K_B * self.temperature * KILO
992
+ omega_range = np.linspace(omega_min, omega_max, omega_num)
993
+
994
+ kernel_raw = create_kernel_function(energies, omega_range, k_BT)
995
+
996
+ if parts == "both":
997
+ kernel_used = np.concatenate((np.real(kernel_raw), -np.imag(kernel_raw)))
998
+ elif parts == "real":
999
+ kernel_used = np.real(kernel_raw)
1000
+ else: # parts == "imag"
1001
+ kernel_used = -np.imag(kernel_raw)
1002
+
1003
+ V_Sigma, U, uvec0 = singular_value_decomposition(kernel_used, sigma_svd)
1004
+
1005
+ _precomp = {
1006
+ "omega_range": omega_range,
1007
+ "mE": mE,
1008
+ "energies_eV_masked": energies_eV_masked,
1009
+ "V_Sigma": V_Sigma,
1010
+ "U": U,
1011
+ "uvec0": uvec0,
1012
+ "ecut_left": ecut_left,
1013
+ "ecut_right": ecut_right,
1014
+ }
1015
+
1016
+ def _reflect_min(xi, p0, p_min, scale):
1017
+ """Map R -> [p_min, +inf) using linear reflection around p_min."""
1018
+ return p_min + np.abs((float(p0) - p_min) + scale * float(xi))
1019
+
1020
+ def _unpack_params(x):
1021
+ params = {}
1022
+
1023
+ i = 0
1024
+ for name in vary:
1025
+ xi = float(x[i])
1026
+
1027
+ if name == "fermi_velocity":
1028
+ if vF0 is None:
1029
+ raise ValueError("Cannot vary fermi_velocity: no "
1030
+ "initial vF provided.")
1031
+ params["fermi_velocity"] = vF0 + scale_vF * xi
1032
+
1033
+ elif name == "bare_mass":
1034
+ if mb0 is None:
1035
+ raise ValueError("Cannot vary bare_mass: no initial "
1036
+ "bare_mass provided.")
1037
+ params["bare_mass"] = mb0 + scale_mb * xi
1038
+
1039
+ elif name == "fermi_wavevector":
1040
+ if kF0 is None:
1041
+ raise ValueError(
1042
+ "Cannot vary fermi_wavevector: no initial kF "
1043
+ "provided."
1044
+ )
1045
+ params["fermi_wavevector"] = kF0 + scale_kF * xi
1046
+
1047
+ elif name == "impurity_magnitude":
1048
+ params["impurity_magnitude"] = _reflect_min(xi, imp0, 0.0, scale_imp)
1049
+
1050
+ elif name == "lambda_el":
1051
+ params["lambda_el"] = _reflect_min(xi, lae0, 0.0, scale_lambda_el)
1052
+
1053
+ elif name == "h_n":
1054
+ params["h_n"] = _reflect_min(xi, h_n0, h_n_min, scale_hn)
1055
+
1056
+ i += 1
1057
+
1058
+ params.setdefault("fermi_wavevector", kF0)
1059
+ params.setdefault("impurity_magnitude", imp0)
1060
+ params.setdefault("lambda_el", lae0)
1061
+ params.setdefault("h_n", h_n0)
1062
+
1063
+ if self._class == "SpectralLinear":
1064
+ params.setdefault("fermi_velocity", vF0)
1065
+ elif self._class == "SpectralQuadratic":
1066
+ params.setdefault("bare_mass", mb0)
1067
+
1068
+ return params
1069
+
1070
+ def _evaluate_cost(params):
1071
+ optimisation_parameters = {
1072
+ "h_n": params["h_n"],
1073
+ "impurity_magnitude": params["impurity_magnitude"],
1074
+ "lambda_el": params["lambda_el"],
1075
+ "fermi_wavevector": params["fermi_wavevector"],
1076
+ }
1077
+
1078
+ if self._class == "SpectralLinear":
1079
+ optimisation_parameters["fermi_velocity"] = params["fermi_velocity"]
1080
+ elif self._class == "SpectralQuadratic":
1081
+ optimisation_parameters["bare_mass"] = params["bare_mass"]
1082
+ else:
1083
+ raise NotImplementedError(
1084
+ f"_evaluate_cost does not support class '{self._class}'."
1085
+ )
1086
+
1087
+ return self._cost_function(
1088
+ optimisation_parameters=optimisation_parameters,
1089
+ omega_min=omega_min, omega_max=omega_max, omega_num=omega_num,
1090
+ omega_I=omega_I, omega_M=omega_M, mem_cfg=mem_cfg,
1091
+ _precomp=_precomp
1092
+ )
1093
+
1094
+ last = {"cost": None, "spectrum": None, "model": None, "alpha": None}
1095
+
1096
+ iter_counter = {"n": 0}
1097
+
1098
+ class ConvergenceException(RuntimeError):
1099
+ """Raised when optimisation has converged successfully."""
1100
+
1101
+ class RegressionException(RuntimeError):
1102
+ """Raised when optimizer regresses toward the initial guess."""
1103
+
1104
+ if converge_iters is None:
1105
+ converge_iters = 0
1106
+ converge_iters = int(converge_iters)
1107
+
1108
+ if tole is not None:
1109
+ tole = float(tole)
1110
+ if tole < 0.0:
1111
+ raise ValueError("tole must be >= 0.")
1112
+ if converge_iters < 0:
1113
+ raise ValueError("converge_iters must be >= 0.")
1114
+
1115
+ # Track best solution seen across all obj calls (not just last).
1116
+ best_global = {
1117
+ "x": None,
1118
+ "params": None,
1119
+ "cost": np.inf,
1120
+ "spectrum": None,
1121
+ "model": None,
1122
+ "alpha": None,
1123
+ }
1124
+
1125
+ history = []
1126
+
1127
+ # Cache most recent evaluation so the callback can read a cost without
1128
+ # forcing an extra objective evaluation.
1129
+ last_x = {"x": None}
1130
+ last_cost = {"cost": None}
1131
+ initial_cost = {"cost": None}
1132
+
1133
+ iter_counter = {"n": 0}
1134
+
1135
+ def _clean_params(params):
1136
+ """Convert NumPy scalar values to plain Python scalars."""
1137
+ out = {}
1138
+ for key, val in params.items():
1139
+ if isinstance(val, np.generic):
1140
+ out[key] = float(val)
1141
+ else:
1142
+ out[key] = val
1143
+ return out
1144
+
1145
+ def obj(x):
1146
+ import warnings
1147
+
1148
+ iter_counter["n"] += 1
1149
+
1150
+ params = _unpack_params(x)
1151
+
1152
+ with warnings.catch_warnings():
1153
+ warnings.simplefilter("error", RuntimeWarning)
1154
+ try:
1155
+ cost, spectrum, model, alpha_select = _evaluate_cost(params)
1156
+ except RuntimeWarning as exc:
1157
+ raise ValueError(f"RuntimeWarning during cost eval: {exc}") from exc
1158
+ cost_f = float(cost)
1159
+
1160
+ history.append(
1161
+ {
1162
+ "x": np.array(x, dtype=float, copy=True),
1163
+ "params": _clean_params(params),
1164
+ "cost": cost_f,
1165
+ "spectrum": spectrum,
1166
+ "model": model,
1167
+ "alpha": float(alpha_select),
1168
+ }
1169
+ )
1170
+
1171
+ last["cost"] = cost_f
1172
+ last["spectrum"] = spectrum
1173
+ last["model"] = model
1174
+ last["alpha"] = float(alpha_select)
1175
+
1176
+ last_x["x"] = np.array(x, dtype=float, copy=True)
1177
+ last_cost["cost"] = cost_f
1178
+
1179
+ if initial_cost["cost"] is None:
1180
+ initial_cost["cost"] = cost_f
1181
+
1182
+ if cost_f < best_global["cost"]:
1183
+ best_global["x"] = np.array(x, dtype=float, copy=True)
1184
+ best_global["cost"] = cost_f
1185
+ best_global["params"] = _clean_params(params)
1186
+ best_global["spectrum"] = spectrum
1187
+ best_global["model"] = model
1188
+ best_global["alpha"] = float(alpha_select)
1189
+
1190
+ msg = [f"Iter {iter_counter['n']:4d} | cost = {cost: .4e}"]
1191
+ for key in sorted(params):
1192
+ msg.append(f"{key}={params[key]:.8g}")
1193
+ print(" | ".join(msg))
1194
+
1195
+ return cost_f
1196
+
1197
+ class TerminationCallback:
1198
+ def __init__(self, tole, converge_iters, min_steps_for_regression):
1199
+ self.tole = None if tole is None else float(tole)
1200
+ self.converge_iters = int(converge_iters)
1201
+ self.min_steps_for_regression = int(min_steps_for_regression)
1202
+ self.iter_count = 0
1203
+ self.call_count = 0
1204
+
1205
+ def __call__(self, xk):
1206
+ self.call_count += 1
1207
+
1208
+ if self.tole is None or self.converge_iters <= 0:
1209
+ return
1210
+
1211
+ current = last_cost["cost"]
1212
+ if current is None:
1213
+ return
1214
+
1215
+ best_cost = float(best["cost"])
1216
+ if np.isfinite(best_cost) and abs(current - best_cost) < self.tole:
1217
+ self.iter_count += 1
1218
+ else:
1219
+ self.iter_count = 0
1220
+
1221
+ if self.iter_count >= self.converge_iters:
1222
+ raise ConvergenceException(
1223
+ f"Converged: |cost-best| < {self.tole:g} for "
1224
+ f"{self.converge_iters} iterations."
1225
+ )
1226
+
1227
+ if self.call_count < self.min_steps_for_regression:
1228
+ return
1229
+
1230
+ current = float(current)
1231
+ init = initial_cost["cost"]
1232
+ if init is None:
1233
+ return
1234
+
1235
+ best_cost = float(best["cost"])
1236
+ if not np.isfinite(best_cost):
1237
+ return
1238
+
1239
+ if abs(current - init) * relative_best < abs(current - best_cost):
1240
+ raise RegressionException(
1241
+ "Regression toward initial guess detected."
1242
+ )
1243
+
1244
+
1245
+ callback = TerminationCallback(
1246
+ tole=tole,
1247
+ converge_iters=converge_iters,
1248
+ min_steps_for_regression=min_steps_for_regression,
1249
+ )
1250
+
1251
+ if not vary:
1252
+ params = _unpack_params(np.zeros(0, dtype=float))
1253
+ cost, spectrum, model, alpha_select = _evaluate_cost(params)
1254
+ return cost, spectrum, model, alpha_select
1255
+
1256
+ x0 = np.zeros(len(vary), dtype=float)
1257
+
1258
+ options = {} if opt_options is None else dict(opt_options)
1259
+ options.setdefault("maxiter", int(opt_iter_max))
1260
+
1261
+ use_patience = (tole is not None) and (int(converge_iters) > 0)
1262
+ if use_patience:
1263
+ options.pop("xatol", None)
1264
+ options.pop("fatol", None)
1265
+
1266
+ retry_count = 0
1267
+ res = None
1268
+
1269
+ while retry_count <= max_retries:
1270
+ best = {
1271
+ "x": None,
1272
+ "params": None,
1273
+ "cost": np.inf,
1274
+ "spectrum": None,
1275
+ "model": None,
1276
+ "alpha": None,
1277
+ }
1278
+ last_x["x"] = None
1279
+ last_cost["cost"] = None
1280
+ initial_cost["cost"] = None
1281
+ iter_counter["n"] = 0
1282
+ history.clear()
1283
+
1284
+ callback = TerminationCallback(
1285
+ tole=tole,
1286
+ converge_iters=converge_iters,
1287
+ min_steps_for_regression=min_steps_for_regression,
1288
+ )
1289
+
1290
+ try:
1291
+ res = minimize(
1292
+ obj,
1293
+ x0,
1294
+ method=opt_method,
1295
+ options=options,
1296
+ callback=callback,
1297
+ )
1298
+ break
1299
+
1300
+ except ConvergenceException as exc:
1301
+ print(str(exc))
1302
+ res = None
1303
+ break
1304
+
1305
+ except RegressionException as exc:
1306
+ print(f"{exc} Rolling back {rollback_steps} steps.")
1307
+ retry_count += 1
1308
+
1309
+ if rollback_steps <= 0 or not history:
1310
+ continue
1311
+
1312
+ back = min(int(rollback_steps), len(history))
1313
+ x0 = np.array(history[-back]["x"], dtype=float, copy=True)
1314
+ continue
1315
+
1316
+ except ValueError as exc:
1317
+ print(f"ValueError encountered: {exc}. Rolling back.")
1318
+ retry_count += 1
1319
+
1320
+ if rollback_steps <= 0 or not history:
1321
+ continue
1322
+
1323
+ back = min(int(rollback_steps), len(history))
1324
+ x0 = np.array(history[-back]["x"], dtype=float, copy=True)
1325
+ continue
1326
+
1327
+ if retry_count > max_retries:
1328
+ print("Max retries reached. Parameters may not be optimal.")
1329
+
1330
+ if best_global["params"] is None:
1331
+ params = _unpack_params(x0)
1332
+ cost, spectrum, model, alpha_select = _evaluate_cost(params)
1333
+ else:
1334
+ params = best_global["params"]
1335
+ cost = best_global["cost"]
1336
+ spectrum = best_global["spectrum"]
1337
+ model = best_global["model"]
1338
+ alpha_select = best_global["alpha"]
1339
+
1340
+ args = ", ".join(
1341
+ f"{key}={params[key]:.10g}" if isinstance(params[key], float)
1342
+ else f"{key}={params[key]}"
1343
+ for key in sorted(params)
1344
+ )
1345
+ print("Optimised parameters:")
1346
+ print(args)
1347
+
1348
+ return cost, spectrum, model, alpha_select, params
1349
+
1350
+
1351
+ @staticmethod
1352
+ def _merge_defaults(defaults, override_dict=None, override_kwargs=None):
1353
+ """Merge defaults with dict + kwargs overrides (kwargs win)."""
1354
+ cfg = dict(defaults)
1355
+ if override_dict:
1356
+ cfg.update(dict(override_dict))
1357
+ if override_kwargs:
1358
+ cfg.update({k: v for k, v in override_kwargs.items() if v is not None})
1359
+ return cfg
1360
+
1361
+ def _prepare_bare(self, fermi_velocity, fermi_wavevector, bare_mass):
1362
+ """Validate class-compatible band parameters and infer missing defaults.
1363
+
1364
+ Enforces:
1365
+ - SpectralLinear: bare_mass must be None; vF and kF must be available.
1366
+ - SpectralQuadratic: fermi_velocity must be None; bare_mass and kF must
1367
+ be available.
1368
+
1369
+ Returns
1370
+ -------
1371
+ fermi_velocity : float or None
1372
+ Initial vF (Linear) or None (Quadratic).
1373
+ fermi_wavevector : float
1374
+ Initial kF.
1375
+ bare_mass : float or None
1376
+ Initial bare mass (Quadratic) or None (Linear).
1377
+ """
1378
+ if self._class == "SpectralLinear":
1379
+ if bare_mass is not None:
1380
+ raise ValueError(
1381
+ "SpectralLinear bayesian_loop does not accept "
1382
+ "`bare_mass`. Provide `fermi_velocity` instead."
1383
+ )
1384
+
1385
+ if fermi_velocity is None:
1386
+ fermi_velocity = getattr(self, "fermi_velocity", None)
1387
+ if fermi_velocity is None:
1388
+ raise ValueError(
1389
+ "SpectralLinear optimisation requires an initial "
1390
+ "fermi_velocity to be provided."
1391
+ )
1392
+
1393
+ if fermi_wavevector is None:
1394
+ fermi_wavevector = getattr(self, "fermi_wavevector", None)
1395
+ if fermi_wavevector is None:
1396
+ raise ValueError(
1397
+ "SpectralLinear optimisation requires an initial "
1398
+ "fermi_wavevector to be provided."
1399
+ )
1400
+
1401
+ return float(fermi_velocity), float(fermi_wavevector), None
1402
+
1403
+ elif self._class == "SpectralQuadratic":
1404
+ if fermi_velocity is not None:
1405
+ raise ValueError(
1406
+ "SpectralQuadratic bayesian_loop does not accept "
1407
+ "`fermi_velocity`. Provide `bare_mass` instead."
1408
+ )
1409
+
1410
+ if bare_mass is None:
1411
+ bare_mass = getattr(self, "_bare_mass", None)
1412
+ if bare_mass is None:
1413
+ raise ValueError(
1414
+ "SpectralQuadratic optimisation requires an initial "
1415
+ "bare_mass to be provided."
1416
+ )
1417
+
1418
+ if fermi_wavevector is None:
1419
+ fermi_wavevector = getattr(self, "fermi_wavevector", None)
1420
+ if fermi_wavevector is None:
1421
+ raise ValueError(
1422
+ "SpectralQuadratic optimisation requires an initial "
1423
+ "fermi_wavevector to be provided."
1424
+ )
1425
+
1426
+ return None, float(fermi_wavevector), float(bare_mass)
1427
+
1428
+ else:
1429
+ raise NotImplementedError(
1430
+ f"_prepare_bare is not implemented for spectral class "
1431
+ "'{self._class}'.")
1432
+
1433
+ def _cost_function(self, *, optimisation_parameters, omega_min, omega_max,
1434
+ omega_num, omega_I, omega_M, mem_cfg, _precomp):
1435
+ r"""TBD
1436
+
1437
+ Negative log-posterior cost function for Bayesian optimisation.
1438
+
1439
+ This mirrors `extract_a2f()` but recomputes the self-energy arrays for the
1440
+ candidate optimisation parameters instead of using cached `self.real/imag`.
1441
+
1442
+ Parameters
1443
+ ----------
1444
+ optimisation_parameters : dict
1445
+ Must include at least keys: "h_n", "impurity_magnitude", "lambda_el".
1446
+ For SpectralLinear, must also include "fermi_velocity" and
1447
+ "fermi_wavevector". For SpectralQuadratic, "bare_mass" is optional
1448
+ (falls back to `self._bare_mass` if present).
1449
+
1450
+ Returns
1451
+ -------
1452
+ cost : float
1453
+ Negative log-posterior evaluated at the selected alpha.
1454
+ spectrum : ndarray
1455
+ Rescaled α²F(ω) spectrum (same scaling convention as `extract_a2f()`).
1456
+ model : ndarray
1457
+ The model spectrum used by MEM (same as `extract_a2f()`).
1458
+ alpha_select : float
1459
+ The selected alpha returned by `_chi2kink_a2f`.
1460
+ """
1461
+
1462
+ required = {"h_n", "impurity_magnitude", "lambda_el"}
1463
+ missing = required.difference(optimisation_parameters)
1464
+ if missing:
1465
+ raise ValueError(
1466
+ f"Missing optimisation parameters: {sorted(missing)}"
1467
+ )
1468
+
1469
+ parts = mem_cfg["parts"]
1470
+ method = mem_cfg["method"]
1471
+ alpha_min = float(mem_cfg["alpha_min"])
1472
+ alpha_max = float(mem_cfg["alpha_max"])
1473
+ alpha_num = int(mem_cfg["alpha_num"])
1474
+ omega_S = float(mem_cfg["omega_S"])
1475
+
1476
+ mu = float(mem_cfg["mu"])
1477
+ a_guess = float(mem_cfg["a_guess"])
1478
+ b_guess = float(mem_cfg["b_guess"])
1479
+ c_guess = float(mem_cfg["c_guess"])
1480
+ d_guess = float(mem_cfg["d_guess"])
1481
+
1482
+ f_chi_squared = mem_cfg["f_chi_squared"]
1483
+ power = int(mem_cfg["power"])
1484
+ W = mem_cfg.get("W", None)
1485
+ t_criterion = float(mem_cfg["t_criterion"])
1486
+ iter_max = int(mem_cfg["iter_max"])
1487
+
1488
+ if f_chi_squared is None:
1489
+ f_chi_squared = 2.5 if parts == "both" else 2.0
1490
+ else:
1491
+ f_chi_squared = float(f_chi_squared)
1492
+
1493
+ h_n = mem_cfg.get("h_n", None)
1494
+ if h_n is None:
1495
+ raise ValueError(
1496
+ "`h_n` must be provided explicitly (h_n=... or mem={'h_n': ...}). "
1497
+ "No default is assumed."
1498
+ )
1499
+
1500
+ if parts not in {"both", "real", "imag"}:
1501
+ raise ValueError("parts must be one of {'both', 'real', 'imag'}")
1502
+
1503
+ if method != "chi2kink":
1504
+ raise NotImplementedError(
1505
+ "Only method='chi2kink' is currently implemented."
1506
+ )
1507
+
1508
+ impurity_magnitude = float(optimisation_parameters["impurity_magnitude"])
1509
+ lambda_el = float(optimisation_parameters["lambda_el"])
1510
+ h_n = float(optimisation_parameters["h_n"])
1511
+
1512
+ fermi_velocity = None
1513
+ fermi_wavevector = None
1514
+ bare_mass = None
1515
+
1516
+ if self._class == "SpectralLinear":
1517
+ required_lin = {"fermi_velocity", "fermi_wavevector"}
1518
+ missing_lin = required_lin.difference(optimisation_parameters)
1519
+ if missing_lin:
1520
+ raise ValueError(
1521
+ "SpectralLinear requires optimisation_parameters to include "
1522
+ f"{sorted(missing_lin)}."
1523
+ )
1524
+ fermi_velocity = optimisation_parameters["fermi_velocity"]
1525
+ fermi_wavevector = optimisation_parameters["fermi_wavevector"]
1526
+
1527
+ elif self._class == "SpectralQuadratic":
1528
+ if "fermi_wavevector" not in optimisation_parameters:
1529
+ raise ValueError(
1530
+ "SpectralQuadratic requires optimisation_parameters to include "
1531
+ "'fermi_wavevector'."
1532
+ )
1533
+ fermi_wavevector = optimisation_parameters["fermi_wavevector"]
1534
+
1535
+ bare_mass = optimisation_parameters.get("bare_mass", None)
1536
+ if bare_mass is None:
1537
+ bare_mass = getattr(self, "_bare_mass", None)
1538
+
1539
+ else:
1540
+ raise NotImplementedError(
1541
+ f"_cost_function does not support class '{self._class}'."
1542
+ )
1543
+
1544
+ from . import create_model_function, MEM_core
1545
+
1546
+ if f_chi_squared is None:
1547
+ f_chi_squared = 2.5 if parts == "both" else 2.0
1548
+
1549
+ if _precomp is None:
1550
+ raise ValueError(
1551
+ "_precomp is None in _cost_function. Pass the precomputed"
1552
+ " kernel/SVD bundle from bayesian_loop."
1553
+ )
1554
+
1555
+ omega_range = _precomp["omega_range"]
1556
+ mE = _precomp["mE"]
1557
+ energies_eV_masked = _precomp["energies_eV_masked"]
1558
+
1559
+ V_Sigma = _precomp["V_Sigma"]
1560
+ U = _precomp["U"]
1561
+ uvec = np.array(_precomp["uvec0"], copy=True)
1562
+
1563
+ if f_chi_squared is None:
1564
+ f_chi_squared = 2.5 if parts == "both" else 2.0
1565
+
1566
+ model = create_model_function(omega_range, omega_I, omega_M, omega_S, h_n)
1567
+
1568
+ delta_omega = (omega_max - omega_min) / (omega_num - 1)
1569
+ model_in = model * delta_omega
1570
+
1571
+ k_BT = K_B * self.temperature * KILO
1572
+
1573
+ if lambda_el:
1574
+ if W is None:
1575
+ if self._class == "SpectralQuadratic":
1576
+ if fermi_wavevector is None or bare_mass is None:
1577
+ raise ValueError(
1578
+ "lambda_el is nonzero, but W is None and cannot be "
1579
+ "inferred. Provide W (meV), or pass both "
1580
+ "`fermi_wavevector` and `bare_mass`."
1581
+ )
1582
+ W = (PREF * fermi_wavevector**2 / bare_mass) * KILO
1583
+ else:
1584
+ raise ValueError(
1585
+ "lambda_el was provided, but W is None. For "
1586
+ "SpectralLinear you must provide W in meV."
1587
+ )
1588
+
1589
+ energies_el = energies_eV_masked * KILO
1590
+ real_el, imag_el = self._el_el_self_energy(
1591
+ energies_el, k_BT, lambda_el, W, power
1592
+ )
1593
+ else:
1594
+ real_el = 0.0
1595
+ imag_el = 0.0
1596
+
1597
+ real, real_sigma, imag, imag_sigma = self._evaluate_self_energy_arrays(
1598
+ fermi_velocity=fermi_velocity,
1599
+ fermi_wavevector=fermi_wavevector,
1600
+ bare_mass=bare_mass,
1601
+ )
1602
+ if real is None or imag is None:
1603
+ raise ValueError(
1604
+ "Cannot compute self-energy arrays for cost evaluation. "
1605
+ "Ensure the required band parameters and peak/broadening " \
1606
+ "inputs are set.")
1607
+
1608
+ real_m = real[mE] * KILO - real_el
1609
+ imag_m = imag[mE] * KILO - impurity_magnitude - imag_el
1610
+
1611
+ if parts == "both":
1612
+ real_sig_m = real_sigma[mE] * KILO
1613
+ imag_sig_m = imag_sigma[mE] * KILO
1614
+ dvec = np.concatenate((real_m, imag_m))
1615
+ wvec = np.concatenate((real_sig_m**(-2), imag_sig_m**(-2)))
1616
+ elif parts == "real":
1617
+ real_sig_m = real_sigma[mE] * KILO
1618
+ dvec = real_m
1619
+ wvec = real_sig_m**(-2)
1620
+ else:
1621
+ imag_sig_m = imag_sigma[mE] * KILO
1622
+ dvec = imag_m
1623
+ wvec = imag_sig_m**(-2)
1624
+
1625
+ spectrum_in, alpha_select = self._chi2kink_a2f(
1626
+ dvec, model_in, uvec, mu, wvec, V_Sigma, U, alpha_min, alpha_max,
1627
+ alpha_num, a_guess, b_guess, c_guess, d_guess, f_chi_squared,
1628
+ t_criterion, iter_max, MEM_core,
1629
+ )
1630
+
1631
+ T = V_Sigma @ (U.T @ spectrum_in)
1632
+ chi_squared = wvec @ ((T - dvec) ** 2)
1633
+
1634
+ mask = (spectrum_in > 0.0) & (model_in > 0.0)
1635
+ if not np.any(mask):
1636
+ raise ValueError(
1637
+ "Invalid spectrum/model for entropy: no positive entries "
1638
+ "after MEM."
1639
+ )
1640
+
1641
+ information_entropy = (
1642
+ np.sum(spectrum_in[mask] - model_in[mask])
1643
+ - np.sum(
1644
+ spectrum_in[mask]
1645
+ * np.log(spectrum_in[mask] / model_in[mask])
1646
+ )
1647
+ )
1648
+
1649
+ cost = (0.5 * chi_squared
1650
+ - alpha_select * information_entropy
1651
+ + 0.5 * np.sum(np.log(2.0 * np.pi / wvec))
1652
+ - 0.5 * spectrum_in.size * np.log(alpha_select))
1653
+
1654
+ spectrum = spectrum_in * omega_num / omega_max
1655
+
1656
+ return (cost, spectrum, model, alpha_select)
1657
+
1658
+
1659
+ @staticmethod
1660
+ def _chi2kink_a2f(dvec, model_in, uvec, mu, wvec, V_Sigma, U,
1661
+ alpha_min, alpha_max, alpha_num, a_guess, b_guess,
1662
+ c_guess, d_guess, f_chi_squared, t_criterion,
1663
+ iter_max, MEM_core):
1664
+ r"""Compute MEM spectrum using the chi2kink alpha-selection procedure.
1665
+
1666
+ Returns
1667
+ -------
1668
+ spectrum_in : ndarray
1669
+ Selected spectrum from MEM_core evaluated at the chi2kink alpha.
1670
+ """
1671
+ from . import (fit_leastsq, chi2kink_logistic)
1672
+
1673
+ alpha_range = np.logspace(alpha_min, alpha_max, alpha_num)
1674
+ chi_squared = np.empty_like(alpha_range, dtype=float)
1675
+
1676
+ for i, alpha in enumerate(alpha_range):
1677
+ spectrum_in, uvec = MEM_core(dvec, model_in, uvec, mu, alpha,
1678
+ wvec, V_Sigma, U, t_criterion, iter_max)
1679
+
1680
+ T = V_Sigma @ (U.T @ spectrum_in)
1681
+ chi_squared[i] = wvec @ ((T - dvec) ** 2)
1682
+
1683
+ if (not np.all(np.isfinite(chi_squared))) or np.any(chi_squared <= 0.0):
1684
+ raise ValueError(
1685
+ "chi_squared contains non-finite or non-positive values."
1686
+ )
1687
+
1688
+ log_alpha = np.log10(alpha_range)
1689
+ log_chi_squared = np.log10(chi_squared)
1690
+
1691
+ p0 = np.array([a_guess, b_guess, c_guess, d_guess], dtype=float)
1692
+ pfit, pcov = fit_leastsq(
1693
+ p0, log_alpha, log_chi_squared, chi2kink_logistic
1694
+ )
1695
+
1696
+ cout = pfit[2]
1697
+ dout = pfit[3]
1698
+ alpha_select = 10 ** (cout - f_chi_squared / dout)
1699
+
1700
+ spectrum_in, uvec = MEM_core(dvec, model_in, uvec, mu, alpha_select,
1701
+ wvec, V_Sigma, U, t_criterion, iter_max)
1702
+
1703
+ return spectrum_in, alpha_select
1704
+
1705
+
1706
+ @staticmethod
1707
+ def _el_el_self_energy(enel_range, k_BT, lambda_el, W, power):
1708
+ """Electron–electron contribution to the self-energy."""
1709
+ x = enel_range / W
1710
+ denom = 1.0 - (np.pi * k_BT / W) ** 2
1711
+
1712
+ if denom == 0.0:
1713
+ raise ZeroDivisionError(
1714
+ "Invalid parameters: 1 - (π k_BT / W)^2 = 0."
1715
+ )
1716
+
1717
+ pref = lambda_el / (W * denom)
1718
+
1719
+ if power == 2:
1720
+ real_el = pref * x * ((np.pi * k_BT) ** 2 - W ** 2) / (1.0 + x ** 2)
1721
+ imag_el = (pref * (enel_range ** 2 + (np.pi * k_BT) ** 2)
1722
+ / (1.0 + x ** 2))
1723
+
1724
+ elif power == 4:
1725
+ num = (
1726
+ (np.pi * k_BT) ** 2 * (1.0 + x ** 2)
1727
+ - W ** 2 * (1.0 - x ** 2)
1728
+ )
1729
+ real_el = pref * x * num / (1.0 + x ** 4)
1730
+ imag_el = (pref * np.sqrt(2.0) * (enel_range ** 2 + (np.pi * k_BT)
1731
+ ** 2) / ( 1.0 + x ** 4))
1732
+ else:
1733
+ raise ValueError(
1734
+ "El-el coupling has not yet been implemented for the given " \
1735
+ "power."
1736
+ )
1737
+
1738
+ return real_el, imag_el
1739
+
1740
+
1741
+ class CreateSelfEnergies:
1742
+ r"""
1743
+ Thin container for self-energies with leaf-aware utilities.
1744
+ All items are assumed to be leaf self-energy objects with
1745
+ a `.label` attribute for identification.
1746
+ """
1747
+
1748
+ def __init__(self, self_energies):
1749
+ self.self_energies = self_energies
1750
+
1751
+ # ------ Basic container protocol ------
1752
+ def __call__(self):
1753
+ return self.self_energies
1754
+
1755
+ @property
1756
+ def self_energies(self):
1757
+ return self._self_energies
1758
+
1759
+ @self_energies.setter
1760
+ def self_energies(self, x):
1761
+ self._self_energies = x
1762
+
1763
+ def __iter__(self):
1764
+ return iter(self.self_energies)
1765
+
1766
+ def __getitem__(self, index):
1767
+ return self.self_energies[index]
1768
+
1769
+ def __setitem__(self, index, value):
1770
+ self.self_energies[index] = value
1771
+
1772
+ def __len__(self):
1773
+ return len(self.self_energies)
1774
+
1775
+ def __deepcopy__(self, memo):
1776
+ import copy
1777
+ return type(self)(copy.deepcopy(self.self_energies, memo))
1778
+
1779
+ # ------ Label-based utilities ------
1780
+ def get_by_label(self, label):
1781
+ r"""
1782
+ Return the self-energy object with the given label.
1783
+
1784
+ Parameters
1785
+ ----------
1786
+ label : str
1787
+ Label of the self-energy to retrieve.
1788
+
1789
+ Returns
1790
+ -------
1791
+ obj : SelfEnergy
1792
+ The corresponding self-energy instance.
1793
+
1794
+ Raises
1795
+ ------
1796
+ KeyError
1797
+ If no self-energy with the given label exists.
1798
+ """
1799
+ for se in self.self_energies:
1800
+ if getattr(se, "label", None) == label:
1801
+ return se
1802
+ raise KeyError(
1803
+ f"No self-energy with label {label!r} found in container."
1804
+ )
1805
+
1806
+ def labels(self):
1807
+ r"""
1808
+ Return a list of all labels.
1809
+ """
1810
+ return [getattr(se, "label", None) for se in self.self_energies]
1811
+
1812
+ def as_dict(self):
1813
+ r"""
1814
+ Return a {label: self_energy} dictionary for convenient access.
1815
+ """
1816
+ return {se.label: se for se in self.self_energies}