xarpes 0.6.0__tar.gz → 0.6.1__tar.gz

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.
Files changed (55) hide show
  1. {xarpes-0.6.0 → xarpes-0.6.1}/PKG-INFO +1 -1
  2. {xarpes-0.6.0 → xarpes-0.6.1}/doc/notebooks/verification.ipynb +3 -3
  3. {xarpes-0.6.0 → xarpes-0.6.1}/xarpes/__init__.py +1 -1
  4. {xarpes-0.6.0 → xarpes-0.6.1}/xarpes/bandmap.py +12 -9
  5. {xarpes-0.6.0 → xarpes-0.6.1}/xarpes/functions.py +39 -42
  6. {xarpes-0.6.0 → xarpes-0.6.1}/xarpes/selfenergies.py +532 -88
  7. {xarpes-0.6.0 → xarpes-0.6.1}/.gitignore +0 -0
  8. {xarpes-0.6.0 → xarpes-0.6.1}/.readthedocs.yaml +0 -0
  9. {xarpes-0.6.0 → xarpes-0.6.1}/LICENSE +0 -0
  10. {xarpes-0.6.0 → xarpes-0.6.1}/README.md +0 -0
  11. {xarpes-0.6.0 → xarpes-0.6.1}/dev_tools/Rmd2ipynb.py +0 -0
  12. {xarpes-0.6.0 → xarpes-0.6.1}/dev_tools/ipynb2Rmd2py.py +0 -0
  13. {xarpes-0.6.0 → xarpes-0.6.1}/doc/Makefile +0 -0
  14. {xarpes-0.6.0 → xarpes-0.6.1}/doc/README.md +0 -0
  15. {xarpes-0.6.0 → xarpes-0.6.1}/doc/_static/xarpes_small.svg +0 -0
  16. {xarpes-0.6.0 → xarpes-0.6.1}/doc/conf.py +0 -0
  17. {xarpes-0.6.0 → xarpes-0.6.1}/doc/index.rst +0 -0
  18. {xarpes-0.6.0 → xarpes-0.6.1}/doc/modules/bandmap.rst +0 -0
  19. {xarpes-0.6.0 → xarpes-0.6.1}/doc/modules/distributions.rst +0 -0
  20. {xarpes-0.6.0 → xarpes-0.6.1}/doc/modules/functions.rst +0 -0
  21. {xarpes-0.6.0 → xarpes-0.6.1}/doc/modules/mdcs.rst +0 -0
  22. {xarpes-0.6.0 → xarpes-0.6.1}/doc/modules/plotting.rst +0 -0
  23. {xarpes-0.6.0 → xarpes-0.6.1}/doc/modules/selfenergies.rst +0 -0
  24. {xarpes-0.6.0 → xarpes-0.6.1}/doc/modules/settings_parameters.rst +0 -0
  25. {xarpes-0.6.0 → xarpes-0.6.1}/doc/modules/settings_plots.rst +0 -0
  26. {xarpes-0.6.0 → xarpes-0.6.1}/doc/notebooks/graphene.ipynb +0 -0
  27. {xarpes-0.6.0 → xarpes-0.6.1}/doc/notebooks/srtio3.ipynb +0 -0
  28. {xarpes-0.6.0 → xarpes-0.6.1}/doc/requirements.txt +0 -0
  29. {xarpes-0.6.0 → xarpes-0.6.1}/examples/graphene/data_sets/graphene_152.ibw +0 -0
  30. {xarpes-0.6.0 → xarpes-0.6.1}/examples/graphene/data_sets/graphene_152_angles.npy +0 -0
  31. {xarpes-0.6.0 → xarpes-0.6.1}/examples/graphene/data_sets/graphene_152_ekin.npy +0 -0
  32. {xarpes-0.6.0 → xarpes-0.6.1}/examples/graphene/data_sets/graphene_152_intensities.npy +0 -0
  33. {xarpes-0.6.0 → xarpes-0.6.1}/examples/graphene/graphene.Rmd +0 -0
  34. {xarpes-0.6.0 → xarpes-0.6.1}/examples/graphene/graphene.py +0 -0
  35. {xarpes-0.6.0 → xarpes-0.6.1}/examples/srtio3/data_sets/STO_2_0010STO_2_.ibw +0 -0
  36. {xarpes-0.6.0 → xarpes-0.6.1}/examples/srtio3/data_sets/STO_2_0010STO_2_angles.npy +0 -0
  37. {xarpes-0.6.0 → xarpes-0.6.1}/examples/srtio3/data_sets/STO_2_0010STO_2_ekin.npy +0 -0
  38. {xarpes-0.6.0 → xarpes-0.6.1}/examples/srtio3/data_sets/STO_2_0010STO_2_intensities.npy +0 -0
  39. {xarpes-0.6.0 → xarpes-0.6.1}/examples/srtio3/srtio3.Rmd +0 -0
  40. {xarpes-0.6.0 → xarpes-0.6.1}/examples/srtio3/srtio3.py +0 -0
  41. {xarpes-0.6.0 → xarpes-0.6.1}/examples/verification/data_sets/verification_angles.npy +0 -0
  42. {xarpes-0.6.0 → xarpes-0.6.1}/examples/verification/data_sets/verification_intensities.npy +0 -0
  43. {xarpes-0.6.0 → xarpes-0.6.1}/examples/verification/data_sets/verification_kinergies.npy +0 -0
  44. {xarpes-0.6.0 → xarpes-0.6.1}/examples/verification/verification.Rmd +0 -0
  45. {xarpes-0.6.0 → xarpes-0.6.1}/examples/verification/verification.py +0 -0
  46. {xarpes-0.6.0 → xarpes-0.6.1}/logo/Makefile +0 -0
  47. {xarpes-0.6.0 → xarpes-0.6.1}/logo/exubi.svg +0 -0
  48. {xarpes-0.6.0 → xarpes-0.6.1}/logo/xarpes.svg +0 -0
  49. {xarpes-0.6.0 → xarpes-0.6.1}/pyproject.toml +0 -0
  50. {xarpes-0.6.0 → xarpes-0.6.1}/xarpes/constants.py +0 -0
  51. {xarpes-0.6.0 → xarpes-0.6.1}/xarpes/distributions.py +0 -0
  52. {xarpes-0.6.0 → xarpes-0.6.1}/xarpes/mdcs.py +0 -0
  53. {xarpes-0.6.0 → xarpes-0.6.1}/xarpes/plotting.py +0 -0
  54. {xarpes-0.6.0 → xarpes-0.6.1}/xarpes/settings_parameters.py +0 -0
  55. {xarpes-0.6.0 → xarpes-0.6.1}/xarpes/settings_plots.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: xarpes
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: Extraction from angle resolved photoemission spectra
5
5
  Author: xARPES Developers
