xarpes 0.5.0__py3-none-any.whl → 0.6.1__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 CHANGED
@@ -13,15 +13,18 @@
13
13
 
14
14
  import numpy as np
15
15
  from .plotting import get_ax_fig_plt, add_fig_kwargs
16
- from .constants import PREF
16
+ from .constants import PREF, KILO, K_B
17
17
 
18
18
  class SelfEnergy:
19
- r"""Self-energy (ekin-leading; hnuminPhi/ekin are read-only)."""
19
+ r"""Self-energy"""
20
20
 
21
- def __init__(self, ekin_range, hnuminPhi, label, properties, parameters):
21
+ def __init__(self, ekin_range, hnuminPhi, energy_resolution,
22
+ temperature, label, properties, parameters):
22
23
  # core read-only state
23
24
  self._ekin_range = ekin_range
24
25
  self._hnuminPhi = hnuminPhi
26
+ self._energy_resolution = energy_resolution
27
+ self._temperature = temperature
25
28
  self._label = label
26
29
 
27
30
  # accept either a dict or a single-element list of dicts
@@ -29,8 +32,9 @@ class SelfEnergy:
29
32
  if len(properties) == 1:
30
33
  properties = properties[0]
31
34
  else:
32
- raise ValueError("`properties` must be a dict or a single " \
33
- "dict in a list.")
35
+ raise ValueError(
36
+ "`properties` must be a dict or a single dict in a list."
37
+ )
34
38
 
35
39
  # single source of truth for all params (+ their *_sigma)
36
40
  self._properties = dict(properties or {})
@@ -54,7 +58,9 @@ class SelfEnergy:
54
58
  if self._class == "SpectralLinear" and (self._bare_mass is not None):
55
59
  raise ValueError("`bare_mass` cannot be set for SpectralLinear.")
56
60
  if self._class == "SpectralQuadratic" and (self._fermi_velocity is not None):
57
- raise ValueError("`fermi_velocity` cannot be set for SpectralQuadratic.")
61
+ raise ValueError(
62
+ "`fermi_velocity` cannot be set for SpectralQuadratic."
63
+ )
58
64
 
59
65
  if self._side is not None and self._side not in ("left", "right"):
60
66
  raise ValueError("`side` must be 'left' or 'right' if provided.")
@@ -78,12 +84,19 @@ class SelfEnergy:
78
84
  self._imag = None
79
85
  self._imag_sigma = None
80
86
 
87
+ # lazy caches for α²F(ω) extraction results
88
+ self._a2f_spectrum = None
89
+ self._a2f_model = None
90
+ self._a2f_omega_range = None
91
+ self._a2f_alpha_select = None
92
+ self._a2f_cost = None
93
+
81
94
  def _check_mass_velocity_exclusivity(self):
82
95
  """Ensure that fermi_velocity and bare_mass are not both set."""
83
96
  if (self._fermi_velocity is not None) and (self._bare_mass is not None):
84
97
  raise ValueError(
85
- "Cannot set both `fermi_velocity` and `bare_mass`: "
86
- "choose one physical parametrization (SpectralLinear or SpectralQuadratic)."
98
+ "Cannot set both `fermi_velocity` and `bare_mass`: choose one "
99
+ "physical parametrization (SpectralLinear or SpectralQuadratic)."
87
100
  )
88
101
 
89
102
  # ---------------- core read-only axes ----------------
@@ -101,6 +114,16 @@ class SelfEnergy:
101
114
  @property
102
115
  def hnuminPhi(self):
103
116
  return self._hnuminPhi
117
+
118
+ @property
119
+ def energy_resolution(self):
120
+ """Energy resolution associated with the self-energy."""
121
+ return self._energy_resolution
122
+
123
+ @property
124
+ def temperature(self):
125
+ """Temperature associated with the self-energy [K]."""
126
+ return self._temperature
104
127
 
105
128
  # ---------------- identifiers ----------------
106
129
  @property
@@ -158,8 +181,9 @@ class SelfEnergy:
158
181
  @fermi_velocity.setter
159
182
  def fermi_velocity(self, x):
160
183
  if self._class == "SpectralQuadratic":
161
- raise ValueError("`fermi_velocity` cannot be set for" \
162
- " SpectralQuadratic.")
184
+ raise ValueError(
185
+ "`fermi_velocity` cannot be set for SpectralQuadratic."
186
+ )
163
187
  self._fermi_velocity = x
164
188
  self._parameters["fermi_velocity"] = x
165
189
  # invalidate dependents
@@ -174,7 +198,9 @@ class SelfEnergy:
174
198
  @bare_mass.setter
175
199
  def bare_mass(self, x):
176
200
  if self._class == "SpectralLinear":
177
- raise ValueError("`bare_mass` cannot be set for SpectralLinear.")
201
+ raise ValueError(
202
+ "`bare_mass` cannot be set for SpectralLinear."
203
+ )
178
204
  self._bare_mass = x
179
205
  self._parameters["bare_mass"] = x
180
206
  # invalidate dependents
@@ -252,7 +278,7 @@ class SelfEnergy:
252
278
  # ---------------- derived outputs ----------------
253
279
  @property
254
280
  def peak_positions(self):
255
- r"""k_parallel = peak * dtor * sqrt(ekin_range / pref) (lazy)."""
281
+ r"""k_parallel = peak * dtor * sqrt(ekin_range / PREF) (lazy)."""
256
282
  if self._peak_positions is None:
257
283
  if self._peak is None or self._ekin_range is None:
258
284
  return None
@@ -263,13 +289,17 @@ class SelfEnergy:
263
289
  "before accessing peak_positions and quantities that "
264
290
  "depend on the latter."
265
291
  )
266
- kpar_mag = np.sqrt(self._ekin_range / PREF) * \
267
- np.sin(np.deg2rad(np.abs(self._peak)))
268
- self._peak_positions = (-1.0 if self._side == "left" \
269
- else 1.0) * kpar_mag
292
+ kpar_mag = (
293
+ np.sqrt(self._ekin_range / PREF)
294
+ * np.sin(np.deg2rad(np.abs(self._peak)))
295
+ )
296
+ self._peak_positions = ((-1.0 if self._side == "left"
297
+ else 1.0) * kpar_mag)
270
298
  else:
271
- self._peak_positions = np.sqrt(self._ekin_range / PREF) \
272
- * np.sin(np.deg2rad(self._peak))
299
+ self._peak_positions = (
300
+ np.sqrt(self._ekin_range / PREF)
301
+ * np.sin(np.deg2rad(self._peak))
302
+ )
273
303
  return self._peak_positions
274
304
 
275
305
 
@@ -279,10 +309,13 @@ class SelfEnergy:
279
309
  if self._peak_positions_sigma is None:
280
310
  if self._peak_sigma is None or self._ekin_range is None:
281
311
  return None
282
- self._peak_positions_sigma = (np.sqrt(self._ekin_range / PREF) \
283
- * np.abs(np.cos(np.deg2rad(self._peak))) \
284
- * np.deg2rad(self._peak_sigma))
312
+ self._peak_positions_sigma = (
313
+ np.sqrt(self._ekin_range / PREF)
314
+ * np.abs(np.cos(np.deg2rad(self._peak)))
315
+ * np.deg2rad(self._peak_sigma)
316
+ )
285
317
  return self._peak_positions_sigma
318
+
286
319
 
287
320
  @property
288
321
  def imag(self):
@@ -290,82 +323,224 @@ class SelfEnergy:
290
323
  if self._imag is None:
291
324
  if self._broadening is None or self._ekin_range is None:
292
325
  return None
293
- if self._class == "SpectralLinear":
294
- if self._fermi_velocity is None:
295
- raise AttributeError("Cannot compute `imag` "
296
- "(SpectralLinear): set `fermi_velocity` first.")
297
- self._imag = np.abs(self._fermi_velocity) * np.sqrt(self._ekin_range \
298
- / PREF) * self._broadening
299
- else:
300
- if self._bare_mass is None:
301
- raise AttributeError("Cannot compute `imag` "
302
- "(SpectralQuadratic): set `bare_mass` first.")
303
- self._imag = (self._ekin_range * self._broadening) \
304
- / np.abs(self._bare_mass)
326
+ self._imag = self._compute_imag()
305
327
  return self._imag
306
328
 
329
+
307
330
  @property
308
331
  def imag_sigma(self):
309
332
  r"""Std. dev. of -Σ'' (lazy)."""
310
333
  if self._imag_sigma is None:
311
334
  if self._broadening_sigma is None or self._ekin_range is None:
312
335
  return None
313
- if self._class == "SpectralLinear":
314
- if self._fermi_velocity is None:
315
- raise AttributeError("Cannot compute `imag_sigma` "
316
- "(SpectralLinear): set `fermi_velocity` first.")
317
- self._imag_sigma = np.abs(self._fermi_velocity) * \
318
- np.sqrt(self._ekin_range / PREF) * self._broadening_sigma
319
- else:
320
- if self._bare_mass is None:
321
- raise AttributeError("Cannot compute `imag_sigma` "
322
- "(SpectralQuadratic): set `bare_mass` first.")
323
- self._imag_sigma = (self._ekin_range * \
324
- self._broadening_sigma) / np.abs(self._bare_mass)
336
+ self._imag_sigma = self._compute_imag_sigma()
325
337
  return self._imag_sigma
326
338
 
339
+
327
340
  @property
328
341
  def real(self):
329
342
  r"""Σ' (lazy)."""
330
343
  if self._real is None:
331
344
  if self._peak is None or self._ekin_range is None:
332
345
  return None
333
- if self._class == "SpectralLinear":
334
- if self._fermi_velocity is None or self._fermi_wavevector is None:
335
- raise AttributeError("Cannot compute `real` "
336
- "(SpectralLinear): set `fermi_velocity` and " \
337
- "`fermi_wavevector` first.")
338
- self._real = self.enel_range - self._fermi_velocity * \
339
- (self.peak_positions - self._fermi_wavevector)
340
- else:
341
- if self._bare_mass is None or self._fermi_wavevector is None:
342
- raise AttributeError("Cannot compute `real` "
343
- "(SpectralQuadratic): set `bare_mass` and " \
344
- "`fermi_wavevector` first.")
345
- self._real = self.enel_range - (PREF / \
346
- self._bare_mass) * (self.peak_positions**2 \
347
- - self._fermi_wavevector**2)
346
+ self._real = self._compute_real()
348
347
  return self._real
349
348
 
349
+
350
350
  @property
351
351
  def real_sigma(self):
352
352
  r"""Std. dev. of Σ' (lazy)."""
353
353
  if self._real_sigma is None:
354
354
  if self._peak_sigma is None or self._ekin_range is None:
355
355
  return None
356
- if self._class == "SpectralLinear":
357
- if self._fermi_velocity is None:
358
- raise AttributeError("Cannot compute `real_sigma` "
359
- "(SpectralLinear): set `fermi_velocity` first.")
360
- self._real_sigma = np.abs(self._fermi_velocity) * self.peak_positions_sigma
361
- else:
362
- if self._bare_mass is None or self._fermi_wavevector is None:
363
- raise AttributeError("Cannot compute `real_sigma` "
364
- "(SpectralQuadratic): set `bare_mass` and " \
365
- "`fermi_wavevector` first.")
366
- self._real_sigma = 2 * PREF * self.peak_positions_sigma \
367
- * np.abs(self.peak_positions / self._bare_mass)
356
+ self._real_sigma = self._compute_real_sigma()
368
357
  return self._real_sigma