6
6
  Requires-Python: >=3.7.0
@@ -15,7 +15,7 @@
15
15
  },
16
16
  {
17
17
  "cell_type": "code",
18
- "execution_count": 10,
18
+ "execution_count": 4,
19
19
  "id": "cae844d7",
20
20
  "metadata": {},
21
21
  "outputs": [
@@ -44,7 +44,7 @@
44
44
  },
45
45
  {
46
46
  "cell_type": "code",
47
- "execution_count": 11,
47
+ "execution_count": 5,
48
48
  "id": "043a5793",
49
49
  "metadata": {},
50
50
  "outputs": [],
@@ -57,7 +57,7 @@
57
57
  },
58
58
  {
59
59
  "cell_type": "code",
60
- "execution_count": 12,
60
+ "execution_count": null,
61
61
  "id": "f701b78c",
62
62
  "metadata": {},
63
63
  "outputs": [],
@@ -1,4 +1,4 @@
1
- __version__ = "0.6.0"
1
+ __version__ = "0.6.1"
2
2
 
3
3
  from importlib import import_module
4
4
 
@@ -14,7 +14,7 @@
14
14
  import numpy as np
15
15
  from igor2 import binarywave
16
16
  from .plotting import get_ax_fig_plt, add_fig_kwargs
17
- from .functions import fit_leastsq, extend_function
17
+ from .functions import fit_least_squares, extend_function
18
18
  from .distributions import FermiDirac, Linear
19
19
  from .constants import PREF
20
20
 
@@ -746,9 +746,10 @@ class BandMap:
746
746
 
747
747
  extra_args = (self.temperature,)
748
748
 
749
- popt, pcov = fit_leastsq(
750
- parameters, energy_range, integrated_intensity, fdir_initial,
751
- self.energy_resolution, None, *extra_args)
749
+ popt, pcov, success = fit_least_squares(
750
+ p0=parameters, xdata=energy_range, ydata=integrated_intensity,
751
+ function=fdir_initial, resolution=self.energy_resolution,
752
+ yerr=None, bounds=None, extra_args=extra_args)
752
753
 
753
754
  # Update hnuminPhi; automatically sets self.enel
754
755
  self.hnuminPhi = popt[0]
@@ -846,9 +847,10 @@ class BandMap:
846
847
  for indx in range(angle_max_index - angle_min_index + 1):
847
848
  edge = Intensities[:, indx]
848
849
 
849
- parameters, pcov = fit_leastsq(
850
- parameters, energy_range, edge, fdir_initial,
851
- self.energy_resolution, None, *extra_args)
850
+ parameters, pcov, success = fit_least_squares(
851
+ p0=parameters, xdata=energy_range, ydata=edge,
852
+ function=fdir_initial, resolution=self.energy_resolution,
853
+ yerr=None, bounds=None, extra_args=extra_args)
852
854
 
853
855
  nmps[indx] = parameters[0]
854
856
  stds[indx] = np.sqrt(np.diag(pcov)[0])
@@ -861,8 +863,9 @@ class BandMap:
861
863
 
862
864
  lin_fun = Linear(offset_guess, slope_guess, 'Linear')
863
865
 
864
- popt, pcov = fit_leastsq(parameters, angle_range, nmps, lin_fun, None,
865
- stds)
866
+ popt, pcov, success = fit_least_squares(p0=parameters, xdata=angle_range,
867
+ ydata=nmps, function=lin_fun, resolution=None,
868
+ yerr=stds, bounds=None)
866
869
 
867
870
  linsp = lin_fun(angle_range, popt[0], popt[1])
868
871
 
@@ -148,8 +148,8 @@ def extend_function(abscissa_range, abscissa_resolution):
148
148
  return extend, step, numb
149
149
 
150
150
 
151
- def error_function(p, xdata, ydata, function, resolution, yerr, extra_args):
152
- r"""The error function used inside the fit_leastsq function.
151
+ def error_function(p, xdata, ydata, function, resolution, yerr, *extra_args):
152
+ r"""The error function used inside the fit_least_squares function.
153
153
 
154
154
  Parameters
155
155
  ----------
@@ -187,57 +187,54 @@ def error_function(p, xdata, ydata, function, resolution, yerr, extra_args):
187
187
  return residual
188
188
 
189
189
 
190
- def fit_leastsq(p0, xdata, ydata, function, resolution=None,
191
- yerr=None, *extra_args):
192
- r"""Wrapper around scipy.optimize.leastsq.
190
+ def fit_least_squares(p0, xdata, ydata, function, resolution=None, yerr=None,
191
+ bounds=None, extra_args=None):
192
+ r"""Least-squares fit using `scipy.optimize.least_squares`.
193
193
 
194
- Parameters
195
- ----------
196
- p0 : ndarray
197
- Initial guess for parameters to be optimized.
198
- xdata : ndarray
199
- Abscissa values the function is evaluated on.
200
- ydata : ndarray
201
- Measured values to compare to.
202
- function : callable
203
- Function or class with __call__ method to evaluate.
204
- resolution : float or None, optional
205
- Convolution resolution (sigma), if applicable.
206
- yerr : ndarray or None, optional
207
- Standard deviations of ydata. Defaults to ones if None.
208
- extra_args : tuple
209
- Additional arguments passed to the function.
194
+ Default behavior is Levenberg–Marquardt (`method="lm"`) when unbounded.
195
+ If `bounds` is provided, switches to trust-region reflective (`"trf"`).
210
196
 
211
- Returns
212
- -------
213
- pfit_leastsq : ndarray
214
- Optimized parameters.
215
- pcov : ndarray or float
216
- Scaled covariance matrix of the optimized parameters.
217
- If the covariance could not be estimated, returns np.inf.
197
+ Returns (pfit, pcov, success) in the same style as the old `leastsq`
198
+ wrapper, with an additional boolean `success` from SciPy.
218
199
  """
219
- from scipy.optimize import leastsq
200
+ from scipy.optimize import least_squares
220
201
 
221
202
  if yerr is None:
222
203
  yerr = np.ones_like(ydata)
223
204
 
224
- pfit, pcov, infodict, errmsg, success = leastsq(
225
- error_function,
226
- p0,
227
- args=(xdata, ydata, function, resolution, yerr, extra_args),
228
- full_output=1
229
- )
205
+ if extra_args is None:
206
+ extra_args = ()
207
+
208
+ def _residuals(p):
209
+ return error_function(
210
+ p, xdata, ydata, function, resolution, yerr, *extra_args
211
+ )
212
+
213
+ if bounds is None:
214
+ res = least_squares(_residuals, p0, method="lm")
215
+ else:
216
+ res = least_squares(_residuals, p0, method="trf", bounds=bounds)
217
+
218
+ pfit = res.x
219
+ success = bool(getattr(res, "success", False))
220
+
221
+ m = len(ydata)
222
+ n = pfit.size
230
223
 
231
- if (len(ydata) > len(p0)) and pcov is not None:
232
- s_sq = (
233
- error_function(pfit, xdata, ydata, function, resolution,
234
- yerr, extra_args) ** 2
235
- ).sum() / (len(ydata) - len(p0))
236
- pcov *= s_sq
224
+ if (m > n) and (res.jac is not None) and res.jac.size:
225
+ resid = res.fun
226
+ s_sq = (resid ** 2).sum() / (m - n)
227
+
228
+ try:
229
+ jtj = res.jac.T @ res.jac
230
+ pcov = np.linalg.inv(jtj) * s_sq
231
+ except np.linalg.LinAlgError:
232
+ pcov = np.inf
237
233
  else:
238
234
  pcov = np.inf
239
235
 
240
- return pfit, pcov
236
+ return pfit, pcov, success
237
+
241
238
 
242
239
 
243
240
  def download_examples():
@@ -34,7 +34,7 @@ class SelfEnergy:
34
34
  else:
35
35
  raise ValueError(
36
36
  "`properties` must be a dict or a single dict in a list."
37
- )
37
+ )
38
38
 
39
39
  # single source of truth for all params (+ their *_sigma)
40
40
  self._properties = dict(properties or {})
@@ -84,12 +84,19 @@ class SelfEnergy:
84
84
  self._imag = None
85
85
  self._imag_sigma = None
86
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
+
87
94
  def _check_mass_velocity_exclusivity(self):
88
95
  """Ensure that fermi_velocity and bare_mass are not both set."""
89
96
  if (self._fermi_velocity is not None) and (self._bare_mass is not None):
90
97
  raise ValueError(
91
- "Cannot set both `fermi_velocity` and `bare_mass`: choose one "
92
- "physical parametrization (SpectralLinear or SpectralQuadratic)."
98
+ "Cannot set both `fermi_velocity` and `bare_mass`: choose one "
99
+ "physical parametrization (SpectralLinear or SpectralQuadratic)."
93
100
  )
94
101
 
95
102
  # ---------------- core read-only axes ----------------
@@ -289,8 +296,10 @@ class SelfEnergy:
289
296
  self._peak_positions = ((-1.0 if self._side == "left"
290
297
  else 1.0) * kpar_mag)
291
298
  else:
292
- self._peak_positions = (np.sqrt(self._ekin_range / PREF)
293
- * 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
+ )
294
303
  return self._peak_positions
295
304
 
296
305
 
@@ -346,6 +355,31 @@ class SelfEnergy:
346
355
  return None
347
356
  self._real_sigma = self._compute_real_sigma()
348
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
349
383
 
350
384
 
351
385
  def _compute_imag(self, fermi_velocity=None, bare_mass=None):
@@ -553,15 +587,37 @@ class SelfEnergy:
553
587
  imag_label = rf"$-\Sigma_{{\mathrm{{{safe_label}}}}}''(E)$"
554
588
 
555
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
556
605
 
557
606
  @add_fig_kwargs
558
- def plot_real(self, ax=None, **kwargs):
607
+ def plot_real(self, ax=None, scale="eV", resolution_range="absent", **kwargs):
559
608
  r"""Plot the real part Σ' of the self-energy as a function of E-μ.
560
609
 
561
610
  Parameters
562
611
  ----------
563
612
  ax : Matplotlib-Axes or None
564
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`.
565
621
  **kwargs :
566
622
  Additional keyword arguments passed to ``ax.errorbar``.
567
623
 
@@ -574,10 +630,31 @@ class SelfEnergy:
574
630
 
575
631
  ax, fig, plt = get_ax_fig_plt(ax=ax)
576
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
+
577
640
  x = self.enel_range
578
641
  y = self.real
579
642
  y_sigma = self.real_sigma
580
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
+
581
658
  real_label, _ = self._se_legend_labels()
582
659
  kwargs.setdefault("label", real_label)
583
660
 
@@ -587,23 +664,33 @@ class SelfEnergy:
587
664
  "Warning: some Σ'(E) uncertainty values are missing. "
588
665
  "Error bars omitted at those energies."
589
666
  )
590
- kwargs.setdefault("yerr", xprs.sigma_confidence * y_sigma)
667
+ kwargs.setdefault("yerr", xprs.sigma_confidence * factor * y_sigma)
591
668
 
592
669
  ax.errorbar(x, y, **kwargs)
593
- ax.set_xlabel(r"$E-\mu$ (eV)")
594
- 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})")
595
674
  ax.legend()
596
675
 
597
676
  return fig
598
677
 
678
+
599
679
  @add_fig_kwargs
600
- def plot_imag(self, ax=None, **kwargs):
680
+ def plot_imag(self, ax=None, scale="eV", resolution_range="absent", **kwargs):
601
681
  r"""Plot the imaginary part -Σ'' of the self-energy vs. E-μ.
602
682
 
603
683
  Parameters
604
684
  ----------
605
685
  ax : Matplotlib-Axes or None
606
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`.
607
694
  **kwargs :
608
695
  Additional keyword arguments passed to ``ax.errorbar``.
609
696
 
@@ -616,10 +703,31 @@ class SelfEnergy:
616
703
 
617
704
  ax, fig, plt = get_ax_fig_plt(ax=ax)
618
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
+
619
713
  x = self.enel_range
620
714
  y = self.imag
621
715
  y_sigma = self.imag_sigma
622
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
+
623
731
  _, imag_label = self._se_legend_labels()
624
732
  kwargs.setdefault("label", imag_label)
625
733
 
@@ -629,31 +737,76 @@ class SelfEnergy:
629
737
  "Warning: some -Σ''(E) uncertainty values are missing. "
630
738
  "Error bars omitted at those energies."
631
739
  )
632
- kwargs.setdefault("yerr", xprs.sigma_confidence * y_sigma)
740
+ kwargs.setdefault("yerr", xprs.sigma_confidence * factor * y_sigma)
633
741
 
634
742
  ax.errorbar(x, y, **kwargs)
635
- ax.set_xlabel(r"$E-\mu$ (eV)")
636
- 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})")
637
747
  ax.legend()