358
+
359
+ @property
360
+ def a2f_spectrum(self):
361
+ """Cached α²F(ω) spectrum from last extraction (or None)."""
362
+ return self._a2f_spectrum
363
+
364
+ @property
365
+ def a2f_model(self):
366
+ """Cached MEM model spectrum from last extraction (or None)."""
367
+ return self._a2f_model
368
+
369
+ @property
370
+ def a2f_omega_range(self):
371
+ """Cached ω grid for the last extraction (or None)."""
372
+ return self._a2f_omega_range
373
+
374
+ @property
375
+ def a2f_alpha_select(self):
376
+ """Cached selected alpha from last extraction (or None)."""
377
+ return self._a2f_alpha_select
378
+
379
+ @property
380
+ def a2f_cost(self):
381
+ """Cached cost from last bayesian_loop (or None)."""
382
+ return self._a2f_cost
383
+
384
+
385
+ def _compute_imag(self, fermi_velocity=None, bare_mass=None):
386
+ r"""Compute -Σ'' without touching caches."""
387
+ if self._broadening is None or self._ekin_range is None:
388
+ return None
389
+
390
+ ekin = np.asarray(self._ekin_range)
391
+ broad = self._broadening
392
+
393
+ if self._class == "SpectralLinear":
394
+ vF = self._fermi_velocity if fermi_velocity is None else fermi_velocity
395
+ if vF is None:
396
+ raise AttributeError(
397
+ "Cannot compute `imag` (SpectralLinear): set `fermi_velocity` "
398
+ "first."
399
+ )
400
+ return np.abs(vF) * np.sqrt(ekin / PREF) * broad
401
+
402
+ if self._class == "SpectralQuadratic":
403
+ mb = self._bare_mass if bare_mass is None else bare_mass
404
+ if mb is None:
405
+ raise AttributeError(
406
+ "Cannot compute `imag` (SpectralQuadratic): set `bare_mass` "
407
+ "first."
408
+ )
409
+ return (ekin * broad) / np.abs(mb)
410
+
411
+ raise NotImplementedError(
412
+ f"_compute_imag is not implemented for spectral class '{self._class}'."
413
+ )
414
+
415
+
416
+ def _compute_imag_sigma(self, fermi_velocity=None, bare_mass=None):
417
+ r"""Compute std. dev. of -Σ'' without touching caches."""
418
+ if self._broadening_sigma is None or self._ekin_range is None:
419
+ return None
420
+
421
+ ekin = np.asarray(self._ekin_range)
422
+ broad_sigma = self._broadening_sigma
423
+
424
+ if self._class == "SpectralLinear":
425
+ vF = self._fermi_velocity if fermi_velocity is None else fermi_velocity
426
+ if vF is None:
427
+ raise AttributeError(
428
+ "Cannot compute `imag_sigma` (SpectralLinear): set "
429
+ "`fermi_velocity` first."
430
+ )
431
+ return np.abs(vF) * np.sqrt(ekin / PREF) * broad_sigma
432
+
433
+ if self._class == "SpectralQuadratic":
434
+ mb = self._bare_mass if bare_mass is None else bare_mass
435
+ if mb is None:
436
+ raise AttributeError(
437
+ "Cannot compute `imag_sigma` (SpectralQuadratic): set "
438
+ "`bare_mass` first."
439
+ )
440
+ return (ekin * broad_sigma) / np.abs(mb)
441
+
442
+ raise NotImplementedError(
443
+ f"_compute_imag_sigma is not implemented for spectral class "
444
+ f"'{self._class}'."
445
+ )
446
+
447
+
448
+ def _compute_real(self, fermi_velocity=None, fermi_wavevector=None,
449
+ bare_mass=None):
450
+ r"""Compute Σ' without touching caches."""
451
+ if self._peak is None or self._ekin_range is None:
452
+ return None
453
+
454
+ enel = self.enel_range
455
+ kpar = self.peak_positions
456
+ if kpar is None:
457
+ return None
458
+
459
+ if self._class == "SpectralLinear":
460
+ vF = self._fermi_velocity if fermi_velocity is None else fermi_velocity
461
+ kF = (self._fermi_wavevector if fermi_wavevector is None
462
+ else fermi_wavevector)
463
+ if vF is None or kF is None:
464
+ raise AttributeError(
465
+ "Cannot compute `real` (SpectralLinear): set `fermi_velocity` "
466
+ "and `fermi_wavevector` first."
467
+ )
468
+ return enel - vF * (kpar - kF)
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` (SpectralQuadratic): set `bare_mass` "
477
+ "and `fermi_wavevector` first."
478
+ )
479
+ return enel - (PREF / mb) * (kpar**2 - kF**2)
480
+
481
+ raise NotImplementedError(
482
+ f"_compute_real is not implemented for spectral class '{self._class}'."
483
+ )
484
+
485
+ def _compute_real_sigma(self, fermi_velocity=None, fermi_wavevector=None,
486
+ bare_mass=None):
487
+ r"""Compute std. dev. of Σ' without touching caches."""
488
+ if self._peak_sigma is None or self._ekin_range is None:
489
+ return None
490
+
491
+ kpar_sigma = self.peak_positions_sigma
492
+ if kpar_sigma is None:
493
+ return None
494
+
495
+ if self._class == "SpectralLinear":
496
+ vF = self._fermi_velocity if fermi_velocity is None else fermi_velocity
497
+ if vF is None:
498
+ raise AttributeError(
499
+ "Cannot compute `real_sigma` (SpectralLinear): set "
500
+ "`fermi_velocity` first."
501
+ )
502
+ return np.abs(vF) * kpar_sigma
503
+
504
+ if self._class == "SpectralQuadratic":
505
+ mb = self._bare_mass if bare_mass is None else bare_mass
506
+ kF = (self._fermi_wavevector if fermi_wavevector is None
507
+ else fermi_wavevector)
508
+ if mb is None or kF is None:
509
+ raise AttributeError(
510
+ "Cannot compute `real_sigma` (SpectralQuadratic): set "
511
+ "`bare_mass` and `fermi_wavevector` first."
512
+ )
513
+
514
+ kpar = self.peak_positions
515
+ if kpar is None:
516
+ return None
517
+ return 2.0 * PREF * kpar_sigma * np.abs(kpar / mb)
518
+
519
+ raise ValueError(
520
+ f"Unsupported spectral class '{self._class}' in "
521
+ "_compute_real_sigma."
522
+ )
523
+
524
+
525
+ def _evaluate_self_energy_arrays(self, fermi_velocity=None, fermi_wavevector=None,
526
+ bare_mass=None):
527
+ r"""Evaluate Σ' / -Σ'' and 1σ uncertainties without mutating caches."""
528
+ real = self._compute_real(
529
+ fermi_velocity=fermi_velocity,
530
+ fermi_wavevector=fermi_wavevector,
531
+ bare_mass=bare_mass,
532
+ )
533
+ real_sigma = self._compute_real_sigma(
534
+ fermi_velocity=fermi_velocity,
535
+ fermi_wavevector=fermi_wavevector,
536
+ bare_mass=bare_mass,
537
+ )
538
+ imag = self._compute_imag(fermi_velocity=fermi_velocity, bare_mass=bare_mass)
539
+ imag_sigma = self._compute_imag_sigma(
540
+ fermi_velocity=fermi_velocity, bare_mass=bare_mass
541
+ )
542
+ return real, real_sigma, imag, imag_sigma
543
+
369
544
 
370
545
  @property
371
546
  def mdc_maxima(self):
@@ -389,7 +564,7 @@ class SelfEnergy:
389
564
  self.peak_positions + self._center_wavevector
390
565
  )
391
566
 
392
- return self._mdc_maxima
567
+ return self._mdc_maxima
393
568
 
394
569
  def _se_legend_labels(self):
395
570
  """Return (real_label, imag_label) for legend with safe subscripts."""
@@ -412,15 +587,37 @@ class SelfEnergy:
412
587
  imag_label = rf"$-\Sigma_{{\mathrm{{{safe_label}}}}}''(E)$"
413
588
 
414
589
  return real_label, imag_label
590
+
591
+ def _a2f_legend_labels(self):
592
+ """Return (a2f_label, model_label) for legend with safe subscripts."""
593
+ se_label = getattr(self, "label", None)
594
+
595
+ if se_label is None:
596
+ return r"$\alpha^2F(\omega)$", r"$m(\omega)$"
597
+
598
+ safe_label = str(se_label).replace("_", r"\_")
599
+ if safe_label == "":
600
+ return r"$\alpha^2F(\omega)$", r"$m(\omega)$"
601
+
602
+ a2f_label = rf"$\alpha^2F_{{\mathrm{{{safe_label}}}}}(\omega)$"
603
+ model_label = rf"$m_{{\mathrm{{{safe_label}}}}}(\omega)$"
604
+ return a2f_label, model_label
415
605
 
416
606
  @add_fig_kwargs
417
- def plot_real(self, ax=None, **kwargs):
607
+ def plot_real(self, ax=None, scale="eV", resolution_range="absent", **kwargs):
418
608
  r"""Plot the real part Σ' of the self-energy as a function of E-μ.
419
609
 
420
610
  Parameters
421
611
  ----------
422
612
  ax : Matplotlib-Axes or None
423
613
  Axis to plot on. Created if not provided by the user.
614
+ scale : {"eV", "meV"}
615
+ Units for both axes. If "meV", x and y (and yerr) are multiplied by
616
+ `KILO`.
617
+ resolution_range : {"absent", "applied"}
618
+ If "applied", removes points with |E-μ| <= energy_resolution (around
619
+ the chemical potential). The energy resolution is taken from
620
+ ``self.energy_resolution`` (in eV) and scaled consistently with `scale`.
424
621
  **kwargs :
425
622
  Additional keyword arguments passed to ``ax.errorbar``.
426
623
 
@@ -433,10 +630,31 @@ class SelfEnergy:
433
630
 
434
631
  ax, fig, plt = get_ax_fig_plt(ax=ax)
435
632
 
633
+ if scale not in ("eV", "meV"):
634
+ raise ValueError("scale must be either 'eV' or 'meV'.")
635
+ if resolution_range not in ("absent", "applied"):
636
+ raise ValueError("resolution_range must be 'absent' or 'applied'.")
637
+
638
+ factor = KILO if scale == "meV" else 1.0
639
+
436
640
  x = self.enel_range
437
641
  y = self.real
438
642
  y_sigma = self.real_sigma
439
643
 
644
+ if x is not None:
645
+ x = factor * np.asarray(x, dtype=float)
646
+ if y is not None:
647
+ y = factor * np.asarray(y, dtype=float)
648
+
649
+ if resolution_range == "applied" and x is not None and y is not None:
650
+ res = self.energy_resolution
651
+ if res is not None:
652
+ keep = np.abs(x) > (factor * float(res))
653
+ x = x[keep]
654
+ y = y[keep]
655
+ if y_sigma is not None:
656
+ y_sigma = np.asarray(y_sigma, dtype=float)[keep]
657
+
440
658
  real_label, _ = self._se_legend_labels()
441
659
  kwargs.setdefault("label", real_label)
442
660
 
@@ -446,23 +664,33 @@ class SelfEnergy:
446
664
  "Warning: some Σ'(E) uncertainty values are missing. "
447
665
  "Error bars omitted at those energies."
448
666
  )
449
- kwargs.setdefault("yerr", xprs.sigma_confidence * y_sigma)
667
+ kwargs.setdefault("yerr", xprs.sigma_confidence * factor * y_sigma)
450
668
 
451
669
  ax.errorbar(x, y, **kwargs)
452
- ax.set_xlabel(r"$E-\mu$ (eV)")
453
- ax.set_ylabel(r"$\Sigma'(E)$ (eV)")
670
+
671
+ x_unit = "meV" if scale == "meV" else "eV"
672
+ ax.set_xlabel(rf"$E-\mu$ ({x_unit})")
673
+ ax.set_ylabel(rf"$\Sigma'(E)$ ({x_unit})")
454
674
  ax.legend()
455
675
 
456
676
  return fig
457
677
 
678
+
458
679
  @add_fig_kwargs
459
- def plot_imag(self, ax=None, **kwargs):
680
+ def plot_imag(self, ax=None, scale="eV", resolution_range="absent", **kwargs):
460
681
  r"""Plot the imaginary part -Σ'' of the self-energy vs. E-μ.
461
682
 
462
683
  Parameters
463
684
  ----------
464
685
  ax : Matplotlib-Axes or None
465
686
  Axis to plot on. Created if not provided by the user.
687
+ scale : {"eV", "meV"}
688
+ Units for both axes. If "meV", x and y (and yerr) are multiplied by
689
+ `KILO`.
690
+ resolution_range : {"absent", "applied"}
691
+ If "applied", removes points with |E-μ| <= energy_resolution (around
692
+ the chemical potential). The energy resolution is taken from
693
+ ``self.energy_resolution`` (in eV) and scaled consistently with `scale`.
466
694
  **kwargs :
467
695
  Additional keyword arguments passed to ``ax.errorbar``.
468
696
 
@@ -475,10 +703,31 @@ class SelfEnergy:
475
703
 
476
704
  ax, fig, plt = get_ax_fig_plt(ax=ax)
477
705
 
706
+ if scale not in ("eV", "meV"):
707
+ raise ValueError("scale must be either 'eV' or 'meV'.")
708
+ if resolution_range not in ("absent", "applied"):
709
+ raise ValueError("resolution_range must be 'absent' or 'applied'.")
710
+
711
+ factor = KILO if scale == "meV" else 1.0
712
+
478
713
  x = self.enel_range
479
714
  y = self.imag
480
715
  y_sigma = self.imag_sigma
481
716
 
717
+ if x is not None:
718
+ x = factor * np.asarray(x, dtype=float)
719
+ if y is not None:
720
+ y = factor * np.asarray(y, dtype=float)
721
+
722
+ if resolution_range == "applied" and x is not None and y is not None:
723
+ res = self.energy_resolution
724
+ if res is not None:
725
+ keep = np.abs(x) > (factor * float(res))
726
+ x = x[keep]
727
+ y = y[keep]
728
+ if y_sigma is not None:
729
+ y_sigma = np.asarray(y_sigma, dtype=float)[keep]
730
+
482
731
  _, imag_label = self._se_legend_labels()
483
732
  kwargs.setdefault("label", imag_label)
484
733
 
@@ -488,31 +737,76 @@ class SelfEnergy:
488
737
  "Warning: some -Σ''(E) uncertainty values are missing. "
489
738
  "Error bars omitted at those energies."
490
739
  )
491
- kwargs.setdefault("yerr", xprs.sigma_confidence * y_sigma)
740
+ kwargs.setdefault("yerr", xprs.sigma_confidence * factor * y_sigma)
492
741
 
493
742
  ax.errorbar(x, y, **kwargs)
494
- ax.set_xlabel(r"$E-\mu$ (eV)")
495
- ax.set_ylabel(r"$-\Sigma''(E)$ (eV)")
743
+
744
+ x_unit = "meV" if scale == "meV" else "eV"
745
+ ax.set_xlabel(rf"$E-\mu$ ({x_unit})")
746
+ ax.set_ylabel(rf"$-\Sigma''(E)$ ({x_unit})")
496
747
  ax.legend()
497
748
 
498
749
  return fig
499
750
 
751
+
500
752
  @add_fig_kwargs
501
- def plot_both(self, ax=None, **kwargs):
502
- r"""Plot Σ'(E) and -Σ''(E) vs. E-μ on the same axis."""
753
+ def plot_both(self, ax=None, scale="eV", resolution_range="absent", **kwargs):
754
+ r"""Plot Σ'(E) and -Σ''(E) vs. E-μ on the same axis.
755
+
756
+ Parameters
757
+ ----------
758
+ ax : Matplotlib-Axes or None
759
+ Axis to plot on. Created if not provided by the user.
760
+ scale : {"eV", "meV"}
761
+ Units for both axes. If "meV", x, y, and yerr are multiplied by
762
+ `KILO`.
763
+ resolution_range : {"absent", "applied"}
764
+ If "applied", removes points with |E-μ| <= energy_resolution (around
765
+ the chemical potential). The energy resolution is taken from
766
+ ``self.energy_resolution`` (in eV) and scaled consistently with `scale`.
767
+ **kwargs :
768
+ Additional keyword arguments passed to ``ax.errorbar``.
769
+ """
503
770
  from . import settings_parameters as xprs
504
771
 
505
772
  ax, fig, plt = get_ax_fig_plt(ax=ax)
506
773
 
774
+ if scale not in ("eV", "meV"):
775
+ raise ValueError("scale must be either 'eV' or 'meV'.")
776
+ if resolution_range not in ("absent", "applied"):
777
+ raise ValueError("resolution_range must be 'absent' or 'applied'.")
778
+
779
+ factor = KILO if scale == "meV" else 1.0
780
+
507
781
  x = self.enel_range
508
782
  real = self.real
509
783
  imag = self.imag
510
784
  real_sigma = self.real_sigma
511
785
  imag_sigma = self.imag_sigma
512
786
 
787
+ if x is not None:
788
+ x = factor * np.asarray(x, dtype=float)
789
+ if real is not None:
790
+ real = factor * np.asarray(real, dtype=float)
791
+ if imag is not None:
792
+ imag = factor * np.asarray(imag, dtype=float)
793
+
794
+ if resolution_range == "applied" and x is not None:
795
+ res = self.energy_resolution
796
+ if res is not None:
797
+ keep = np.abs(x) > (factor * float(res))
798
+ x = x[keep]
799
+ if real is not None:
800
+ real = real[keep]
801
+ if imag is not None:
802
+ imag = imag[keep]
803
+ if real_sigma is not None:
804
+ real_sigma = np.asarray(real_sigma, dtype=float)[keep]
805
+ if imag_sigma is not None:
806
+ imag_sigma = np.asarray(imag_sigma, dtype=float)[keep]
807
+
513
808
  real_label, imag_label = self._se_legend_labels()
514
809
 
515
- # --- plot Σ'
516
810
  kw_real = dict(kwargs)
517
811
  if real_sigma is not None:
518
812
  if np.isnan(real_sigma).any():
@@ -520,11 +814,10 @@ class SelfEnergy:
520
814
  "Warning: some Σ'(E) uncertainty values are missing. "
521
815
  "Error bars omitted at those energies."
522
816
  )
523
- kw_real.setdefault("yerr", xprs.sigma_confidence * real_sigma)
817
+ kw_real.setdefault("yerr", xprs.sigma_confidence * factor * real_sigma)
524
818
  kw_real.setdefault("label", real_label)
525
819
  ax.errorbar(x, real, **kw_real)
526
820
 
527
- # --- plot -Σ''
528
821
  kw_imag = dict(kwargs)
529
822
  if imag_sigma is not None:
530
823
  if np.isnan(imag_sigma).any():
@@ -532,16 +825,1362 @@ class SelfEnergy:
532
825
  "Warning: some -Σ''(E) uncertainty values are missing. "
533
826
  "Error bars omitted at those energies."
534
827
  )
535
- kw_imag.setdefault("yerr", xprs.sigma_confidence * imag_sigma)
828
+ kw_imag.setdefault("yerr", xprs.sigma_confidence * factor * imag_sigma)
536
829
  kw_imag.setdefault("label", imag_label)
537
830
  ax.errorbar(x, imag, **kw_imag)
538
831
 
539
- ax.set_xlabel(r"$E-\mu$ (eV)")
540
- ax.set_ylabel(r"$\Sigma'(E),\ -\Sigma''(E)$ (eV)")
832
+ x_unit = "meV" if scale == "meV" else "eV"
833
+ ax.set_xlabel(rf"$E-\mu$ ({x_unit})")
834
+ ax.set_ylabel(rf"$\Sigma'(E),\ -\Sigma''(E)$ ({x_unit})")
541
835
  ax.legend()
542
836
 
543
837
  return fig
544
838
 
839
+ @add_fig_kwargs
840
+ def plot_a2f(self, ax=None, abscissa="forward", **kwargs):
841
+ ax, fig, plt = get_ax_fig_plt(ax=ax)
842
+
843
+ xlim_in = ax.get_xlim()
844
+ ylim_in = ax.get_ylim()
845
+
846
+ if abscissa not in ("forward", "reversed"):
847
+ raise ValueError("abscissa must be either 'forward' or 'reversed'.")
848
+
849
+ omega = self.a2f_omega_range
850
+ spectrum = self.a2f_spectrum
851
+
852
+ if omega is None or spectrum is None:
853
+ raise AttributeError(
854
+ "No cached α²F(ω) spectrum found. Run `extract_a2f()` or "
855
+ "`bayesian_loop()` first."
856
+ )
857
+
858
+ if abscissa == "reversed":
859
+ omega = -omega[::-1]
860
+ spectrum = spectrum[::-1]
861
+
862
+ a2f_label, _ = self._a2f_legend_labels()
863
+ kwargs.setdefault("label", a2f_label)
864
+ ax.plot(omega, spectrum, **kwargs)
865
+
866
+ ax.set_xlabel(r"$\omega$ (meV)")
867
+ ax.set_ylabel(r"$\alpha^2F(\omega)$ (-)")
868
+
869
+ self._apply_spectra_axis_defaults(ax, omega, abscissa, xlim_in, ylim_in)
870
+
871
+ ax.legend()
872
+ return fig
873
+
874
+ @add_fig_kwargs
875
+ def plot_model(self, ax=None, abscissa="forward", **kwargs):
876
+ ax, fig, plt = get_ax_fig_plt(ax=ax)
877
+
878
+ xlim_in = ax.get_xlim()
879
+ ylim_in = ax.get_ylim()
880
+
881
+ if abscissa not in ("forward", "reversed"):
882
+ raise ValueError("abscissa must be either 'forward' or 'reversed'.")
883
+
884
+ omega = self.a2f_omega_range
885
+ model = self.a2f_model
886
+
887
+ if omega is None or model is None:
888
+ raise AttributeError(
889
+ "No cached model spectrum found. Run `extract_a2f()` or "
890
+ "`bayesian_loop()` first."
891
+ )
892
+
893
+ if abscissa == "reversed":
894
+ omega = -omega[::-1]
895
+ model = model[::-1]
896
+
897
+ _, model_label = self._a2f_legend_labels()
898
+ kwargs.setdefault("label", model_label)
899
+ ax.plot(omega, model, **kwargs)
900
+
901
+ ax.set_xlabel(r"$\omega$ (meV)")
902
+ ax.set_ylabel(r"$m(\omega)$ (-)")
903
+
904
+ self._apply_spectra_axis_defaults(ax, omega, abscissa, xlim_in, ylim_in)
905
+
906
+ ax.legend()
907
+ return fig
908
+
909
+ @add_fig_kwargs
910
+ def plot_spectra(self, ax=None, abscissa="forward", **kwargs):
911
+ ax, fig, plt = get_ax_fig_plt(ax=ax)
912
+
913
+ xlim_in = ax.get_xlim()
914
+ ylim_in = ax.get_ylim()
915
+
916
+ if abscissa not in ("forward", "reversed"):
917
+ raise ValueError("abscissa must be either 'forward' or 'reversed'.")
918
+
919
+ omega = self.a2f_omega_range
920
+ spectrum = self.a2f_spectrum
921
+ model = self.a2f_model
922
+
923
+ if omega is None or spectrum is None or model is None:
924
+ raise AttributeError(
925
+ "No cached spectra found. Run `extract_a2f()` or `bayesian_loop()` "
926
+ "first."
927
+ )
928
+
929
+ if abscissa == "reversed":
930
+ omega = -omega[::-1]
931
+ spectrum = spectrum[::-1]
932
+ model = model[::-1]
933
+
934
+ kw_a2f = dict(kwargs)
935
+ kw_model = dict(kwargs)
936
+ a2f_label, model_label = self._a2f_legend_labels()
937
+ kw_a2f.setdefault("label", a2f_label)
938
+ kw_model.setdefault("label", model_label)
939
+
940
+ ax.plot(omega, spectrum, **kw_a2f)
941
+ ax.plot(omega, model, **kw_model)
942
+
943
+ self._apply_spectra_axis_defaults(ax, omega, abscissa, xlim_in, ylim_in)
944
+
945
+ ax.set_xlabel(r"$\omega$ (meV)")
946
+ ax.set_ylabel(r"$\alpha^2F_n(\omega),~m_n(\omega)~(-)$")
947
+ ax.legend()
948
+
949
+ return fig
950
+
951
+ @staticmethod
952
+ def _apply_spectra_axis_defaults(ax, omega, abscissa, xlim_in, ylim_in):
953
+ """Apply default spectra x-range and y-min, without stomping overrides.
954
+
955
+ Defaults are applied only if the incoming axis limits were Matplotlib's
956
+ defaults (0, 1), i.e. the caller did not pre-set them.
957
+ """
958
+ if abscissa not in ("forward", "reversed"):
959
+ raise ValueError("abscissa must be either 'forward' or 'reversed'.")
960
+
961
+ omega_max = float(np.max(np.abs(omega)))
962
+
963
+ # --- X defaults (only if user did not pre-set xlim)
964
+ x0, x1 = xlim_in
965
+ x_is_default = np.isclose(x0, 0.0) and np.isclose(x1, 1.0)
966
+ if x_is_default:
967
+ if abscissa == "forward":
968
+ ax.set_xlim(0.0, omega_max)
969
+ else:
970
+ ax.set_xlim(-omega_max, 0.0)
971
+
972
+ # --- Y default: set only the bottom to 0 (only if user did not pre-set)
973
+ y0, y1 = ylim_in
974
+ y_is_default = np.isclose(y0, 0.0) and np.isclose(y1, 1.0)
975
+ if y_is_default:
976
+ ax.set_ylim(bottom=0.0)
977
+
978
+
979
+ @add_fig_kwargs
980
+ def extract_a2f(self, *, omega_min, omega_max, omega_num, omega_I, omega_M,
981
+ mem=None, ax=None, **mem_kwargs):
982
+ r"""
983
+ Extract Eliashberg function α²F(ω) from the self-energy. While working
984
+ with band maps and MDCs is more intuitive in eV, the self-energy
985
+ extraction is performed in eV.
986
+
987
+ Parameters
988
+ ----------
989
+ ax : Matplotlib-Axes or None
990
+ Axis to plot on. Created if not provided by the user. (Not used yet;
991
+ reserved for future plotting.)
992
+
993
+ Returns
994
+ -------
995
+ spectrum : ndarray
996
+ Extracted α²F(ω).
997
+ model : ndarray
998
+ MEM model spectrum.
999
+ omega_range : ndarray
1000
+ ω grid used for the extraction.
1001
+ alpha_select : float
1002
+ Selected alpha returned by the chi2kink procedure.
1003
+ """
1004
+ from . import settings_parameters as xprs
1005
+
1006
+ # Reserve the plot API now; not used yet, but this matches xARPES style.
1007
+ ax, fig, plt = get_ax_fig_plt(ax=ax)
1008
+
1009
+ mem_cfg = self._merge_defaults(xprs.mem_defaults, mem, mem_kwargs)
1010
+
1011
+ method = mem_cfg["method"]
1012
+ parts = mem_cfg["parts"]
1013
+ iter_max = int(mem_cfg["iter_max"])
1014
+ alpha_min = float(mem_cfg["alpha_min"])
1015
+ alpha_max = float(mem_cfg["alpha_max"])
1016
+ alpha_num = int(mem_cfg["alpha_num"])
1017
+ ecut_left = float(mem_cfg["ecut_left"])
1018
+ ecut_right = mem_cfg["ecut_right"]
1019
+ omega_S = float(mem_cfg["omega_S"])
1020
+ f_chi_squared = mem_cfg["f_chi_squared"]
1021
+ sigma_svd = float(mem_cfg["sigma_svd"])
1022
+ t_criterion = float(mem_cfg["t_criterion"])
1023
+ mu = float(mem_cfg["mu"])
1024
+ a_guess = float(mem_cfg["a_guess"])
1025
+ b_guess = float(mem_cfg["b_guess"])
1026
+ c_guess = float(mem_cfg["c_guess"])
1027
+ d_guess = float(mem_cfg["d_guess"])
1028
+ power = int(mem_cfg["power"])
1029
+ lambda_el = float(mem_cfg["lambda_el"])
1030
+ impurity_magnitude = float(mem_cfg["impurity_magnitude"])
1031
+ W = mem_cfg.get("W", None)
1032
+
1033
+ if omega_S < 0.0:
1034
+ raise ValueError("omega_S must be >= 0.")
1035
+ if f_chi_squared is None:
1036
+ f_chi_squared = 2.5 if parts == "both" else 2.0
1037
+ else:
1038
+ f_chi_squared = float(f_chi_squared)
1039
+ if d_guess <= 0.0:
1040
+ raise ValueError(
1041
+ "chi2kink requires d_guess > 0 to fix the logistic sign "
1042
+ "ambiguity."
1043
+ )
1044
+
1045
+ h_n = mem_cfg.get("h_n", None)
1046
+ if h_n is None:
1047
+ raise ValueError(
1048
+ "`optimisation_parameters` must include 'h_n' for cost evaluation."
1049
+ )
1050
+
1051
+ from . import (create_model_function, create_kernel_function,
1052
+ singular_value_decomposition, MEM_core)
1053
+
1054
+ omega_range = np.linspace(omega_min, omega_max, omega_num)
1055
+ model = create_model_function(omega_range, omega_I, omega_M, omega_S, h_n)
1056
+
1057
+ delta_omega = (omega_max - omega_min) / (omega_num - 1)
1058
+ model_in = model * delta_omega
1059
+
1060
+ energies_eV = self.enel_range
1061
+
1062
+ ecut_left_eV = ecut_left / KILO
1063
+ if ecut_right is None:
1064
+ ecut_right_eV = self.energy_resolution
1065
+ else:
1066
+ ecut_right_eV = float(ecut_right) / KILO
1067
+
1068
+ Emin = np.min(energies_eV)
1069
+ Elow = Emin + ecut_left_eV
1070
+ Ehigh = -ecut_right_eV
1071
+ mE = (energies_eV >= Elow) & (energies_eV <= Ehigh)
1072
+
1073
+ if not np.any(mE):
1074
+ raise ValueError(
1075
+ "Energy cutoffs removed all points; adjust ecut_left/right."
1076
+ )
1077
+
1078
+ energies_eV_masked = energies_eV[mE]
1079
+ energies = energies_eV_masked * KILO
1080
+ k_BT = K_B * self.temperature * KILO
1081
+
1082
+ kernel = create_kernel_function(energies, omega_range, k_BT)
1083
+
1084
+ if lambda_el:
1085
+ if W is None:
1086
+ if self._class == "SpectralQuadratic":
1087
+ W = (PREF * self._fermi_wavevector**2 / self._bare_mass) * KILO
1088
+ else:
1089
+ raise ValueError(
1090
+ "lambda_el was provided, but W is None. For a linearised "
1091
+ "band (SpectralLinear), you must also provide W in meV: "
1092
+ "the electron–electron interaction scale."
1093
+ )
1094
+
1095
+ energies_el = energies_eV_masked * KILO
1096
+ real_el, imag_el = self._el_el_self_energy(
1097
+ energies_el, k_BT, lambda_el, W, power
1098
+ )
1099
+ else:
1100
+ real_el = 0.0
1101
+ imag_el = 0.0
1102
+
1103
+ if parts == "both":
1104
+ real = self.real[mE] * KILO - real_el
1105
+ real_sigma = self.real_sigma[mE] * KILO
1106
+ imag = self.imag[mE] * KILO - impurity_magnitude - imag_el
1107
+ imag_sigma = self.imag_sigma[mE] * KILO
1108
+ dvec = np.concatenate((real, imag))
1109
+ wvec = np.concatenate((real_sigma**(-2), imag_sigma**(-2)))
1110
+ H = np.concatenate((np.real(kernel), -np.imag(kernel)))
1111
+ elif parts == "real":
1112
+ real = self.real[mE] * KILO - real_el
1113
+ real_sigma = self.real_sigma[mE] * KILO
1114
+ dvec = real
1115
+ wvec = real_sigma**(-2)
1116
+ H = np.real(kernel)
1117
+ else: # parts == "imag"
1118
+ imag = self.imag[mE] * KILO - impurity_magnitude - imag_el
1119
+ imag_sigma = self.imag_sigma[mE] * KILO
1120
+ dvec = imag
1121
+ wvec = imag_sigma**(-2)
1122
+ H = -np.imag(kernel)
1123
+
1124
+ V_Sigma, U, uvec = singular_value_decomposition(H, sigma_svd)
1125
+
1126
+ if method == "chi2kink":
1127
+ (spectrum_in, alpha_select, fit_curve, guess_curve,
1128
+ chi2kink_result) = self._chi2kink_a2f(
1129
+ dvec, model_in, uvec, mu, wvec, V_Sigma, U, alpha_min,
1130
+ alpha_max, alpha_num, a_guess, b_guess, c_guess, d_guess,
1131
+ f_chi_squared, t_criterion, iter_max, MEM_core
1132
+ )
1133
+ else:
1134
+ raise NotImplementedError(
1135
+ f"extract_a2f does not support method='{method}'."
1136
+ )
1137
+
1138
+ # --- Plot on ax: always raw chi2 + guess; add fit only if success ---
1139
+ alpha_range = chi2kink_result["alpha_range"]
1140
+ alpha0 = float(alpha_range[0])
1141
+ x_plot = np.log10(alpha_range / alpha0)
1142
+ y_chi2 = chi2kink_result["log_chi_squared"]
1143
+
1144
+ ax.set_xlabel(r"log$_{10}(\alpha)$ (-)")
1145
+ ax.set_ylabel(r"log$_{10}(\chi^2)$ (-)")
1146
+
1147
+ ax.plot(x_plot, y_chi2, label="data")
1148
+ ax.plot(x_plot, guess_curve, label="guess")
1149
+
1150
+ if chi2kink_result["success"]:
1151
+ ax.plot(x_plot, fit_curve, label="fit")
1152
+ ax.axvline(
1153
+ np.log10(alpha_select / alpha0),
1154
+ linestyle="--",
1155
+ label=r"$\alpha_{\rm sel}$",
1156
+ )
1157
+ ax.legend()
1158
+
1159
+ # Abort extraction if fit failed (you asked to terminate in that case)
1160
+ if not chi2kink_result["success"]:
1161
+ raise RuntimeError(
1162
+ "chi2kink logistic fit failed; aborting extract_a2f after "
1163
+ "plotting chi2 and guess."
1164
+ )
1165
+
1166
+ # From here on, we know spectrum_in and alpha_select exist
1167
+ spectrum = spectrum_in * omega_num / omega_max
1168
+
1169
+ self._a2f_spectrum = spectrum
1170
+ self._a2f_model = model
1171
+ self._a2f_omega_range = omega_range
1172
+ self._a2f_alpha_select = alpha_select
1173
+ self._a2f_cost = None
1174
+
1175
+ return fig, spectrum, model, omega_range, alpha_select
1176
+
1177
+
1178
+ def bayesian_loop(self, *, omega_min, omega_max, omega_num, omega_I,
1179
+ omega_M, fermi_velocity=None,
1180
+ fermi_wavevector=None, bare_mass=None, vary=(),
1181
+ opt_method="Nelder-Mead", opt_options=None,
1182
+ mem=None, loop=None, **mem_kwargs):
1183
+ r"""
1184
+ Bayesian outer loop calling `_cost_function()`.
1185
+
1186
+ If `vary` is non-empty, runs a SciPy optimization over the selected
1187
+ parameters in `vary`.
1188
+
1189
+ Supported entries in `vary` depend on `self._class`:
1190
+
1191
+ - Common: "fermi_wavevector", "impurity_magnitude", "lambda_el", "h_n"
1192
+ - SpectralLinear: additionally "fermi_velocity"
1193
+ - SpectralQuadratic: additionally "bare_mass"
1194
+
1195
+ Notes
1196
+ -----
1197
+ **Convergence behaviour**
1198
+
1199
+ By default, convergence is controlled by a *custom patience criterion*:
1200
+ the optimization terminates when the absolute difference between the
1201
+ current cost and the best cost seen so far is smaller than `tole` for
1202
+ `converge_iters` consecutive iterations.
1203
+
1204
+ To instead rely on SciPy's native convergence criteria (e.g. Nelder–Mead
1205
+ `xatol` / `fatol`), disable the custom criterion by setting
1206
+ `converge_iters=0` or `tole=None`. In that case, SciPy termination options
1207
+ supplied via `opt_options` are used.
1208
+
1209
+ Parameters
1210
+ ----------
1211
+ opt_options : dict, optional
1212
+ Options passed directly to `scipy.optimize.minimize`. These are only
1213
+ used for convergence if the custom criterion is disabled (see Notes).
1214
+ """
1215
+
1216
+ fermi_velocity, fermi_wavevector, bare_mass = self._prepare_bare(
1217
+ fermi_velocity, fermi_wavevector, bare_mass)
1218
+
1219
+ vary = tuple(vary) if vary is not None else ()
1220
+
1221
+ allowed = {"fermi_wavevector", "impurity_magnitude", "lambda_el", "h_n"}
1222
+
1223
+ if self._class == "SpectralLinear":
1224
+ allowed.add("fermi_velocity")
1225
+ elif self._class == "SpectralQuadratic":
1226
+ allowed.add("bare_mass")
1227
+ else:
1228
+ raise NotImplementedError(
1229
+ f"bayesian_loop does not support spectral class '{self._class}'."
1230
+ )
1231
+
1232
+ unknown = set(vary).difference(allowed)
1233
+ if unknown:
1234
+ raise ValueError(
1235
+ f"Unsupported entries in vary: {sorted(unknown)}. "
1236
+ f"Allowed: {sorted(allowed)}."
1237
+ )
1238
+
1239
+ omega_num = int(omega_num)
1240
+ if omega_num < 2:
1241
+ raise ValueError("omega_num must be an integer >= 2.")
1242
+
1243
+ from . import settings_parameters as xprs
1244
+
1245
+ mem_cfg = self._merge_defaults(xprs.mem_defaults, mem, mem_kwargs)
1246
+
1247
+ parts = mem_cfg["parts"]
1248
+ sigma_svd = float(mem_cfg["sigma_svd"])
1249
+ ecut_left = float(mem_cfg["ecut_left"])
1250
+ ecut_right = mem_cfg["ecut_right"]
1251
+ omega_S = float(mem_cfg["omega_S"])
1252
+ imp0 = float(mem_cfg["impurity_magnitude"])
1253
+ lae0 = float(mem_cfg["lambda_el"])
1254
+ h_n0 = float(mem_cfg["h_n"])
1255
+ h_n_min = float(mem_cfg.get("h_n_min", 1e-8))
1256
+
1257
+ loop_overrides = {
1258
+ key: val for key, val in mem_kwargs.items()
1259
+ if (val is not None) and (key in xprs.loop_defaults)
1260
+ }
1261
+ loop_cfg = self._merge_defaults(xprs.loop_defaults, loop, loop_overrides)
1262
+
1263
+ tole = float(loop_cfg["tole"])
1264
+ converge_iters = int(loop_cfg["converge_iters"])
1265
+ opt_iter_max = int(loop_cfg["opt_iter_max"])
1266
+ scale_vF = float(loop_cfg["scale_vF"])
1267
+ scale_mb = float(loop_cfg["scale_mb"])
1268
+ scale_imp = float(loop_cfg["scale_imp"])
1269
+ scale_kF = float(loop_cfg["scale_kF"])
1270
+ scale_lambda_el = float(loop_cfg["scale_lambda_el"])
1271
+ scale_hn = float(loop_cfg["scale_hn"])
1272
+
1273
+ rollback_steps = int(loop_cfg.get("rollback_steps"))
1274
+ max_retries = int(loop_cfg.get("max_retries"))
1275
+ relative_best = float(loop_cfg.get("relative_best"))
1276
+ min_steps_for_regression = int(loop_cfg.get("min_steps_for_regression"))
1277
+
1278
+ if rollback_steps < 0:
1279
+ raise ValueError("rollback_steps must be >= 0.")
1280
+ if max_retries < 0:
1281
+ raise ValueError("max_retries must be >= 0.")
1282
+ if relative_best <= 0.0:
1283
+ raise ValueError("relative_best must be > 0.")
1284
+ if min_steps_for_regression < 0:
1285
+ raise ValueError("min_steps_for_regression must be >= 0.")
1286
+
1287
+ vF0 = float(fermi_velocity) if fermi_velocity is not None else None
1288
+ kF0 = float(fermi_wavevector) if fermi_wavevector is not None else None
1289
+ mb0 = float(bare_mass) if bare_mass is not None else None
1290
+
1291
+ if lae0 < 0.0:
1292
+ raise ValueError("Initial lambda_el must be >= 0.")
1293
+ if imp0 < 0.0:
1294
+ raise ValueError("Initial impurity_magnitude must be >= 0.")
1295
+ if omega_S < 0.0:
1296
+ raise ValueError("omega_S must be >= 0.")
1297
+ if h_n_min <= 0.0:
1298
+ raise ValueError("h_n_min must be > 0.")
1299
+ if h_n0 < h_n_min:
1300
+ raise ValueError(
1301
+ f"Initial h_n ({h_n0:g}) must be >= h_n_min ({h_n_min:g})."
1302
+ )
1303
+ if kF0 is None:
1304
+ raise ValueError(
1305
+ "bayesian_loop requires an initial fermi_wavevector."
1306
+ )
1307
+ if self._class == "SpectralLinear" and vF0 is None:
1308
+ raise ValueError(
1309
+ "bayesian_loop requires an initial fermi_velocity."
1310
+ )
1311
+ if self._class == "SpectralQuadratic" and mb0 is None:
1312
+ raise ValueError("bayesian_loop requires an initial bare_mass.")
1313
+
1314
+ from scipy.optimize import minimize
1315
+ from . import create_kernel_function, singular_value_decomposition
1316
+
1317
+ ecut_left = float(mem_cfg["ecut_left"])
1318
+ ecut_right = mem_cfg["ecut_right"]
1319
+
1320
+ ecut_left_eV = ecut_left / KILO
1321
+ if ecut_right is None:
1322
+ ecut_right_eV = self.energy_resolution
1323
+ else:
1324
+ ecut_right_eV = float(ecut_right) / KILO
1325
+
1326
+ energies_eV = self.enel_range
1327
+ Emin = np.min(energies_eV)
1328
+ Elow = Emin + ecut_left_eV
1329
+ Ehigh = -ecut_right_eV
1330
+ mE = (energies_eV >= Elow) & (energies_eV <= Ehigh)
1331
+
1332
+ if not np.any(mE):
1333
+ raise ValueError(
1334
+ "Energy cutoffs removed all points; adjust ecut_left/right."
1335
+ )
1336
+
1337
+ energies_eV_masked = energies_eV[mE]
1338
+ energies = energies_eV_masked * KILO
1339
+
1340
+ k_BT = K_B * self.temperature * KILO
1341
+ omega_range = np.linspace(omega_min, omega_max, omega_num)
1342
+
1343
+ kernel_raw = create_kernel_function(energies, omega_range, k_BT)
1344
+
1345
+ if parts == "both":
1346
+ kernel_used = np.concatenate((np.real(kernel_raw), -np.imag(kernel_raw)))
1347
+ elif parts == "real":
1348
+ kernel_used = np.real(kernel_raw)
1349
+ else: # parts == "imag"
1350
+ kernel_used = -np.imag(kernel_raw)
1351
+
1352
+ V_Sigma, U, uvec0 = singular_value_decomposition(kernel_used, sigma_svd)
1353
+
1354
+ _precomp = {
1355
+ "omega_range": omega_range,
1356
+ "mE": mE,
1357
+ "energies_eV_masked": energies_eV_masked,
1358
+ "V_Sigma": V_Sigma,
1359
+ "U": U,
1360
+ "uvec0": uvec0,
1361
+ "ecut_left": ecut_left,
1362
+ "ecut_right": ecut_right,
1363
+ }
1364
+
1365
+ def _reflect_min(xi, p0, p_min, scale):
1366
+ """Map R -> [p_min, +inf) using linear reflection around p_min."""
1367
+ return p_min + np.abs((float(p0) - p_min) + scale * float(xi))
1368
+
1369
+ def _unpack_params(x):
1370
+ params = {}
1371
+
1372
+ i = 0
1373
+ for name in vary:
1374
+ xi = float(x[i])
1375
+
1376
+ if name == "fermi_velocity":
1377
+ if vF0 is None:
1378
+ raise ValueError("Cannot vary fermi_velocity: no "
1379
+ "initial vF provided.")
1380
+ params["fermi_velocity"] = vF0 + scale_vF * xi
1381
+
1382
+ elif name == "bare_mass":
1383
+ if mb0 is None:
1384
+ raise ValueError("Cannot vary bare_mass: no initial "
1385
+ "bare_mass provided.")
1386
+ params["bare_mass"] = mb0 + scale_mb * xi
1387
+
1388
+ elif name == "fermi_wavevector":
1389
+ if kF0 is None:
1390
+ raise ValueError(
1391
+ "Cannot vary fermi_wavevector: no initial kF "
1392
+ "provided."
1393
+ )
1394
+ params["fermi_wavevector"] = kF0 + scale_kF * xi
1395
+
1396
+ elif name == "impurity_magnitude":
1397
+ params["impurity_magnitude"] = _reflect_min(xi, imp0, 0.0, scale_imp)
1398
+
1399
+ elif name == "lambda_el":
1400
+ params["lambda_el"] = _reflect_min(xi, lae0, 0.0, scale_lambda_el)
1401
+
1402
+ elif name == "h_n":
1403
+ params["h_n"] = _reflect_min(xi, h_n0, h_n_min, scale_hn)
1404
+
1405
+ i += 1
1406
+
1407
+ params.setdefault("fermi_wavevector", kF0)
1408
+ params.setdefault("impurity_magnitude", imp0)
1409
+ params.setdefault("lambda_el", lae0)
1410
+ params.setdefault("h_n", h_n0)
1411
+
1412
+ if self._class == "SpectralLinear":
1413
+ params.setdefault("fermi_velocity", vF0)
1414
+ elif self._class == "SpectralQuadratic":
1415
+ params.setdefault("bare_mass", mb0)
1416
+
1417
+ return params
1418
+
1419
+ def _evaluate_cost(params):
1420
+ optimisation_parameters = {
1421
+ "h_n": params["h_n"],
1422
+ "impurity_magnitude": params["impurity_magnitude"],
1423
+ "lambda_el": params["lambda_el"],
1424
+ "fermi_wavevector": params["fermi_wavevector"],
1425
+ }
1426
+
1427
+ if self._class == "SpectralLinear":
1428
+ optimisation_parameters["fermi_velocity"] = params["fermi_velocity"]
1429
+ elif self._class == "SpectralQuadratic":
1430
+ optimisation_parameters["bare_mass"] = params["bare_mass"]
1431
+ else:
1432
+ raise NotImplementedError(
1433
+ f"_evaluate_cost does not support class '{self._class}'."
1434
+ )
1435
+
1436
+ return self._cost_function(
1437
+ optimisation_parameters=optimisation_parameters,
1438
+ omega_min=omega_min, omega_max=omega_max, omega_num=omega_num,
1439
+ omega_I=omega_I, omega_M=omega_M, mem_cfg=mem_cfg,
1440
+ _precomp=_precomp
1441
+ )
1442
+
1443
+ last = {"cost": None, "spectrum": None, "model": None, "alpha": None}
1444
+
1445
+ iter_counter = {"n": 0}
1446
+
1447
+ class ConvergenceException(RuntimeError):
1448
+ """Raised when optimisation has converged successfully."""
1449
+
1450
+ class RegressionException(RuntimeError):
1451
+ """Raised when optimizer regresses toward the initial guess."""
1452
+
1453
+ if converge_iters is None:
1454
+ converge_iters = 0
1455
+ converge_iters = int(converge_iters)
1456
+
1457
+ if tole is not None:
1458
+ tole = float(tole)
1459
+ if tole < 0.0:
1460
+ raise ValueError("tole must be >= 0.")
1461
+ if converge_iters < 0:
1462
+ raise ValueError("converge_iters must be >= 0.")
1463
+
1464
+ # Track best solution seen across all obj calls (not just last).
1465
+ best_global = {
1466
+ "x": None,
1467
+ "params": None,
1468
+ "cost": np.inf,
1469
+ "spectrum": None,
1470
+ "model": None,
1471
+ "alpha": None,
1472
+ }
1473
+
1474
+ history = []
1475
+
1476
+ # Cache most recent evaluation so the callback can read a cost without
1477
+ # forcing an extra objective evaluation.
1478
+ last_x = {"x": None}
1479
+ last_cost = {"cost": None}
1480
+ initial_cost = {"cost": None}
1481
+
1482
+ iter_counter = {"n": 0}
1483
+
1484
+ def _clean_params(params):
1485
+ """Convert NumPy scalar values to plain Python scalars."""
1486
+ out = {}
1487
+ for key, val in params.items():
1488
+ if isinstance(val, np.generic):
1489
+ out[key] = float(val)
1490
+ else:
1491
+ out[key] = val
1492
+ return out
1493
+
1494
+ def obj(x):
1495
+ import warnings
1496
+
1497
+ iter_counter["n"] += 1
1498
+
1499
+ params = _unpack_params(x)
1500
+
1501
+ with warnings.catch_warnings():
1502
+ warnings.simplefilter("error", RuntimeWarning)
1503
+ try:
1504
+ cost, spectrum, model, alpha_select = _evaluate_cost(params)
1505
+ except RuntimeWarning as exc:
1506
+ raise ValueError(f"RuntimeWarning during cost eval: {exc}") from exc
1507
+ cost_f = float(cost)
1508
+
1509
+ history.append(
1510
+ {
1511
+ "x": np.array(x, dtype=float, copy=True),
1512
+ "params": _clean_params(params),
1513
+ "cost": cost_f,
1514
+ "spectrum": spectrum,
1515
+ "model": model,
1516
+ "alpha": float(alpha_select),
1517
+ }
1518
+ )
1519
+
1520
+ last["cost"] = cost_f
1521
+ last["spectrum"] = spectrum
1522
+ last["model"] = model
1523
+ last["alpha"] = float(alpha_select)
1524
+
1525
+ last_x["x"] = np.array(x, dtype=float, copy=True)
1526
+ last_cost["cost"] = cost_f
1527
+
1528
+ if initial_cost["cost"] is None:
1529
+ initial_cost["cost"] = cost_f
1530
+
1531
+ if cost_f < best_global["cost"]:
1532
+ best_global["x"] = np.array(x, dtype=float, copy=True)
1533
+ best_global["cost"] = cost_f
1534
+ best_global["params"] = _clean_params(params)
1535
+ best_global["spectrum"] = spectrum
1536
+ best_global["model"] = model
1537
+ best_global["alpha"] = float(alpha_select)
1538
+
1539
+ msg = [f"Iter {iter_counter['n']:4d} | cost = {cost: .4e}"]
1540
+ for key in sorted(params):
1541
+ msg.append(f"{key}={params[key]:.8g}")
1542
+ print(" | ".join(msg))
1543
+
1544
+ return cost_f
1545
+
1546
+ class TerminationCallback:
1547
+ def __init__(self, tole, converge_iters,
1548
+ min_steps_for_regression):
1549
+ self.tole = None if tole is None else float(tole)
1550
+ self.converge_iters = int(converge_iters)
1551
+ self.min_steps_for_regression = int(
1552
+ min_steps_for_regression
1553
+ )
1554
+ self.iter_count = 0
1555
+ self.call_count = 0
1556
+
1557
+ def __call__(self, xk):
1558
+ self.call_count += 1
1559
+
1560
+ if self.tole is None or self.converge_iters <= 0:
1561
+ return
1562
+
1563
+ current = last_cost["cost"]
1564
+ if current is None:
1565
+ return
1566
+
1567
+ best_cost = float(best_global["cost"])
1568
+ if np.isfinite(best_cost):
1569
+ if abs(current - best_cost) < self.tole:
1570
+ self.iter_count += 1
1571
+ else:
1572
+ self.iter_count = 0
1573
+
1574
+ if self.iter_count >= self.converge_iters:
1575
+ raise ConvergenceException(
1576
+ "Converged: |cost-best| < "
1577
+ f"{self.tole:g} for "
1578
+ f"{self.converge_iters} iterations."
1579
+ )
1580
+
1581
+ if self.call_count < self.min_steps_for_regression:
1582
+ return
1583
+
1584
+ init_cost = initial_cost["cost"]
1585
+ if init_cost is None:
1586
+ return
1587
+
1588
+ current = float(current)
1589
+ init_cost = float(init_cost)
1590
+
1591
+ if not np.isfinite(best_cost):
1592
+ return
1593
+
1594
+ if (
1595
+ abs(current - init_cost) * relative_best
1596
+ < abs(current - best_cost)
1597
+ ):
1598
+ raise RegressionException(
1599
+ "Regression toward initial guess detected."
1600
+ )
1601
+
1602
+ callback = TerminationCallback(
1603
+ tole=tole,
1604
+ converge_iters=converge_iters,
1605
+ min_steps_for_regression=min_steps_for_regression,
1606
+ )
1607
+
1608
+ if not vary:
1609
+ params = _unpack_params(np.zeros(0, dtype=float))
1610
+ cost, spectrum, model, alpha_select = _evaluate_cost(params)
1611
+ return cost, spectrum, model, alpha_select
1612
+
1613
+ x0 = np.zeros(len(vary), dtype=float)
1614
+
1615
+ options = {} if opt_options is None else dict(opt_options)
1616
+ options.setdefault("maxiter", int(opt_iter_max))
1617
+
1618
+ use_patience = (tole is not None) and (int(converge_iters) > 0)
1619
+ if use_patience:
1620
+ options.pop("xatol", None)
1621
+ options.pop("fatol", None)
1622
+
1623
+ retry_count = 0
1624
+ res = None
1625
+
1626
+ while retry_count <= max_retries:
1627
+ best = {
1628
+ "x": None,
1629
+ "params": None,
1630
+ "cost": np.inf,
1631
+ "spectrum": None,
1632
+ "model": None,
1633
+ "alpha": None,
1634
+ }
1635
+ last_x["x"] = None
1636
+ last_cost["cost"] = None
1637
+ initial_cost["cost"] = None
1638
+ iter_counter["n"] = 0
1639
+ history.clear()
1640
+
1641
+ callback = TerminationCallback(
1642
+ tole=tole,
1643
+ converge_iters=converge_iters,
1644
+ min_steps_for_regression=min_steps_for_regression,
1645
+ )
1646
+
1647
+ try:
1648
+ res = minimize(
1649
+ obj,
1650
+ x0,
1651
+ method=opt_method,
1652
+ options=options,
1653
+ callback=callback,
1654
+ )
1655
+ break
1656
+
1657
+ except ConvergenceException as exc:
1658
+ print(str(exc))
1659
+ res = None
1660
+ break
1661
+
1662
+ except RegressionException as exc:
1663
+ print(f"{exc} Rolling back {rollback_steps} steps.")
1664
+ retry_count += 1
1665
+
1666
+ if rollback_steps <= 0 or not history:
1667
+ continue
1668
+
1669
+ back = min(int(rollback_steps), len(history))
1670
+ x0 = np.array(history[-back]["x"], dtype=float, copy=True)
1671
+ continue
1672
+
1673
+ except ValueError as exc:
1674
+ print(f"ValueError encountered: {exc}. Rolling back.")
1675
+ retry_count += 1
1676
+
1677
+ if rollback_steps <= 0 or not history:
1678
+ continue
1679
+
1680
+ back = min(int(rollback_steps), len(history))
1681
+ x0 = np.array(history[-back]["x"], dtype=float, copy=True)
1682
+ continue
1683
+
1684
+ if retry_count > max_retries:
1685
+ print("Max retries reached. Parameters may not be optimal.")
1686
+
1687
+ if best_global["params"] is None:
1688
+ params = _unpack_params(x0)
1689
+ cost, spectrum, model, alpha_select = _evaluate_cost(params)
1690
+ else:
1691
+ params = best_global["params"]
1692
+ cost = best_global["cost"]
1693
+ spectrum = best_global["spectrum"]
1694
+ model = best_global["model"]
1695
+ alpha_select = best_global["alpha"]
1696
+
1697
+ args = ", ".join(
1698
+ f"{key}={params[key]:.10g}" if isinstance(params[key], float)
1699
+ else f"{key}={params[key]}"
1700
+ for key in sorted(params)
1701
+ )
1702
+ print("Optimised parameters:")
1703
+ print(args)
1704
+
1705
+ # store inside class methods
1706
+ self._a2f_spectrum = spectrum
1707
+ self._a2f_model = model
1708
+ self._a2f_omega_range = omega_range
1709
+ self._a2f_alpha_select = alpha_select
1710
+ self._a2f_cost = cost
1711
+
1712
+ return spectrum, model, omega_range, alpha_select, cost, params
1713
+
1714
+ @staticmethod
1715
+ def _merge_defaults(defaults, override_dict=None, override_kwargs=None):
1716
+ """Merge defaults with dict + kwargs overrides (kwargs win)."""
1717
+ cfg = dict(defaults)
1718
+
1719
+ if override_dict is not None:
1720
+ cfg.update(dict(override_dict))
1721
+
1722
+ if override_kwargs is not None:
1723
+ cfg.update({k: v for k, v in override_kwargs.items() if v is not None})
1724
+
1725
+ return cfg
1726
+
1727
+ def _prepare_bare(self, fermi_velocity, fermi_wavevector, bare_mass):
1728
+ """Validate class-compatible band parameters and infer missing defaults.
1729
+
1730
+ Enforces:
1731
+ - SpectralLinear: bare_mass must be None; vF and kF must be available.
1732
+ - SpectralQuadratic: fermi_velocity must be None; bare_mass and kF must
1733
+ be available.
1734
+
1735
+ Returns
1736
+ -------
1737
+ fermi_velocity : float or None
1738
+ Initial vF (Linear) or None (Quadratic).
1739
+ fermi_wavevector : float
1740
+ Initial kF.
1741
+ bare_mass : float or None
1742
+ Initial bare mass (Quadratic) or None (Linear).
1743
+ """
1744
+ if self._class == "SpectralLinear":
1745
+ if bare_mass is not None:
1746
+ raise ValueError(
1747
+ "SpectralLinear bayesian_loop does not accept "
1748
+ "`bare_mass`. Provide `fermi_velocity` instead."
1749
+ )
1750
+
1751
+ if fermi_velocity is None:
1752
+ fermi_velocity = getattr(self, "fermi_velocity", None)
1753
+ if fermi_velocity is None:
1754
+ raise ValueError(
1755
+ "SpectralLinear optimisation requires an initial "
1756
+ "fermi_velocity to be provided."
1757
+ )
1758
+
1759
+ if fermi_wavevector is None:
1760
+ fermi_wavevector = getattr(self, "fermi_wavevector", None)
1761
+ if fermi_wavevector is None:
1762
+ raise ValueError(
1763
+ "SpectralLinear optimisation requires an initial "
1764
+ "fermi_wavevector to be provided."
1765
+ )
1766
+
1767
+ return float(fermi_velocity), float(fermi_wavevector), None
1768
+
1769
+ elif self._class == "SpectralQuadratic":
1770
+ if fermi_velocity is not None:
1771
+ raise ValueError(
1772
+ "SpectralQuadratic bayesian_loop does not accept "
1773
+ "`fermi_velocity`. Provide `bare_mass` instead."
1774
+ )
1775
+
1776
+ if bare_mass is None:
1777
+ bare_mass = getattr(self, "_bare_mass", None)
1778
+ if bare_mass is None:
1779
+ raise ValueError(
1780
+ "SpectralQuadratic optimisation requires an initial "
1781
+ "bare_mass to be provided."
1782
+ )
1783
+
1784
+ if fermi_wavevector is None:
1785
+ fermi_wavevector = getattr(self, "fermi_wavevector", None)
1786
+ if fermi_wavevector is None:
1787
+ raise ValueError(
1788
+ "SpectralQuadratic optimisation requires an initial "
1789
+ "fermi_wavevector to be provided."
1790
+ )
1791
+
1792
+ return None, float(fermi_wavevector), float(bare_mass)
1793
+
1794
+ else:
1795
+ raise NotImplementedError(
1796
+ f"_prepare_bare is not implemented for spectral class "
1797
+ "'{self._class}'.")
1798
+
1799
+ def _cost_function(self, *, optimisation_parameters, omega_min, omega_max,
1800
+ omega_num, omega_I, omega_M, mem_cfg, _precomp):
1801
+ r"""TBD
1802
+
1803
+ Negative log-posterior cost function for Bayesian optimisation.
1804
+
1805
+ This mirrors `extract_a2f()` but recomputes the self-energy arrays for the
1806
+ candidate optimisation parameters instead of using cached `self.real/imag`.
1807
+
1808
+ Parameters
1809
+ ----------
1810
+ optimisation_parameters : dict
1811
+ Must include at least keys: "h_n", "impurity_magnitude", "lambda_el".
1812
+ For SpectralLinear, must also include "fermi_velocity" and
1813
+ "fermi_wavevector". For SpectralQuadratic, "bare_mass" is optional
1814
+ (falls back to `self._bare_mass` if present).
1815
+
1816
+ Returns
1817
+ -------
1818
+ cost : float
1819
+ Negative log-posterior evaluated at the selected alpha.
1820
+ spectrum : ndarray
1821
+ Rescaled α²F(ω) spectrum (same scaling convention as `extract_a2f()`).
1822
+ model : ndarray
1823
+ The model spectrum used by MEM (same as `extract_a2f()`).
1824
+ alpha_select : float
1825
+ The selected alpha returned by `_chi2kink_a2f`.
1826
+ """
1827
+
1828
+ required = {"h_n", "impurity_magnitude", "lambda_el"}
1829
+ missing = required.difference(optimisation_parameters)
1830
+ if missing:
1831
+ raise ValueError(
1832
+ f"Missing optimisation parameters: {sorted(missing)}"
1833
+ )
1834
+
1835
+ parts = mem_cfg["parts"]
1836
+ method = mem_cfg["method"]
1837
+ alpha_min = float(mem_cfg["alpha_min"])
1838
+ alpha_max = float(mem_cfg["alpha_max"])
1839
+ alpha_num = int(mem_cfg["alpha_num"])
1840
+ omega_S = float(mem_cfg["omega_S"])
1841
+
1842
+ mu = float(mem_cfg["mu"])
1843
+ a_guess = float(mem_cfg["a_guess"])
1844
+ b_guess = float(mem_cfg["b_guess"])
1845
+ c_guess = float(mem_cfg["c_guess"])
1846
+ d_guess = float(mem_cfg["d_guess"])
1847
+
1848
+ f_chi_squared = mem_cfg["f_chi_squared"]
1849
+ power = int(mem_cfg["power"])
1850
+ W = mem_cfg.get("W", None)
1851
+ t_criterion = float(mem_cfg["t_criterion"])
1852
+ iter_max = int(mem_cfg["iter_max"])
1853
+
1854
+ if f_chi_squared is None:
1855
+ f_chi_squared = 2.5 if parts == "both" else 2.0
1856
+ else:
1857
+ f_chi_squared = float(f_chi_squared)
1858
+
1859
+ if d_guess <= 0.0:
1860
+ raise ValueError(
1861
+ "chi2kink requires d_guess > 0 to fix the logistic sign ambiguity."
1862
+ )
1863
+
1864
+ if parts not in {"both", "real", "imag"}:
1865
+ raise ValueError("parts must be one of {'both', 'real', 'imag'}")
1866
+
1867
+ if method != "chi2kink":
1868
+ raise NotImplementedError(
1869
+ "Only method='chi2kink' is currently implemented."
1870
+ )
1871
+
1872
+ impurity_magnitude = float(optimisation_parameters["impurity_magnitude"])
1873
+ lambda_el = float(optimisation_parameters["lambda_el"])
1874
+ h_n = float(optimisation_parameters["h_n"])
1875
+
1876
+ fermi_velocity = None
1877
+ fermi_wavevector = None
1878
+ bare_mass = None
1879
+
1880
+ if self._class == "SpectralLinear":
1881
+ required_lin = {"fermi_velocity", "fermi_wavevector"}
1882
+ missing_lin = required_lin.difference(optimisation_parameters)
1883
+ if missing_lin:
1884
+ raise ValueError(
1885
+ "SpectralLinear requires optimisation_parameters to include "
1886
+ f"{sorted(missing_lin)}."
1887
+ )
1888
+ fermi_velocity = optimisation_parameters["fermi_velocity"]
1889
+ fermi_wavevector = optimisation_parameters["fermi_wavevector"]
1890
+
1891
+ elif self._class == "SpectralQuadratic":
1892
+ if "fermi_wavevector" not in optimisation_parameters:
1893
+ raise ValueError(
1894
+ "SpectralQuadratic requires optimisation_parameters to include "
1895
+ "'fermi_wavevector'."
1896
+ )
1897
+ fermi_wavevector = optimisation_parameters["fermi_wavevector"]
1898
+
1899
+ bare_mass = optimisation_parameters.get("bare_mass", None)
1900
+ if bare_mass is None:
1901
+ bare_mass = getattr(self, "_bare_mass", None)
1902
+
1903
+ else:
1904
+ raise NotImplementedError(
1905
+ f"_cost_function does not support class '{self._class}'."
1906
+ )
1907
+
1908
+ from . import create_model_function, MEM_core
1909
+
1910
+ if f_chi_squared is None:
1911
+ f_chi_squared = 2.5 if parts == "both" else 2.0
1912
+
1913
+ if _precomp is None:
1914
+ raise ValueError(
1915
+ "_precomp is None in _cost_function. Pass the precomputed"
1916
+ " kernel/SVD bundle from bayesian_loop."
1917
+ )
1918
+
1919
+ omega_range = _precomp["omega_range"]
1920
+ mE = _precomp["mE"]
1921
+ energies_eV_masked = _precomp["energies_eV_masked"]
1922
+
1923
+ V_Sigma = _precomp["V_Sigma"]
1924
+ U = _precomp["U"]
1925
+ uvec = np.array(_precomp["uvec0"], copy=True)
1926
+
1927
+ if f_chi_squared is None:
1928
+ f_chi_squared = 2.5 if parts == "both" else 2.0
1929
+
1930
+ model = create_model_function(omega_range, omega_I, omega_M, omega_S, h_n)
1931
+
1932
+ delta_omega = (omega_max - omega_min) / (omega_num - 1)
1933
+ model_in = model * delta_omega
1934
+
1935
+ k_BT = K_B * self.temperature * KILO
1936
+
1937
+ if lambda_el:
1938
+ if W is None:
1939
+ if self._class == "SpectralQuadratic":
1940
+ if fermi_wavevector is None or bare_mass is None:
1941
+ raise ValueError(
1942
+ "lambda_el is nonzero, but W is None and cannot be "
1943
+ "inferred. Provide W (meV), or pass both "
1944
+ "`fermi_wavevector` and `bare_mass`."
1945
+ )
1946
+ W = (PREF * fermi_wavevector**2 / bare_mass) * KILO
1947
+ else:
1948
+ raise ValueError(
1949
+ "lambda_el was provided, but W is None. For "
1950
+ "SpectralLinear you must provide W in meV."
1951
+ )
1952
+
1953
+ energies_el = energies_eV_masked * KILO
1954
+ real_el, imag_el = self._el_el_self_energy(
1955
+ energies_el, k_BT, lambda_el, W, power
1956
+ )
1957
+ else:
1958
+ real_el = 0.0
1959
+ imag_el = 0.0
1960
+
1961
+ real, real_sigma, imag, imag_sigma = self._evaluate_self_energy_arrays(
1962
+ fermi_velocity=fermi_velocity,
1963
+ fermi_wavevector=fermi_wavevector,
1964
+ bare_mass=bare_mass,
1965
+ )
1966
+ if real is None or imag is None:
1967
+ raise ValueError(
1968
+ "Cannot compute self-energy arrays for cost evaluation. "
1969
+ "Ensure the required band parameters and peak/broadening " \
1970
+ "inputs are set.")
1971
+
1972
+ real_m = real[mE] * KILO - real_el
1973
+ imag_m = imag[mE] * KILO - impurity_magnitude - imag_el
1974
+
1975
+ if parts == "both":
1976
+ real_sig_m = real_sigma[mE] * KILO
1977
+ imag_sig_m = imag_sigma[mE] * KILO
1978
+ dvec = np.concatenate((real_m, imag_m))
1979
+ wvec = np.concatenate((real_sig_m**(-2), imag_sig_m**(-2)))
1980
+ elif parts == "real":
1981
+ real_sig_m = real_sigma[mE] * KILO
1982
+ dvec = real_m
1983
+ wvec = real_sig_m**(-2)
1984
+ else:
1985
+ imag_sig_m = imag_sigma[mE] * KILO
1986
+ dvec = imag_m
1987
+ wvec = imag_sig_m**(-2)
1988
+
1989
+ (spectrum_in, alpha_select, fit_curve, guess_curve,
1990
+ chi2kink_result) = self._chi2kink_a2f(
1991
+ dvec, model_in, uvec, mu, wvec, V_Sigma, U, alpha_min, alpha_max,
1992
+ alpha_num, a_guess, b_guess, c_guess, d_guess, f_chi_squared,
1993
+ t_criterion, iter_max, MEM_core,
1994
+ )
1995
+
1996
+ T = V_Sigma @ (U.T @ spectrum_in)
1997
+ chi_squared = wvec @ ((T - dvec) ** 2)
1998
+
1999
+ mask = (spectrum_in > 0.0) & (model_in > 0.0)
2000
+ if not np.any(mask):
2001
+ raise ValueError(
2002
+ "Invalid spectrum/model for entropy: no positive entries "
2003
+ "after MEM."
2004
+ )
2005
+
2006
+ information_entropy = (
2007
+ np.sum(spectrum_in[mask] - model_in[mask])
2008
+ - np.sum(
2009
+ spectrum_in[mask]
2010
+ * np.log(spectrum_in[mask] / model_in[mask])
2011
+ )
2012
+ )
2013
+
2014
+ cost = (0.5 * chi_squared
2015
+ - alpha_select * information_entropy
2016
+ + 0.5 * np.sum(np.log(2.0 * np.pi / wvec))
2017
+ - 0.5 * spectrum_in.size * np.log(alpha_select))
2018
+
2019
+ spectrum = spectrum_in * omega_num / omega_max
2020
+
2021
+ return (cost, spectrum, model, alpha_select)
2022
+
2023
+
2024
+ @staticmethod
2025
+ def _chi2kink_a2f(dvec, model_in, uvec, mu, wvec, V_Sigma, U, alpha_min,
2026
+ alpha_max, alpha_num, a_guess, b_guess, c_guess, d_guess,
2027
+ f_chi_squared, t_criterion, iter_max, MEM_core, *,
2028
+ plot=None):
2029
+ r"""
2030
+ Compute MEM spectrum using the chi2-kink alpha-selection procedure.
2031
+
2032
+ Notes
2033
+ -----
2034
+ This routine contains extensive logic to detect failure modes of the
2035
+ chi2-kink logistic fit, including (but not limited to):
2036
+
2037
+ - non-finite or non-positive chi² values,
2038
+ - lack of meaningful parameter updates relative to the initial guess,
2039
+ - absence of improvement in the residual sum of squares,
2040
+ - numerical instabilities or overflows in the logistic model,
2041
+ - invalid or non-finite alpha selection.
2042
+
2043
+ Despite these safeguards, it is **not possible to guarantee** that all
2044
+ failure modes are detected in a nonlinear least-squares problem.
2045
+ Consequently, a reported success should be interpreted as a *necessary*
2046
+ but *not sufficient* condition for physical or numerical reliability.
2047
+
2048
+ Callers **must** inspect the returned ``success`` flag (contained in
2049
+ ``chi2kink_result``) before using the fitted curve, selected alpha, or
2050
+ MEM spectrum. When ``success`` is False, the returned quantities are
2051
+ limited to those required for diagnostic plotting only.
2052
+ """
2053
+ from . import fit_least_squares, chi2kink_logistic
2054
+
2055
+ alpha_range = np.logspace(alpha_min, alpha_max, int(alpha_num))
2056
+ chi_squared = np.empty_like(alpha_range, dtype=float)
2057
+
2058
+ for i, alpha in enumerate(alpha_range):
2059
+ spectrum_in, uvec = MEM_core(
2060
+ dvec, model_in, uvec, mu, alpha, wvec, V_Sigma, U,
2061
+ t_criterion, iter_max
2062
+ )
2063
+ T = V_Sigma @ (U.T @ spectrum_in)
2064
+ chi_squared[i] = wvec @ ((T - dvec) ** 2)
2065
+
2066
+ if (not np.all(np.isfinite(chi_squared))) or np.any(chi_squared <= 0.0):
2067
+ raise ValueError(
2068
+ "chi_squared contains non-finite or non-positive values."
2069
+ )
2070
+
2071
+ log_alpha = np.log10(alpha_range)
2072
+ log_chi_squared = np.log10(chi_squared)
2073
+
2074
+ p0 = np.array([a_guess, b_guess, c_guess, d_guess], dtype=float)
2075
+ pfit, pcov, lsq_success = fit_least_squares(
2076
+ p0, log_alpha, log_chi_squared, chi2kink_logistic
2077
+ )
2078
+
2079
+ with np.errstate(over="ignore", invalid="ignore", divide="ignore"):
2080
+ guess_curve = chi2kink_logistic(log_alpha, *p0)
2081
+
2082
+ # Start from the necessary requirement: least_squares must say success
2083
+ success = bool(lsq_success)
2084
+
2085
+ # If the guess itself blows up, we can't trust anything
2086
+ if not np.all(np.isfinite(guess_curve)):
2087
+ success = False
2088
+
2089
+ fit_curve_tmp = None
2090
+ if success:
2091
+ pfit = np.asarray(pfit, dtype=float)
2092
+
2093
+ if np.allclose(pfit, p0, rtol=1e-12, atol=0.0):
2094
+ success = False
2095
+ else:
2096
+ with np.errstate(over="ignore", invalid="ignore",
2097
+ divide="ignore"):
2098
+ fit_curve_tmp = chi2kink_logistic(log_alpha, *pfit)
2099
+
2100
+ if not np.all(np.isfinite(fit_curve_tmp)):
2101
+ success = False
2102
+ else:
2103
+ r0 = guess_curve - log_chi_squared
2104
+ r1 = fit_curve_tmp - log_chi_squared
2105
+ sse0 = float(r0 @ r0)
2106
+ sse1 = float(r1 @ r1)
2107
+
2108
+ tol = 1e-12 * max(1.0, sse0)
2109
+ if (not np.isfinite(sse1)) or (sse1 >= sse0 - tol):
2110
+ success = False
2111
+
2112
+ alpha_select = None
2113
+ fit_curve = None
2114
+ spectrum_out = None
2115
+
2116
+ if success:
2117
+ fit_curve = fit_curve_tmp
2118
+
2119
+ cout = float(pfit[2])
2120
+ dout = float(pfit[3])
2121
+ exp10 = cout - float(f_chi_squared) / dout
2122
+
2123
+ if (not np.isfinite(exp10)) or (exp10 < -308.0) or (exp10 > 308.0):
2124
+ success = False
2125
+ fit_curve = None
2126
+ else:
2127
+ with np.errstate(over="raise", invalid="raise"):
2128
+ alpha_select = float(np.power(10.0, exp10))
2129
+
2130
+ spectrum_out, uvec = MEM_core(
2131
+ dvec, model_in, uvec, mu, alpha_select, wvec, V_Sigma, U,
2132
+ t_criterion, iter_max
2133
+ )
2134
+
2135
+ chi2kink_result = {
2136
+ "alpha_range": alpha_range,
2137
+ "chi_squared": chi_squared,
2138
+ "log_alpha": log_alpha,
2139
+ "log_chi_squared": log_chi_squared,
2140
+ "p0": p0,
2141
+ "pfit": pfit,
2142
+ "pcov": pcov,
2143
+ "success": bool(success),
2144
+ "alpha_select": alpha_select,
2145
+ }
2146
+
2147
+ return spectrum_out, alpha_select, fit_curve, guess_curve, chi2kink_result
2148
+
2149
+
2150
+ @staticmethod
2151
+ def _el_el_self_energy(enel_range, k_BT, lambda_el, W, power):
2152
+ """Electron–electron contribution to the self-energy."""
2153
+ x = enel_range / W
2154
+ denom = 1.0 - (np.pi * k_BT / W) ** 2
2155
+
2156
+ if denom == 0.0:
2157
+ raise ZeroDivisionError(
2158
+ "Invalid parameters: 1 - (π k_BT / W)^2 = 0."
2159
+ )
2160
+
2161
+ pref = lambda_el / (W * denom)
2162
+
2163
+ if power == 2:
2164
+ real_el = pref * x * ((np.pi * k_BT) ** 2 - W ** 2) / (1.0 + x ** 2)
2165
+ imag_el = (pref * (enel_range ** 2 + (np.pi * k_BT) ** 2)
2166
+ / (1.0 + x ** 2))
2167
+
2168
+ elif power == 4:
2169
+ num = (
2170
+ (np.pi * k_BT) ** 2 * (1.0 + x ** 2)
2171
+ - W ** 2 * (1.0 - x ** 2)
2172
+ )
2173
+ real_el = pref * x * num / (1.0 + x ** 4)
2174
+ imag_el = (pref * np.sqrt(2.0) * (enel_range ** 2 + (np.pi * k_BT)
2175
+ ** 2) / ( 1.0 + x ** 4))
2176
+ else:
2177
+ raise ValueError(
2178
+ "El-el coupling has not yet been implemented for the given " \
2179
+ "power."
2180
+ )
2181
+
2182
+ return real_el, imag_el
2183
+
545
2184
 
546
2185
  class CreateSelfEnergies:
547
2186
  r"""