638
748
 
639
749
  return fig
640
750
 
751
+
641
752
  @add_fig_kwargs
642
- def plot_both(self, ax=None, **kwargs):
643
- 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
+ """
644
770
  from . import settings_parameters as xprs
645
771
 
646
772
  ax, fig, plt = get_ax_fig_plt(ax=ax)
647
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
+
648
781
  x = self.enel_range
649
782
  real = self.real
650
783
  imag = self.imag
651
784
  real_sigma = self.real_sigma
652
785
  imag_sigma = self.imag_sigma
653
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
+
654
808
  real_label, imag_label = self._se_legend_labels()
655
809
 
656
- # --- plot Σ'
657
810
  kw_real = dict(kwargs)
658
811
  if real_sigma is not None:
659
812
  if np.isnan(real_sigma).any():
@@ -661,11 +814,10 @@ class SelfEnergy:
661
814
  "Warning: some Σ'(E) uncertainty values are missing. "
662
815
  "Error bars omitted at those energies."
663
816
  )
664
- kw_real.setdefault("yerr", xprs.sigma_confidence * real_sigma)
817
+ kw_real.setdefault("yerr", xprs.sigma_confidence * factor * real_sigma)
665
818
  kw_real.setdefault("label", real_label)
666
819
  ax.errorbar(x, real, **kw_real)
667
820
 
668
- # --- plot -Σ''
669
821
  kw_imag = dict(kwargs)
670
822
  if imag_sigma is not None:
671
823
  if np.isnan(imag_sigma).any():
@@ -673,27 +825,187 @@ class SelfEnergy:
673
825
  "Warning: some -Σ''(E) uncertainty values are missing. "
674
826
  "Error bars omitted at those energies."
675
827
  )
676
- kw_imag.setdefault("yerr", xprs.sigma_confidence * imag_sigma)
828
+ kw_imag.setdefault("yerr", xprs.sigma_confidence * factor * imag_sigma)
677
829
  kw_imag.setdefault("label", imag_label)
678
830
  ax.errorbar(x, imag, **kw_imag)
679
831
 
680
- ax.set_xlabel(r"$E-\mu$ (eV)")
681
- 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})")
835
+ ax.legend()
836
+
837
+ return fig
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)~(-)$")
682
947
  ax.legend()
683
948
 
684
949
  return fig
685
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
+
686
978
 
979
+ @add_fig_kwargs
687
980
  def extract_a2f(self, *, omega_min, omega_max, omega_num, omega_I, omega_M,
688
- mem=None, **mem_kwargs):
981
+ mem=None, ax=None, **mem_kwargs):
689
982
  r"""
690
983
  Extract Eliashberg function α²F(ω) from the self-energy. While working
691
984
  with band maps and MDCs is more intuitive in eV, the self-energy
692
985
  extraction is performed in eV.
693
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.
694
1003
  """
695
1004
  from . import settings_parameters as xprs
696
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
+
697
1009
  mem_cfg = self._merge_defaults(xprs.mem_defaults, mem, mem_kwargs)
698
1010
 
699
1011
  method = mem_cfg["method"]
@@ -724,22 +1036,24 @@ class SelfEnergy:
724
1036
  f_chi_squared = 2.5 if parts == "both" else 2.0
725
1037
  else:
726
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
+ )
727
1044
 
728
1045
  h_n = mem_cfg.get("h_n", None)
729
1046
  if h_n is None:
730
1047
  raise ValueError(
731
- "`h_n` must be provided explicitly (h_n=... or mem={'h_n': ...}). "
732
- "No default is assumed."
1048
+ "`optimisation_parameters` must include 'h_n' for cost evaluation."
733
1049
  )
734
1050
 
735
1051
  from . import (create_model_function, create_kernel_function,
736
1052
  singular_value_decomposition, MEM_core)
737
1053
 
738
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)
739
1056
 
740
- model = create_model_function(omega_range, omega_I, omega_M, omega_S,
741
- h_n)
742
-
743
1057
  delta_omega = (omega_max - omega_min) / (omega_num - 1)
744
1058
  model_in = model * delta_omega
745
1059
 
@@ -763,7 +1077,6 @@ class SelfEnergy:
763
1077
 
764
1078
  energies_eV_masked = energies_eV[mE]
765
1079
  energies = energies_eV_masked * KILO
766
-
767
1080
  k_BT = K_B * self.temperature * KILO
768
1081
 
769
1082
  kernel = create_kernel_function(energies, omega_range, k_BT)
@@ -771,16 +1084,13 @@ class SelfEnergy:
771
1084
  if lambda_el:
772
1085
  if W is None:
773
1086
  if self._class == "SpectralQuadratic":
774
- W = (
775
- PREF * self._fermi_wavevector**2 / self._bare_mass
776
- ) * KILO
1087
+ W = (PREF * self._fermi_wavevector**2 / self._bare_mass) * KILO
777
1088
  else:
778
1089
  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."
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."
782
1093
  )
783
-
784
1094
 
785
1095
  energies_el = energies_eV_masked * KILO
786
1096
  real_el, imag_el = self._el_el_self_energy(
@@ -798,14 +1108,12 @@ class SelfEnergy:
798
1108
  dvec = np.concatenate((real, imag))
799
1109
  wvec = np.concatenate((real_sigma**(-2), imag_sigma**(-2)))
800
1110
  H = np.concatenate((np.real(kernel), -np.imag(kernel)))
801
-
802
1111
  elif parts == "real":
803
1112
  real = self.real[mE] * KILO - real_el
804
1113
  real_sigma = self.real_sigma[mE] * KILO
805
1114
  dvec = real
806
1115
  wvec = real_sigma**(-2)
807
1116
  H = np.real(kernel)
808
-
809
1117
  else: # parts == "imag"
810
1118
  imag = self.imag[mE] * KILO - impurity_magnitude - imag_el
811
1119
  imag_sigma = self.imag_sigma[mE] * KILO
@@ -816,14 +1124,55 @@ class SelfEnergy:
816
1124
  V_Sigma, U, uvec = singular_value_decomposition(H, sigma_svd)
817
1125
 
818
1126
  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
- )
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
+ )
824
1165
 
1166
+ # From here on, we know spectrum_in and alpha_select exist
825
1167
  spectrum = spectrum_in * omega_num / omega_max
826
- return spectrum, model
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
827
1176
 
828
1177
 
829
1178
  def bayesian_loop(self, *, omega_min, omega_max, omega_num, omega_I,
@@ -1193,12 +1542,15 @@ class SelfEnergy:
1193
1542
  print(" | ".join(msg))
1194
1543
 
1195
1544
  return cost_f
1196
-
1545
+
1197
1546
  class TerminationCallback:
1198
- def __init__(self, tole, converge_iters, min_steps_for_regression):
1547
+ def __init__(self, tole, converge_iters,
1548
+ min_steps_for_regression):
1199
1549
  self.tole = None if tole is None else float(tole)
1200
1550
  self.converge_iters = int(converge_iters)
1201
- self.min_steps_for_regression = int(min_steps_for_regression)
1551
+ self.min_steps_for_regression = int(
1552
+ min_steps_for_regression
1553
+ )
1202
1554
  self.iter_count = 0
1203
1555
  self.call_count = 0
1204
1556
 
@@ -1212,36 +1564,41 @@ class SelfEnergy:
1212
1564
  if current is None:
1213
1565
  return
1214
1566
 
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
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
1220
1573
 
1221
1574
  if self.iter_count >= self.converge_iters:
1222
1575
  raise ConvergenceException(
1223
- f"Converged: |cost-best| < {self.tole:g} for "
1576
+ "Converged: |cost-best| < "
1577
+ f"{self.tole:g} for "
1224
1578
  f"{self.converge_iters} iterations."
1225
1579
  )
1226
-
1580
+
1227
1581
  if self.call_count < self.min_steps_for_regression:
1228
1582
  return
1229
1583
 
1230
- current = float(current)
1231
- init = initial_cost["cost"]
1232
- if init is None:
1584
+ init_cost = initial_cost["cost"]
1585
+ if init_cost is None:
1233
1586
  return
1234
1587
 
1235
- best_cost = float(best["cost"])
1588
+ current = float(current)
1589
+ init_cost = float(init_cost)
1590
+
1236
1591
  if not np.isfinite(best_cost):
1237
1592
  return
1238
1593
 
1239
- if abs(current - init) * relative_best < abs(current - best_cost):
1594
+ if (
1595
+ abs(current - init_cost) * relative_best
1596
+ < abs(current - best_cost)
1597
+ ):
1240
1598
  raise RegressionException(
1241
1599
  "Regression toward initial guess detected."
1242
1600
  )
1243
1601
 
1244
-
1245
1602
  callback = TerminationCallback(
1246
1603
  tole=tole,
1247
1604
  converge_iters=converge_iters,
@@ -1345,17 +1702,26 @@ class SelfEnergy:
1345
1702
  print("Optimised parameters:")
1346
1703
  print(args)
1347
1704
 
1348
- return cost, spectrum, model, alpha_select, params
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
1349
1711
 
1712
+ return spectrum, model, omega_range, alpha_select, cost, params
1350
1713
 
1351
1714
  @staticmethod
1352
1715
  def _merge_defaults(defaults, override_dict=None, override_kwargs=None):
1353
1716
  """Merge defaults with dict + kwargs overrides (kwargs win)."""
1354
1717
  cfg = dict(defaults)
1355
- if override_dict:
1718
+
1719
+ if override_dict is not None:
1356
1720
  cfg.update(dict(override_dict))
1357
- if override_kwargs:
1721
+
1722
+ if override_kwargs is not None:
1358
1723
  cfg.update({k: v for k, v in override_kwargs.items() if v is not None})
1724
+
1359
1725
  return cfg
1360
1726
 
1361
1727
  def _prepare_bare(self, fermi_velocity, fermi_wavevector, bare_mass):
@@ -1490,11 +1856,9 @@ class SelfEnergy:
1490
1856
  else:
1491
1857
  f_chi_squared = float(f_chi_squared)
1492
1858
 
1493
- h_n = mem_cfg.get("h_n", None)
1494
- if h_n is None:
1859
+ if d_guess <= 0.0:
1495
1860
  raise ValueError(
1496
- "`h_n` must be provided explicitly (h_n=... or mem={'h_n': ...}). "
1497
- "No default is assumed."
1861
+ "chi2kink requires d_guess > 0 to fix the logistic sign ambiguity."
1498
1862
  )
1499
1863
 
1500
1864
  if parts not in {"both", "real", "imag"}:
@@ -1622,7 +1986,8 @@ class SelfEnergy:
1622
1986
  dvec = imag_m
1623
1987
  wvec = imag_sig_m**(-2)
1624
1988
 
1625
- spectrum_in, alpha_select = self._chi2kink_a2f(
1989
+ (spectrum_in, alpha_select, fit_curve, guess_curve,
1990
+ chi2kink_result) = self._chi2kink_a2f(
1626
1991
  dvec, model_in, uvec, mu, wvec, V_Sigma, U, alpha_min, alpha_max,
1627
1992
  alpha_num, a_guess, b_guess, c_guess, d_guess, f_chi_squared,
1628
1993
  t_criterion, iter_max, MEM_core,
@@ -1657,26 +2022,44 @@ class SelfEnergy:
1657
2022
 
1658
2023
 
1659
2024
  @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.
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.
1665
2031
 
1666
- Returns
1667
- -------
1668
- spectrum_in : ndarray
1669
- Selected spectrum from MEM_core evaluated at the chi2kink alpha.
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.
1670
2052
  """
1671
- from . import (fit_leastsq, chi2kink_logistic)
2053
+ from . import fit_least_squares, chi2kink_logistic
1672
2054
 
1673
- alpha_range = np.logspace(alpha_min, alpha_max, alpha_num)
2055
+ alpha_range = np.logspace(alpha_min, alpha_max, int(alpha_num))
1674
2056
  chi_squared = np.empty_like(alpha_range, dtype=float)
1675
2057
 
1676
2058
  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
-
2059
+ spectrum_in, uvec = MEM_core(
2060
+ dvec, model_in, uvec, mu, alpha, wvec, V_Sigma, U,
2061
+ t_criterion, iter_max
2062
+ )
1680
2063
  T = V_Sigma @ (U.T @ spectrum_in)
1681
2064
  chi_squared[i] = wvec @ ((T - dvec) ** 2)
1682
2065
 
@@ -1689,18 +2072,79 @@ class SelfEnergy:
1689
2072
  log_chi_squared = np.log10(chi_squared)
1690
2073
 
1691
2074
  p0 = np.array([a_guess, b_guess, c_guess, d_guess], dtype=float)
1692
- pfit, pcov = fit_leastsq(
2075
+ pfit, pcov, lsq_success = fit_least_squares(
1693
2076
  p0, log_alpha, log_chi_squared, chi2kink_logistic
1694
2077
  )
1695
2078
 
1696
- cout = pfit[2]
1697
- dout = pfit[3]
1698
- alpha_select = 10 ** (cout - f_chi_squared / dout)
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)
1699
2084
 
1700
- spectrum_in, uvec = MEM_core(dvec, model_in, uvec, mu, alpha_select,
1701
- wvec, V_Sigma, U, t_criterion, iter_max)
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
+ }
1702
2146
 
1703
- return spectrum_in, alpha_select
2147
+ return spectrum_out, alpha_select, fit_curve, guess_curve, chi2kink_result
1704
2148
 
1705
2149
 
1706
2150
  @staticmethod
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes