From 1038d20735584a1883eef3378718de1fa41e35a5 Mon Sep 17 00:00:00 2001 From: smribet Date: Wed, 3 Sep 2025 16:59:48 -0700 Subject: [PATCH 001/136] starting spectroscopy class --- .../datastructures/dataset3dspectroscopy.py | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/quantem/core/datastructures/dataset3dspectroscopy.py diff --git a/src/quantem/core/datastructures/dataset3dspectroscopy.py b/src/quantem/core/datastructures/dataset3dspectroscopy.py new file mode 100644 index 00000000..6d4c05a1 --- /dev/null +++ b/src/quantem/core/datastructures/dataset3dspectroscopy.py @@ -0,0 +1,102 @@ +from typing import Any, Self + +import numpy as np +from numpy.typing import NDArray + +from quantem.core.datastructures.dataset3d import Dataset3d +from quantem.core.utils.validators import ensure_valid_array + + +class Dataset3dspectroscopy(Dataset3d): + """A 3D-STEM spectroscopy dataset class that inherits from Dataset3d. + + This class represents a scanning transmission electron microscopy (STEM) dataset, + where the data consists of a 3D array with dimensions (energy, scan_y, scan_x). + The first dimension represents the energy, while the latter + two dimensions represent real space sampling. + + """ + + def __init__( + self, + array: NDArray | Any, + name: str, + origin: NDArray | tuple | list | float | int, + sampling: NDArray | tuple | list | float | int, + units: list[str] | tuple | list, + signal_units: str = "arb. units", + _token: object | None = None, + ): + """Initialize a 3D-STEM spectroscopy dataset. + + Parameters + ---------- + array : NDArray | Any + The underlying 3D array data + name : str + A descriptive name for the dataset + origin : NDArray | tuple | list | float | int + The origin coordinates for each dimension + sampling : NDArray | tuple | list | float | int + The sampling rate/spacing for each dimension + units : list[str] | tuple | list + Units for each dimension + signal_units : str, optional + Units for the array values, by default "arb. units" + _token : object | None, optional + Token to prevent direct instantiation, by default None + """ + super().__init__( + array=array, + name=name, + origin=origin, + sampling=sampling, + units=units, + signal_units=signal_units, + _token=_token, + ) + self._virtual_images = {} + + @classmethod + def from_array( + cls, + array: NDArray | Any, + name: str | None = None, + origin: NDArray | tuple | list | float | int | None = None, + sampling: NDArray | tuple | list | float | int | None = None, + units: list[str] | tuple | list | None = None, + signal_units: str = "arb. units", + ) -> Self: + """ + Create a new Dataset3dspectroscopy from an array. + + Parameters + ---------- + array : NDArray | Any + The underlying 3D array data + name : str | None, optional + A descriptive name for the dataset. If None, defaults to "4D-STEM dataset" + origin : NDArray | tuple | list | float | int | None, optional + The origin coordinates for each dimension. If None, defaults to zeros + sampling : NDArray | tuple | list | float | int | None, optional + The sampling rate/spacing for each dimension. If None, defaults to ones + units : list[str] | tuple | list | None, optional + Units for each dimension. If None, defaults to ["pixels"] * 4 + signal_units : str, optional + Units for the array values, by default "arb. units" + + Returns + ------- + Dataset3dspectroscopy + A new Dataset3dspectroscopy instance + """ + array = ensure_valid_array(array, ndim=4) + return cls( + array=array, + name=name if name is not None else "3D-STEM dataset", + origin=origin if origin is not None else np.zeros(3), + sampling=sampling if sampling is not None else np.ones(3), + units=units if units is not None else ["pixels"] * 3, + signal_units=signal_units, + _token=cls._token, + ) From 9a67b3ae1f5d00c81fa5c9a5051551889cdab624 Mon Sep 17 00:00:00 2001 From: smribet Date: Thu, 18 Sep 2025 15:59:15 -0700 Subject: [PATCH 002/136] moving dataset3dspectroscopy --- .../datastructures => spectroscopy}/dataset3dspectroscopy.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/quantem/{core/datastructures => spectroscopy}/dataset3dspectroscopy.py (100%) diff --git a/src/quantem/core/datastructures/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py similarity index 100% rename from src/quantem/core/datastructures/dataset3dspectroscopy.py rename to src/quantem/spectroscopy/dataset3dspectroscopy.py From cb2f678f49d2e0cc79483f95b39bdcdc5a017f6f Mon Sep 17 00:00:00 2001 From: smribet Date: Fri, 19 Sep 2025 14:00:10 -0700 Subject: [PATCH 003/136] updating inits for file loading --- src/quantem/__init__.py | 1 + src/quantem/core/io/__init__.py | 2 +- src/quantem/core/io/file_readers.py | 46 +++++++++++++++++++ src/quantem/spectroscopy/__init__.py | 3 ++ .../spectroscopy/dataset3dspectroscopy.py | 6 ++- 5 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/quantem/__init__.py b/src/quantem/__init__.py index e63a4a41..90a75a1e 100644 --- a/src/quantem/__init__.py +++ b/src/quantem/__init__.py @@ -3,3 +3,4 @@ from quantem.core import visualization as visualization from quantem import imaging as imaging +from quantem import spectroscopy as spectroscopy diff --git a/src/quantem/core/io/__init__.py b/src/quantem/core/io/__init__.py index 2780eae4..de0df5f8 100644 --- a/src/quantem/core/io/__init__.py +++ b/src/quantem/core/io/__init__.py @@ -1,7 +1,7 @@ from quantem.core.io.file_readers import read_2d as read_2d from quantem.core.io.file_readers import read_4dstem as read_4dstem from quantem.core.io.file_readers import ( - read_emdfile_to_4dstem as read_emdfile_to_4dstem, + read_3d_spectroscopy as read_3d_spectroscopy, ) from quantem.core.io.serialize import AutoSerialize as AutoSerialize from quantem.core.io.serialize import load as load diff --git a/src/quantem/core/io/file_readers.py b/src/quantem/core/io/file_readers.py index 8b7b7194..96b330c5 100644 --- a/src/quantem/core/io/file_readers.py +++ b/src/quantem/core/io/file_readers.py @@ -7,6 +7,9 @@ from quantem.core.datastructures import Dataset2d as Dataset2d from quantem.core.datastructures import Dataset3d as Dataset3d from quantem.core.datastructures import Dataset4dstem as Dataset4dstem +from quantem.spectroscopy.dataset3dspectroscopy import ( + Dataset3dspectroscopy as Dataset3dspectroscopy, +) def read_4dstem( @@ -55,6 +58,49 @@ def read_4dstem( return dataset +def read_3d_spectroscopy( + file_path: str, + file_type: str, +) -> Dataset3dspectroscopy: + """ + File reader for 3D spectroscopy data data + + Parameters + ---------- + file_path: str + Path to data + file_type: str + The type of file reader needed. See rosettasciio for supported formats + https://hyperspy.org/rosettasciio/supported_formats/index.html + + Returns + -------- + Dataset3dspectroscopy + """ + file_reader = importlib.import_module(f"rsciio.{file_type}").file_reader # type: ignore + imported_data = file_reader(file_path)[0] + dataset = Dataset3dspectroscopy.from_array( + array=imported_data["data"], + sampling=[ + imported_data["axes"][0]["scale"], + imported_data["axes"][1]["scale"], + imported_data["axes"][2]["scale"], + ], + origin=[ + imported_data["axes"][0]["offset"], + imported_data["axes"][1]["offset"], + imported_data["axes"][2]["offset"], + ], + units=[ + imported_data["axes"][0]["units"], + imported_data["axes"][1]["units"], + imported_data["axes"][2]["units"], + ], + ) + + return dataset + + def read_2d( file_path: str, file_type: str | None = None, diff --git a/src/quantem/spectroscopy/__init__.py b/src/quantem/spectroscopy/__init__.py index e69de29b..7b1f9808 100644 --- a/src/quantem/spectroscopy/__init__.py +++ b/src/quantem/spectroscopy/__init__.py @@ -0,0 +1,3 @@ +from quantem.spectroscopy.dataset3dspectroscopy import ( + Dataset3dspectroscopy as Dataset3dspectroscopy, +) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 6d4c05a1..b71db976 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -90,7 +90,7 @@ def from_array( Dataset3dspectroscopy A new Dataset3dspectroscopy instance """ - array = ensure_valid_array(array, ndim=4) + array = ensure_valid_array(array, ndim=3) return cls( array=array, name=name if name is not None else "3D-STEM dataset", @@ -100,3 +100,7 @@ def from_array( signal_units=signal_units, _token=cls._token, ) + + ## PCA + ## imaging + ## specturm picking From f87fb21f39ebfa4edd37cc64fc81031689679c5a Mon Sep 17 00:00:00 2001 From: smribet Date: Fri, 19 Sep 2025 14:46:40 -0700 Subject: [PATCH 004/136] Swapping to (E,x,y) at least for now. We may want validators. --- src/quantem/core/io/file_readers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/quantem/core/io/file_readers.py b/src/quantem/core/io/file_readers.py index 96b330c5..2941554a 100644 --- a/src/quantem/core/io/file_readers.py +++ b/src/quantem/core/io/file_readers.py @@ -80,21 +80,21 @@ def read_3d_spectroscopy( file_reader = importlib.import_module(f"rsciio.{file_type}").file_reader # type: ignore imported_data = file_reader(file_path)[0] dataset = Dataset3dspectroscopy.from_array( - array=imported_data["data"], + array=imported_data["data"].transpose((2, 0, 1)), sampling=[ + imported_data["axes"][2]["scale"], imported_data["axes"][0]["scale"], imported_data["axes"][1]["scale"], - imported_data["axes"][2]["scale"], ], origin=[ + imported_data["axes"][2]["offset"], imported_data["axes"][0]["offset"], imported_data["axes"][1]["offset"], - imported_data["axes"][2]["offset"], ], units=[ + imported_data["axes"][2]["units"], imported_data["axes"][0]["units"], imported_data["axes"][1]["units"], - imported_data["axes"][2]["units"], ], ) From 51fe26b171d2f5ad30a33ca2773d4fb1603ece2b Mon Sep 17 00:00:00 2001 From: smribet Date: Fri, 19 Sep 2025 15:08:07 -0700 Subject: [PATCH 005/136] adding EDS and EELS --- src/quantem/core/io/file_readers.py | 74 ++++++++++++------- src/quantem/spectroscopy/__init__.py | 7 ++ src/quantem/spectroscopy/dataset3deds.py | 56 ++++++++++++++ src/quantem/spectroscopy/dataset3deels.py | 56 ++++++++++++++ .../spectroscopy/dataset3dspectroscopy.py | 2 +- 5 files changed, 169 insertions(+), 26 deletions(-) create mode 100644 src/quantem/spectroscopy/dataset3deds.py create mode 100644 src/quantem/spectroscopy/dataset3deels.py diff --git a/src/quantem/core/io/file_readers.py b/src/quantem/core/io/file_readers.py index 2941554a..efabfe09 100644 --- a/src/quantem/core/io/file_readers.py +++ b/src/quantem/core/io/file_readers.py @@ -7,9 +7,13 @@ from quantem.core.datastructures import Dataset2d as Dataset2d from quantem.core.datastructures import Dataset3d as Dataset3d from quantem.core.datastructures import Dataset4dstem as Dataset4dstem -from quantem.spectroscopy.dataset3dspectroscopy import ( - Dataset3dspectroscopy as Dataset3dspectroscopy, +from quantem.spectroscopy import ( + Dataset3deds as Dataset3deds, ) +from quantem.spectroscopy import ( + Dataset3deels as Dataset3deels, +) +from quantem.spectroscopy import Dataset3dspectroscopy as Dataset3dspectroscopy def read_4dstem( @@ -58,10 +62,7 @@ def read_4dstem( return dataset -def read_3d_spectroscopy( - file_path: str, - file_type: str, -) -> Dataset3dspectroscopy: +def read_3d_spectroscopy(file_path: str, file_type: str, data_type: str) -> Dataset3dspectroscopy: """ File reader for 3D spectroscopy data data @@ -72,31 +73,54 @@ def read_3d_spectroscopy( file_type: str The type of file reader needed. See rosettasciio for supported formats https://hyperspy.org/rosettasciio/supported_formats/index.html - + data_type: str + type of spectroscopy data 'EELS' or 'EDS' Returns -------- Dataset3dspectroscopy """ file_reader = importlib.import_module(f"rsciio.{file_type}").file_reader # type: ignore imported_data = file_reader(file_path)[0] - dataset = Dataset3dspectroscopy.from_array( - array=imported_data["data"].transpose((2, 0, 1)), - sampling=[ - imported_data["axes"][2]["scale"], - imported_data["axes"][0]["scale"], - imported_data["axes"][1]["scale"], - ], - origin=[ - imported_data["axes"][2]["offset"], - imported_data["axes"][0]["offset"], - imported_data["axes"][1]["offset"], - ], - units=[ - imported_data["axes"][2]["units"], - imported_data["axes"][0]["units"], - imported_data["axes"][1]["units"], - ], - ) + if data_type == "EELS": + dataset = Dataset3deels.from_array( + array=imported_data["data"].transpose((2, 0, 1)), + sampling=[ + imported_data["axes"][2]["scale"], + imported_data["axes"][0]["scale"], + imported_data["axes"][1]["scale"], + ], + origin=[ + imported_data["axes"][2]["offset"], + imported_data["axes"][0]["offset"], + imported_data["axes"][1]["offset"], + ], + units=[ + imported_data["axes"][2]["units"], + imported_data["axes"][0]["units"], + imported_data["axes"][1]["units"], + ], + ) + elif data_type == "EDS": + dataset = Dataset3deds.from_array( + array=imported_data["data"].transpose((2, 0, 1)), + sampling=[ + imported_data["axes"][2]["scale"], + imported_data["axes"][0]["scale"], + imported_data["axes"][1]["scale"], + ], + origin=[ + imported_data["axes"][2]["offset"], + imported_data["axes"][0]["offset"], + imported_data["axes"][1]["offset"], + ], + units=[ + imported_data["axes"][2]["units"], + imported_data["axes"][0]["units"], + imported_data["axes"][1]["units"], + ], + ) + else: + raise ValueError(f"`data_type` must be `EDS` or `EELS` not `{data_type}`") return dataset diff --git a/src/quantem/spectroscopy/__init__.py b/src/quantem/spectroscopy/__init__.py index 7b1f9808..9b87e017 100644 --- a/src/quantem/spectroscopy/__init__.py +++ b/src/quantem/spectroscopy/__init__.py @@ -1,3 +1,10 @@ from quantem.spectroscopy.dataset3dspectroscopy import ( Dataset3dspectroscopy as Dataset3dspectroscopy, ) +from quantem.spectroscopy.dataset3deels import ( + Dataset3deels as Dataset3deels, +) + +from quantem.spectroscopy.dataset3deds import ( + Dataset3deds as Dataset3deds, +) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py new file mode 100644 index 00000000..b5b47b69 --- /dev/null +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -0,0 +1,56 @@ +from typing import Any + +from numpy.typing import NDArray + +from quantem.spectroscopy import Dataset3dspectroscopy + + +class Dataset3deds(Dataset3dspectroscopy): + """An EDS dataset class that inherits from Dataset3dspectroscopy. + + This class represents a scanning transmission electron microscopy (STEM) dataset, + where the data consists of a 3D array with dimensions (energy, scan_y, scan_x). + The first dimension represents the energy, while the latter + two dimensions represent real space sampling. + + """ + + def __init__( + self, + array: NDArray | Any, + name: str, + origin: NDArray | tuple | list | float | int, + sampling: NDArray | tuple | list | float | int, + units: list[str] | tuple | list, + signal_units: str = "arb. units", + _token: object | None = None, + ): + """Initialize a 3D EDS dataset. + + Parameters + ---------- + array : NDArray | Any + The underlying 3D array data + name : str + A descriptive name for the dataset + origin : NDArray | tuple | list | float | int + The origin coordinates for each dimension + sampling : NDArray | tuple | list | float | int + The sampling rate/spacing for each dimension + units : list[str] | tuple | list + Units for each dimension + signal_units : str, optional + Units for the array values, by default "arb. units" + _token : object | None, optional + Token to prevent direct instantiation, by default None + """ + super().__init__( + array=array, + name=name, + origin=origin, + sampling=sampling, + units=units, + signal_units=signal_units, + _token=_token, + ) + self._virtual_images = {} diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py new file mode 100644 index 00000000..74c39733 --- /dev/null +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -0,0 +1,56 @@ +from typing import Any + +from numpy.typing import NDArray + +from quantem.spectroscopy import Dataset3dspectroscopy + + +class Dataset3deels(Dataset3dspectroscopy): + """An EELS dataset class that inherits from Dataset3dspectroscopy. + + This class represents a scanning transmission electron microscopy (STEM) dataset, + where the data consists of a 3D array with dimensions (energy, scan_y, scan_x). + The first dimension represents the energy, while the latter + two dimensions represent real space sampling. + + """ + + def __init__( + self, + array: NDArray | Any, + name: str, + origin: NDArray | tuple | list | float | int, + sampling: NDArray | tuple | list | float | int, + units: list[str] | tuple | list, + signal_units: str = "arb. units", + _token: object | None = None, + ): + """Initialize a 3D EELS dataset. + + Parameters + ---------- + array : NDArray | Any + The underlying 3D array data + name : str + A descriptive name for the dataset + origin : NDArray | tuple | list | float | int + The origin coordinates for each dimension + sampling : NDArray | tuple | list | float | int + The sampling rate/spacing for each dimension + units : list[str] | tuple | list + Units for each dimension + signal_units : str, optional + Units for the array values, by default "arb. units" + _token : object | None, optional + Token to prevent direct instantiation, by default None + """ + super().__init__( + array=array, + name=name, + origin=origin, + sampling=sampling, + units=units, + signal_units=signal_units, + _token=_token, + ) + self._virtual_images = {} diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index b71db976..c1ec8d0a 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -75,7 +75,7 @@ def from_array( array : NDArray | Any The underlying 3D array data name : str | None, optional - A descriptive name for the dataset. If None, defaults to "4D-STEM dataset" + A descriptive name for the dataset. If None, defaults to "3D-STEM dataset" origin : NDArray | tuple | list | float | int | None, optional The origin coordinates for each dimension. If None, defaults to zeros sampling : NDArray | tuple | list | float | int | None, optional From 09fe1099ef92974cbcd097f4fd9797b423c2fd09 Mon Sep 17 00:00:00 2001 From: Sangoda <87961379+Sangoda@users.noreply.github.com> Date: Wed, 24 Sep 2025 15:22:04 -0700 Subject: [PATCH 006/136] test commit --- src/quantem/spectroscopy/dataset3dspectroscopy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index c1ec8d0a..e337a1be 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -104,3 +104,4 @@ def from_array( ## PCA ## imaging ## specturm picking +## this is a commit \ No newline at end of file From e4c9b3bfb0d453f092fedf7d2421f926be3e5d5a Mon Sep 17 00:00:00 2001 From: smribet Date: Wed, 24 Sep 2025 15:22:54 -0700 Subject: [PATCH 007/136] checking pull --- src/quantem/spectroscopy/dataset3dspectroscopy.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index e337a1be..c1ec8d0a 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -104,4 +104,3 @@ def from_array( ## PCA ## imaging ## specturm picking -## this is a commit \ No newline at end of file From f697492bff4e16c0fa65ade0e5f856e6aed3b37d Mon Sep 17 00:00:00 2001 From: Kovidh Singh Date: Thu, 25 Sep 2025 12:02:56 -0700 Subject: [PATCH 008/136] test --- src/quantem/core/visualization/visualization.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/quantem/core/visualization/visualization.py b/src/quantem/core/visualization/visualization.py index f4ee9765..3894516b 100644 --- a/src/quantem/core/visualization/visualization.py +++ b/src/quantem/core/visualization/visualization.py @@ -78,6 +78,7 @@ def _show_2d_array( ax : Axes The matplotlib axes object. """ + print("hello") is_complex = np.iscomplexobj(array) if is_complex: amplitude = np.abs(array) From 4329f149ff6f305098599990b1c104b2141e530f Mon Sep 17 00:00:00 2001 From: smribet Date: Thu, 25 Sep 2025 12:05:12 -0700 Subject: [PATCH 009/136] pull test --- src/quantem/core/visualization/visualization.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/quantem/core/visualization/visualization.py b/src/quantem/core/visualization/visualization.py index 3894516b..f4ee9765 100644 --- a/src/quantem/core/visualization/visualization.py +++ b/src/quantem/core/visualization/visualization.py @@ -78,7 +78,6 @@ def _show_2d_array( ax : Axes The matplotlib axes object. """ - print("hello") is_complex = np.iscomplexobj(array) if is_complex: amplitude = np.abs(array) From 57e0b70db6e8da93b7b94383ce9f69c01f78a353 Mon Sep 17 00:00:00 2001 From: Sangoda <87961379+Sangoda@users.noreply.github.com> Date: Thu, 2 Oct 2025 14:39:30 -0700 Subject: [PATCH 010/136] DataSpectroscopy class for EELS and EDS. --- src/quantem/spectroscopy/__init__.py | 3 + .../spectroscopy/dataset3dspectroscopy.py | 60 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/quantem/spectroscopy/__init__.py b/src/quantem/spectroscopy/__init__.py index 9b87e017..60b5fd31 100644 --- a/src/quantem/spectroscopy/__init__.py +++ b/src/quantem/spectroscopy/__init__.py @@ -8,3 +8,6 @@ from quantem.spectroscopy.dataset3deds import ( Dataset3deds as Dataset3deds, ) + + +from quantem.spectroscopy.dataset3dspectroscopy import DataSpectroscopy diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index c1ec8d0a..febf15b9 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -2,6 +2,9 @@ import numpy as np from numpy.typing import NDArray +import matplotlib as mpl +import matplotlib.pyplot as plt + from quantem.core.datastructures.dataset3d import Dataset3d from quantem.core.utils.validators import ensure_valid_array @@ -104,3 +107,60 @@ def from_array( ## PCA ## imaging ## specturm picking + + +class DataSpectroscopy: + """ + Class for handling 3D spectroscopy data and extracting spectra from ROIs. + """ + def __init__(self, array): + # Use the underlying array attribute, do not sum over axis 0 + self.array = np.asarray(array.array, dtype=float) + self.sampling = array.sampling + self.origin = array.origin + self.shape = self.array.shape + + def image_to_spec(self, y, x, dy=None, dx=None, title=None): + """ + Make and show a spectrum plot from a spatial ROI in a 3D EDS cube (E, Y, X). + + Parameters + ---------- + y, x : int + Top-left pixel of the ROI. + dy, dx : int, optional + ROI size (height, width). Defaults to full image if None. + title : str, optional + Plot title (auto-filled if None). + + Returns + ------- + (fig, ax) : tuple + The Matplotlib Figure and Axes of the spectrum plot. + """ + if dy is None: + dy = self.shape[1] + if dx is None: + dx = self.shape[2] + + dE = float(self.sampling[0]) + E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 + E = E0 + dE * np.arange(self.shape[0]) + + spec = np.empty(self.shape[0], dtype=float) + for k in range(self.shape[0]): + img = np.asarray(self.array[k], dtype=float) + roi = img[y:y+dy, x:x+dx] + if roi.size == 0: + raise ValueError("ROI is empty; check y/x/dy/dx.") + spec[k] = roi.mean() + + fig, ax = plt.subplots(figsize=(6, 4)) + ax.plot(E, spec) + ax.set_xlabel("Energy (keV)") + ax.set_ylabel("Intensity") + ax.set_title(title or f"Spectrum ROI y={y}:{y+dy}, x={x}:{x+dx}") + fig.tight_layout() + plt.show() + + return fig, ax From 760ff16f2ce80689ea53fdcd710e2d55d4cc187c Mon Sep 17 00:00:00 2001 From: Kovidh Singh Date: Thu, 9 Oct 2025 02:09:22 -0700 Subject: [PATCH 011/136] added pca and plot_virtual_image --- .../spectroscopy/dataset3dspectroscopy.py | 292 +++++++++++++++++- 1 file changed, 290 insertions(+), 2 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index febf15b9..41cc07e3 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -1,9 +1,10 @@ -from typing import Any, Self +from typing import Any, Self, Optional, Tuple import numpy as np from numpy.typing import NDArray -import matplotlib as mpl import matplotlib.pyplot as plt +from numpy.typing import NDArray +from sklearn.decomposition import PCA from quantem.core.datastructures.dataset3d import Dataset3d @@ -105,7 +106,294 @@ def from_array( ) ## PCA + + def perform_pca( + self, + n_components: int = 10, + standardize: bool = True, + mask: Optional[NDArray] = None, + plot_results: bool = True, + random_state: Optional[int] = 42 + ) -> dict: + """ + Perform Principal Component Analysis (PCA) on the spectroscopy dataset. + + Parameters + ---------- + n_components : int + Number of principal components to compute + standardize : bool + If True, standardize the data before PCA (zero mean, unit variance) + mask : Optional[NDArray] + Optional spatial mask to select pixels for analysis + plot_results : bool + If True, plot the explained variance and first few components + random_state : Optional[int] + Random state for reproducibility + + Returns + ------- + dict + Dictionary containing: + - 'pca': fitted PCA object + - 'components': principal component spectra (n_components x n_energy) + - 'loadings': spatial loadings (n_components x n_pixels) + - 'explained_variance_ratio': explained variance for each component + - 'reconstructed': reconstructed dataset using n_components + """ + data = np.asarray(self.array, dtype=float) + n_energy, ny, nx = data.shape + + # Reshape data to (n_pixels, n_energy) for PCA + data_reshaped = data.reshape(n_energy, -1).T # (n_pixels, n_energy) + + if mask is not None: + mask_flat = mask.flatten() + data_masked = data_reshaped[mask_flat] + else: + data_masked = data_reshaped + + if standardize: + mean = np.mean(data_masked, axis=0) + std = np.std(data_masked, axis=0) + std[std == 0] = 1 # Avoid division by zero + data_processed = (data_masked - mean) / std + else: + data_processed = data_masked + + # Perform PCA + pca = PCA(n_components=n_components, random_state=random_state) + loadings = pca.fit_transform(data_processed) # (n_pixels, n_components) + components = pca.components_ # (n_components, n_energy) + + # Reconstruct data + if standardize: + reconstructed = pca.inverse_transform(loadings) * std + mean + else: + reconstructed = pca.inverse_transform(loadings) + + if mask is None: + loadings_spatial = loadings.T.reshape(n_components, ny, nx) + else: + loadings_spatial = np.zeros((n_components, ny * nx)) + loadings_spatial[:, mask_flat] = loadings.T + loadings_spatial = loadings_spatial.reshape(n_components, ny, nx) + + if plot_results: + self._plot_pca_results( + components, loadings_spatial, pca.explained_variance_ratio_, + n_show=min(4, n_components) + ) + + return { + 'pca': pca, + 'components': components, + 'loadings': loadings_spatial, + 'explained_variance_ratio': pca.explained_variance_ratio_, + 'explained_variance': pca.explained_variance_, + 'reconstructed': reconstructed.T.reshape(n_energy, ny, nx) if mask is None else reconstructed + } + + def _plot_pca_results( + self, + components: NDArray, + loadings: NDArray, + explained_variance_ratio: NDArray, + n_show: int = 4 + ): + """ + Plot PCA results including scree plot, components, and loadings. + + Parameters + ---------- + components : NDArray + Principal component spectra + loadings : NDArray + Spatial loadings for each component + explained_variance_ratio : NDArray + Explained variance ratios + n_show : int + Number of components to show + """ + fig = plt.figure(figsize=(15, 10)) + gs = fig.add_gridspec(3, n_show + 1, width_ratios=[1.5] + [1] * n_show) + + # Plot 1: Scree plot (explained variance) + ax_scree = fig.add_subplot(gs[0, 0]) + cumsum_var = np.cumsum(explained_variance_ratio) + + ax_scree.bar(range(1, len(explained_variance_ratio) + 1), + explained_variance_ratio * 100, alpha=0.6, label='Individual') + ax_scree.plot(range(1, len(explained_variance_ratio) + 1), + cumsum_var * 100, 'ro-', label='Cumulative') + ax_scree.set_xlabel('Component Number') + ax_scree.set_ylabel('Explained Variance (%)') + ax_scree.set_title('Scree Plot') + ax_scree.legend() + ax_scree.grid(True, alpha=0.3) + + # Get energy axis + energy_sampling = float(self.sampling[0]) + energy_origin = float(self.origin[0]) + energy_axis = energy_origin + energy_sampling * np.arange(components.shape[1]) + + # Plot components and loadings + for i in range(n_show): + ax_comp = fig.add_subplot(gs[1, i + 1]) + ax_comp.plot(energy_axis, components[i]) + ax_comp.set_title(f'PC{i+1} ({explained_variance_ratio[i]*100:.1f}%)') + ax_comp.set_xlabel('Energy') + if i == 0: + ax_comp.set_ylabel('Component') + ax_comp.grid(True, alpha=0.3) + + ax_load = fig.add_subplot(gs[2, i + 1]) + im = ax_load.imshow(loadings[i], cmap='RdBu_r', origin='lower') + ax_load.set_title(f'Loading {i+1}') + ax_load.axis('off') + plt.colorbar(im, ax=ax_load, fraction=0.046, pad=0.04) + + ax_stats = fig.add_subplot(gs[1:, 0]) + ax_stats.axis('off') + + stats_text = f"PCA Summary\n" + "="*20 + "\n\n" + stats_text += f"Total components: {len(explained_variance_ratio)}\n" + stats_text += f"Components for 95% var: {np.argmax(cumsum_var >= 0.95) + 1}\n" + stats_text += f"Components for 99% var: {np.argmax(cumsum_var >= 0.99) + 1}\n\n" + + for i in range(min(5, len(explained_variance_ratio))): + stats_text += f"PC{i+1}: {explained_variance_ratio[i]*100:.2f}%\n" + + ax_stats.text(0.1, 0.9, stats_text, transform=ax_stats.transAxes, + fontsize=10, verticalalignment='top', + fontfamily='monospace', + bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5)) + + plt.suptitle('PCA Analysis Results', fontsize=14, fontweight='bold') + plt.tight_layout() + plt.show() + ## imaging + def plot_virtual_image( + self, + E0: float, + dE: float, + mask: Optional[NDArray] = None, + normalize_spectrum: bool = True, + cmap: str = 'viridis', + title: Optional[str] = None, + figsize: Tuple[float, float] = (12, 5) + ) -> Tuple[plt.Figure, Tuple[plt.Axes, plt.Axes]]: + """ + Generate a virtual image by integrating over an energy range. + + Creates a figure with two sub-panels: + 1. Full spectrum with highlighted energy range + 2. Resulting virtual image from the energy integration + + Parameters + ---------- + E0 : float + Starting energy for integration (in same units as energy axis) + dE : float + Energy range width for integration + mask : Optional[NDArray] + Optional spatial mask to apply to the image (same shape as spatial dims) + normalize_spectrum : bool + If True, normalize the spectrum display to [0, 1] + cmap : str + Colormap for the virtual image display + title : Optional[str] + Custom title for the figure + figsize : Tuple[float, float] + Figure size (width, height) in inches + + Returns + ------- + fig : plt.Figure + The matplotlib Figure object + (ax1, ax2) : Tuple[plt.Axes, plt.Axes] + The axes for spectrum and image subplots + """ + # Get energy axis + energy_sampling = float(self.sampling[0]) + energy_origin = float(self.origin[0]) + energy_axis = energy_origin + energy_sampling * np.arange(self.shape[0]) + + #energy indices for integration + E_end = E0 + dE + energy_indices = np.where((energy_axis >= E0) & (energy_axis <= E_end))[0] + + if len(energy_indices) == 0: + raise ValueError(f"No energy channels found in range [{E0}, {E_end}]") + + data = np.asarray(self.array, dtype=float) + + # Compute mean spectrum (averaged over all spatial pixels) + if mask is not None: + mask = np.asarray(mask, dtype=bool) + if mask.shape != data.shape[1:]: + raise ValueError(f"Mask shape {mask.shape} doesn't match spatial dimensions {data.shape[1:]}") + + spectrum = np.zeros(data.shape[0]) + for i in range(data.shape[0]): + masked_data = data[i][mask] + spectrum[i] = masked_data.mean() if masked_data.size > 0 else 0 + else: + spectrum = data.mean(axis=(1, 2)) + + # Create virtual image by integrating over energy range + virtual_image = data[energy_indices].sum(axis=0) + + if mask is not None: + virtual_image = virtual_image * mask + + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize) + + # Plot 1: Full spectrum with highlighted energy range + + if normalize_spectrum and spectrum.max() > 0: + spectrum_display = spectrum / spectrum.max() + else: + spectrum_display = spectrum + + ax1.plot(energy_axis, spectrum_display, 'b-', linewidth=1.5, label='Full spectrum') + + energy_min_idx = energy_indices[0] + energy_max_idx = energy_indices[-1] + ax1.axvspan(energy_axis[energy_min_idx], energy_axis[energy_max_idx], + alpha=0.3, color='red', label=f'Selected range') + + ax1.axvline(x=E0, color='red', linestyle='--', alpha=0.5, linewidth=1) + ax1.axvline(x=E_end, color='red', linestyle='--', alpha=0.5, linewidth=1) + + ax1.set_xlabel(f'Energy ({self.units[0] if hasattr(self, "units") else "eV"})') + ax1.set_ylabel('Intensity (arb. units)') + ax1.set_title('Energy Spectrum') + ax1.grid(True, alpha=0.3) + ax1.legend() + + y_pos = ax1.get_ylim()[1] * 0.9 + ax1.text(E0 + dE/2, y_pos, f'E: {E0:.1f} - {E_end:.1f}\nΔE: {dE:.1f}', + ha='center', va='top', fontsize=9, + bbox=dict(boxstyle='round,pad=0.5', facecolor='yellow', alpha=0.5)) + + # Plot 2: Virtual image + im = ax2.imshow(virtual_image, cmap=cmap, origin='lower') + ax2.set_xlabel(f'X ({self.units[2] if hasattr(self, "units") else "pixels"})') + ax2.set_ylabel(f'Y ({self.units[1] if hasattr(self, "units") else "pixels"})') + ax2.set_title(f'Virtual Image (E: {E0:.1f} - {E_end:.1f})') + + cbar = plt.colorbar(im, ax=ax2) + cbar.set_label('Integrated Intensity', rotation=270, labelpad=15) + + if title is None: + title = f'Virtual Image Analysis - Energy Integration' + fig.suptitle(title, fontsize=14, fontweight='bold') + + plt.tight_layout() + return fig, (ax1, ax2) + ## specturm picking From b73cbb822353644cc3050456c5b3a903170303b2 Mon Sep 17 00:00:00 2001 From: Kovidh Singh Date: Mon, 3 Nov 2025 11:07:40 -0800 Subject: [PATCH 012/136] fixes --- src/quantem/spectroscopy/__init__.py | 1 - src/quantem/spectroscopy/dataset3dspectroscopy.py | 12 ------------ 2 files changed, 13 deletions(-) diff --git a/src/quantem/spectroscopy/__init__.py b/src/quantem/spectroscopy/__init__.py index 60b5fd31..8ddb58c0 100644 --- a/src/quantem/spectroscopy/__init__.py +++ b/src/quantem/spectroscopy/__init__.py @@ -10,4 +10,3 @@ ) -from quantem.spectroscopy.dataset3dspectroscopy import DataSpectroscopy diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 41cc07e3..546ff568 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -396,18 +396,6 @@ def plot_virtual_image( ## specturm picking - -class DataSpectroscopy: - """ - Class for handling 3D spectroscopy data and extracting spectra from ROIs. - """ - def __init__(self, array): - # Use the underlying array attribute, do not sum over axis 0 - self.array = np.asarray(array.array, dtype=float) - self.sampling = array.sampling - self.origin = array.origin - self.shape = self.array.shape - def image_to_spec(self, y, x, dy=None, dx=None, title=None): """ Make and show a spectrum plot from a spatial ROI in a 3D EDS cube (E, Y, X). From 5e46224d677332eebd17b1feac2b0dc62a09a3d5 Mon Sep 17 00:00:00 2001 From: Sangoda <87961379+Sangoda@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:53:37 -0800 Subject: [PATCH 013/136] file_readers fix for 3d spectroscopy data --- src/quantem/core/io/file_readers.py | 32 +- .../spectroscopy/dataset3dspectroscopy.py | 1227 ++++- src/quantem/spectroscopy/xray_lines.json | 3985 +++++++++++++++++ 3 files changed, 5011 insertions(+), 233 deletions(-) create mode 100644 src/quantem/spectroscopy/xray_lines.json diff --git a/src/quantem/core/io/file_readers.py b/src/quantem/core/io/file_readers.py index 02f5ffa1..10bb4c1b 100644 --- a/src/quantem/core/io/file_readers.py +++ b/src/quantem/core/io/file_readers.py @@ -96,7 +96,7 @@ def read_4dstem( return dataset -def read_3d_spectroscopy(file_path: str, file_type: str, data_type: str) -> Dataset3dspectroscopy: +def read_3d_spectroscopy(file_path: str, file_type: str, data_type: str, dataset_index: int | None = None) -> Dataset3dspectroscopy: """ File reader for 3D spectroscopy data data @@ -114,7 +114,35 @@ def read_3d_spectroscopy(file_path: str, file_type: str, data_type: str) -> Data Dataset3dspectroscopy """ file_reader = importlib.import_module(f"rsciio.{file_type}").file_reader # type: ignore - imported_data = file_reader(file_path)[0] + data_list = file_reader(file_path) + + # If specific index provided, use it + if dataset_index is not None: + imported_data = data_list[dataset_index] + if imported_data["data"].ndim != 3: + raise ValueError( + f"Dataset at index {dataset_index} has {imported_data['data'].ndim} dimensions, " + f"expected 4D. Shape: {imported_data['data'].shape}" + ) + else: + # Automatically find first 3D dataset + three_d_datasets = [(i, d) for i, d in enumerate(data_list) if d["data"].ndim == 3] + + if len(three_d_datasets) == 0: + print(f"No 3D datasets found in {file_path}. Available datasets:") + for i, d in enumerate(data_list): + print(f" Dataset {i}: shape {d['data'].shape}, ndim={d['data'].ndim}") + raise ValueError("No 3D dataset found in file") + + dataset_index, imported_data = three_d_datasets[0] + + if len(data_list) > 1: + print( + f"File contains {len(data_list)} dataset(s). Using dataset {dataset_index} with shape {imported_data['data'].shape}" + ) + + imported_axes = imported_data["axes"] + # imported_data[0], if data_type == "EELS": dataset = Dataset3deels.from_array( array=imported_data["data"].transpose((2, 0, 1)), diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 546ff568..b53a4e75 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -1,112 +1,108 @@ from typing import Any, Self, Optional, Tuple +import os +import json +from scipy.signal import find_peaks + import numpy as np from numpy.typing import NDArray +import matplotlib as mpl import matplotlib.pyplot as plt -from numpy.typing import NDArray +from matplotlib.patches import Rectangle from sklearn.decomposition import PCA - from quantem.core.datastructures.dataset3d import Dataset3d from quantem.core.utils.validators import ensure_valid_array -class Dataset3dspectroscopy(Dataset3d): - """A 3D-STEM spectroscopy dataset class that inherits from Dataset3d. - - This class represents a scanning transmission electron microscopy (STEM) dataset, - where the data consists of a 3D array with dimensions (energy, scan_y, scan_x). - The first dimension represents the energy, while the latter - two dimensions represent real space sampling. +class Dataset3dspectroscopy(Dataset3d): + """ + Class for handling 3D spectroscopy data and extracting spectra from ROIs. + Accepts either a dataset-like object or explicit arguments, and works as a base class. """ - def __init__( - self, - array: NDArray | Any, - name: str, - origin: NDArray | tuple | list | float | int, - sampling: NDArray | tuple | list | float | int, - units: list[str] | tuple | list, - signal_units: str = "arb. units", - _token: object | None = None, - ): - """Initialize a 3D-STEM spectroscopy dataset. - - Parameters - ---------- - array : NDArray | Any - The underlying 3D array data - name : str - A descriptive name for the dataset - origin : NDArray | tuple | list | float | int - The origin coordinates for each dimension - sampling : NDArray | tuple | list | float | int - The sampling rate/spacing for each dimension - units : list[str] | tuple | list - Units for each dimension - signal_units : str, optional - Units for the array values, by default "arb. units" - _token : object | None, optional - Token to prevent direct instantiation, by default None - """ - super().__init__( - array=array, - name=name, - origin=origin, - sampling=sampling, - units=units, - signal_units=signal_units, - _token=_token, - ) - self._virtual_images = {} + # stores the element line info so you don't need to reload each time + element_info = None + # loads the element info @classmethod - def from_array( - cls, - array: NDArray | Any, - name: str | None = None, - origin: NDArray | tuple | list | float | int | None = None, - sampling: NDArray | tuple | list | float | int | None = None, - units: list[str] | tuple | list | None = None, - signal_units: str = "arb. units", - ) -> Self: - """ - Create a new Dataset3dspectroscopy from an array. + def load_element_info(cls, path='xray_lines.json'): + if cls.element_info is not None: + # don't reload if already loaded + return + base_dir = os.path.dirname(os.path.abspath(__file__)) + full_path = os.path.join(base_dir, path) + with open(full_path, 'r') as f: + cls.element_info = json.load(f)['elements'] + def __init__(self, array, name=None, origin=None, sampling=None, units=None, signal_units="arb. units", _token=None): + if ( + name is None and origin is None and sampling is None and units is None + and hasattr(array, "array") and hasattr(array, "name") and hasattr(array, "origin") and hasattr(array, "sampling") and hasattr(array, "units") + ): + super().__init__( + array=array.array, + name=array.name, + origin=getattr(array, "origin", np.zeros(3)), + sampling=array.sampling, + units=array.units, + signal_units=getattr(array, "signal_units", signal_units), + _token=type(self)._token if _token is None else _token, + ) + # Initialize model elements storage + self.model_elements = None + else: + super().__init__( + array=array, + name=name, + origin=origin, + sampling=sampling, + units=units, + signal_units=signal_units, + _token=type(self)._token if _token is None else _token, + ) + + # Initialize model elements storage + self.model_elements = None + + def add_elements_to_model(self, elements): + """ + Add elements to the model for persistent use in show_mean_spectrum. + Parameters ---------- - array : NDArray | Any - The underlying 3D array data - name : str | None, optional - A descriptive name for the dataset. If None, defaults to "3D-STEM dataset" - origin : NDArray | tuple | list | float | int | None, optional - The origin coordinates for each dimension. If None, defaults to zeros - sampling : NDArray | tuple | list | float | int | None, optional - The sampling rate/spacing for each dimension. If None, defaults to ones - units : list[str] | tuple | list | None, optional - Units for each dimension. If None, defaults to ["pixels"] * 4 - signal_units : str, optional - Units for the array values, by default "arb. units" - - Returns - ------- - Dataset3dspectroscopy - A new Dataset3dspectroscopy instance + elements : list or str + Element symbol(s) to add to the model. Can be a single string (e.g., 'Al') + or list of symbols (e.g., ['Au', 'Cu', 'Si']). """ - array = ensure_valid_array(array, ndim=3) - return cls( - array=array, - name=name if name is not None else "3D-STEM dataset", - origin=origin if origin is not None else np.zeros(3), - sampling=sampling if sampling is not None else np.ones(3), - units=units if units is not None else ["pixels"] * 3, - signal_units=signal_units, - _token=cls._token, - ) - - ## PCA + # Load element info if not already loaded + if type(self).element_info is None: + type(self).load_element_info() + + # Convert to list if single string provided + if isinstance(elements, str): + elements = [elements] + + # Convert list of element symbols to dict using class element_info + if isinstance(elements, list): + all_info = type(self).element_info + if all_info is not None: + # Initialize model_elements as dict if it doesn't exist + if self.model_elements is None: + self.model_elements = {} + + # Add new elements to existing model + for el in elements: + if el in all_info: + self.model_elements[el] = all_info[el] + + def clear_model_elements(self): + """Clear all elements from the model.""" + self.model_elements = None + ## PCA ANALYSIS METHODS + def perform_pca( self, n_components: int = 10, @@ -117,7 +113,7 @@ def perform_pca( ) -> dict: """ Perform Principal Component Analysis (PCA) on the spectroscopy dataset. - + Parameters ---------- n_components : int @@ -130,7 +126,7 @@ def perform_pca( If True, plot the explained variance and first few components random_state : Optional[int] Random state for reproducibility - + Returns ------- dict @@ -143,16 +139,16 @@ def perform_pca( """ data = np.asarray(self.array, dtype=float) n_energy, ny, nx = data.shape - + # Reshape data to (n_pixels, n_energy) for PCA data_reshaped = data.reshape(n_energy, -1).T # (n_pixels, n_energy) - + if mask is not None: mask_flat = mask.flatten() data_masked = data_reshaped[mask_flat] else: data_masked = data_reshaped - + if standardize: mean = np.mean(data_masked, axis=0) std = np.std(data_masked, axis=0) @@ -160,31 +156,31 @@ def perform_pca( data_processed = (data_masked - mean) / std else: data_processed = data_masked - + # Perform PCA pca = PCA(n_components=n_components, random_state=random_state) loadings = pca.fit_transform(data_processed) # (n_pixels, n_components) components = pca.components_ # (n_components, n_energy) - + # Reconstruct data if standardize: reconstructed = pca.inverse_transform(loadings) * std + mean else: reconstructed = pca.inverse_transform(loadings) - + if mask is None: loadings_spatial = loadings.T.reshape(n_components, ny, nx) else: loadings_spatial = np.zeros((n_components, ny * nx)) loadings_spatial[:, mask_flat] = loadings.T loadings_spatial = loadings_spatial.reshape(n_components, ny, nx) - + if plot_results: self._plot_pca_results( components, loadings_spatial, pca.explained_variance_ratio_, n_show=min(4, n_components) ) - + return { 'pca': pca, 'components': components, @@ -193,7 +189,7 @@ def perform_pca( 'explained_variance': pca.explained_variance_, 'reconstructed': reconstructed.T.reshape(n_energy, ny, nx) if mask is None else reconstructed } - + def _plot_pca_results( self, components: NDArray, @@ -203,7 +199,7 @@ def _plot_pca_results( ): """ Plot PCA results including scree plot, components, and loadings. - + Parameters ---------- components : NDArray @@ -217,26 +213,26 @@ def _plot_pca_results( """ fig = plt.figure(figsize=(15, 10)) gs = fig.add_gridspec(3, n_show + 1, width_ratios=[1.5] + [1] * n_show) - + # Plot 1: Scree plot (explained variance) ax_scree = fig.add_subplot(gs[0, 0]) cumsum_var = np.cumsum(explained_variance_ratio) - - ax_scree.bar(range(1, len(explained_variance_ratio) + 1), + + ax_scree.bar(range(1, len(explained_variance_ratio) + 1), explained_variance_ratio * 100, alpha=0.6, label='Individual') - ax_scree.plot(range(1, len(explained_variance_ratio) + 1), + ax_scree.plot(range(1, len(explained_variance_ratio) + 1), cumsum_var * 100, 'ro-', label='Cumulative') ax_scree.set_xlabel('Component Number') ax_scree.set_ylabel('Explained Variance (%)') ax_scree.set_title('Scree Plot') ax_scree.legend() ax_scree.grid(True, alpha=0.3) - + # Get energy axis energy_sampling = float(self.sampling[0]) energy_origin = float(self.origin[0]) energy_axis = energy_origin + energy_sampling * np.arange(components.shape[1]) - + # Plot components and loadings for i in range(n_show): ax_comp = fig.add_subplot(gs[1, i + 1]) @@ -246,197 +242,966 @@ def _plot_pca_results( if i == 0: ax_comp.set_ylabel('Component') ax_comp.grid(True, alpha=0.3) - + ax_load = fig.add_subplot(gs[2, i + 1]) im = ax_load.imshow(loadings[i], cmap='RdBu_r', origin='lower') ax_load.set_title(f'Loading {i+1}') ax_load.axis('off') plt.colorbar(im, ax=ax_load, fraction=0.046, pad=0.04) - + ax_stats = fig.add_subplot(gs[1:, 0]) ax_stats.axis('off') - + stats_text = f"PCA Summary\n" + "="*20 + "\n\n" stats_text += f"Total components: {len(explained_variance_ratio)}\n" stats_text += f"Components for 95% var: {np.argmax(cumsum_var >= 0.95) + 1}\n" stats_text += f"Components for 99% var: {np.argmax(cumsum_var >= 0.99) + 1}\n\n" - + for i in range(min(5, len(explained_variance_ratio))): stats_text += f"PC{i+1}: {explained_variance_ratio[i]*100:.2f}%\n" - + ax_stats.text(0.1, 0.9, stats_text, transform=ax_stats.transAxes, fontsize=10, verticalalignment='top', fontfamily='monospace', bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5)) - + plt.suptitle('PCA Analysis Results', fontsize=14, fontweight='bold') plt.tight_layout() plt.show() - ## imaging - def plot_virtual_image( - self, - E0: float, - dE: float, - mask: Optional[NDArray] = None, - normalize_spectrum: bool = True, - cmap: str = 'viridis', - title: Optional[str] = None, - figsize: Tuple[float, float] = (12, 5) - ) -> Tuple[plt.Figure, Tuple[plt.Axes, plt.Axes]]: + ''' + def quantify_composition(self, roi=None, elements=None, k_factors=None, method='cliff_lorimer', mask=None): """ - Generate a virtual image by integrating over an energy range. + Quantify elemental composition from EDS spectrum using Cliff-Lorimer approach. - Creates a figure with two sub-panels: - 1. Full spectrum with highlighted energy range - 2. Resulting virtual image from the energy integration + The Cliff-Lorimer equation relates atomic fractions to X-ray intensities: + CA/CB = kAB * (IA/IB) Parameters ---------- - E0 : float - Starting energy for integration (in same units as energy axis) - dE : float - Energy range width for integration - mask : Optional[NDArray] - Optional spatial mask to apply to the image (same shape as spatial dims) - normalize_spectrum : bool - If True, normalize the spectrum display to [0, 1] - cmap : str - Colormap for the virtual image display - title : Optional[str] - Custom title for the figure - figsize : Tuple[float, float] - Figure size (width, height) in inches - + roi : list or tuple, optional + Region of interest as [y, x, dy, dx]. If None, uses full image. + elements : list, required + List of element symbols to quantify (e.g., ['Pt', 'Co']). + k_factors : dict, optional + K-factors for element pairs relative to first element. + Format: {'Pt': 1.0, 'Co': 1.23} where first element = 1.0 + If None, uses theoretical k-factors from element database. + method : str, optional + Quantification method. Currently supports 'cliff_lorimer'. + mask : array, optional + Boolean mask for energy channel selection. + Returns ------- - fig : plt.Figure - The matplotlib Figure object - (ax1, ax2) : Tuple[plt.Axes, plt.Axes] - The axes for spectrum and image subplots + dict : Composition results containing: + - 'atomic_percent': dict of element -> atomic % + - 'weight_percent': dict of element -> weight % + - 'intensities': dict of element -> integrated intensity + - 'k_factors': dict of k-factors used + + Examples + -------- + # Basic quantification with theoretical k-factors + comp = dataset.quantify_composition(elements=['Pt', 'Co']) + + # With experimental k-factors + k_factors = {'Pt': 1.0, 'Co': 1.23} + comp = dataset.quantify_composition(elements=['Pt', 'Co'], k_factors=k_factors) + + # Access results + print(f"Pt: {comp['atomic_percent']['Pt']:.1f} at%") + print(f"Co: {comp['atomic_percent']['Co']:.1f} at%") """ - # Get energy axis - energy_sampling = float(self.sampling[0]) - energy_origin = float(self.origin[0]) - energy_axis = energy_origin + energy_sampling * np.arange(self.shape[0]) - #energy indices for integration - E_end = E0 + dE - energy_indices = np.where((energy_axis >= E0) & (energy_axis <= E_end))[0] + # Input validation + if elements is None or len(elements) < 2: + raise ValueError("At least 2 elements required for quantification") - if len(energy_indices) == 0: - raise ValueError(f"No energy channels found in range [{E0}, {E_end}]") + # Load element info if not available + if type(self).element_info is None: + type(self).load_element_info() - data = np.asarray(self.array, dtype=float) + # Extract spectrum from ROI + spectrum_data = self._extract_spectrum_for_quantification(roi, mask) + spec = spectrum_data['spectrum'] + E = spectrum_data['energy'] - # Compute mean spectrum (averaged over all spatial pixels) - if mask is not None: - mask = np.asarray(mask, dtype=bool) - if mask.shape != data.shape[1:]: - raise ValueError(f"Mask shape {mask.shape} doesn't match spatial dimensions {data.shape[1:]}") + # Get X-ray line intensities for each element + intensities = {} + for element in elements: + intensity = self._integrate_element_intensity(element, spec, E) + intensities[element] = intensity - spectrum = np.zeros(data.shape[0]) - for i in range(data.shape[0]): - masked_data = data[i][mask] - spectrum[i] = masked_data.mean() if masked_data.size > 0 else 0 + # Handle k-factors + if k_factors is None: + k_factors = self._calculate_theoretical_k_factors(elements) + else: + # Validate k-factors + if not all(elem in k_factors for elem in elements): + raise ValueError("k_factors must include all elements") + + # Apply Cliff-Lorimer quantification + if method == 'cliff_lorimer': + results = self._cliff_lorimer_quantification(elements, intensities, k_factors) else: - spectrum = data.mean(axis=(1, 2)) + raise ValueError(f"Unknown quantification method: {method}") - # Create virtual image by integrating over energy range - virtual_image = data[energy_indices].sum(axis=0) + return results + + def _extract_spectrum_for_quantification(self, roi, mask): + """Extract spectrum data for quantification (similar to show_mean_spectrum).""" + # Parse ROI (reuse logic from show_mean_spectrum) + if roi is None: + y, x, dy, dx = 0, 0, int(self.shape[1]), int(self.shape[2]) + elif len(roi) == 2: + y, x, dy, dx = int(roi[0]), int(roi[1]), 1, 1 + elif len(roi) == 4: + y_val, x_val, dy_val, dx_val = roi + y = 0 if y_val is None else int(y_val) + x = 0 if x_val is None else int(x_val) + dy = int(self.shape[1]) - y if dy_val is None else int(dy_val) + dx = int(self.shape[2]) - x if dx_val is None else int(dx_val) + else: + raise ValueError("roi must be None, [y, x], or [y, x, dy, dx]") + + # Energy axis + dE = float(self.sampling[0]) + E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 + E = E0 + dE * np.arange(self.shape[0]) + # Extract spectrum with mask handling if mask is not None: - virtual_image = virtual_image * mask + mask = np.asarray(mask, dtype=bool) + if mask.shape != (self.shape[0],): + raise ValueError(f"Mask shape {mask.shape} doesn't match energy axis ({self.shape[0]},)") + arr = np.asarray(self.array, dtype=float)[mask, :, :] + spec = arr.sum(axis=(1,2)) if arr.shape[0] > 0 else np.zeros(0) + E = E[mask] + else: + spec = np.empty(self.shape[0], dtype=float) + for k in range(self.shape[0]): + img = np.asarray(self.array[k], dtype=float) + roi_data = img[y:y+dy, x:x+dx] + if roi_data.size == 0: + raise ValueError("ROI is empty") + spec[k] = roi_data.mean() + + return {'spectrum': spec, 'energy': E} + + def _integrate_element_intensity(self, element, spectrum, energy): + """Integrate X-ray intensity for a specific element using its characteristic lines.""" + all_info = type(self).element_info + if element not in all_info: + raise ValueError(f"Element {element} not found in database") - fig, (ax1, ax2) = plt.subplots(1, 2, figsize=figsize) + total_intensity = 0.0 + element_lines = all_info[element] - # Plot 1: Full spectrum with highlighted energy range + # Get the most intense lines (K-alpha, L-alpha, etc.) + weighted_lines = [(info['weight'], info['energy (keV)'], line_name) + for line_name, info in element_lines.items() + if info['energy (keV)'] <= 12.0] # Ignore high energy lines + weighted_lines.sort(reverse=True) # Sort by weight (highest first) - if normalize_spectrum and spectrum.max() > 0: - spectrum_display = spectrum / spectrum.max() - else: - spectrum_display = spectrum - - ax1.plot(energy_axis, spectrum_display, 'b-', linewidth=1.5, label='Full spectrum') + # Use top 3 most intense lines for integration + for weight, line_energy, line_name in weighted_lines[:3]: + if weight > 0.1: # Only significant lines + # Find integration window around the line + # Use ±0.1 keV window or adaptive based on energy resolution + window_width = max(0.1, line_energy * 0.01) # 1% of energy or 0.1 keV minimum + + # Find energy indices for integration + energy_mask = (energy >= line_energy - window_width) & (energy <= line_energy + window_width) + + if np.any(energy_mask): + # Simple background subtraction: use linear interpolation at edges + line_spectrum = spectrum[energy_mask] + if len(line_spectrum) > 2: + # Background level from edges of integration window + bg_level = (line_spectrum[0] + line_spectrum[-1]) / 2 + # Integrate above background, weighted by line intensity + net_intensity = np.sum(line_spectrum - bg_level) * weight + total_intensity += max(0, net_intensity) # No negative intensities - energy_min_idx = energy_indices[0] - energy_max_idx = energy_indices[-1] - ax1.axvspan(energy_axis[energy_min_idx], energy_axis[energy_max_idx], - alpha=0.3, color='red', label=f'Selected range') + return total_intensity + + def _calculate_theoretical_k_factors(self, elements): + """Calculate theoretical k-factors using atomic number approximation.""" + # This is a simplified approach - in practice you'd use more sophisticated + # quantum mechanical calculations or experimental values - ax1.axvline(x=E0, color='red', linestyle='--', alpha=0.5, linewidth=1) - ax1.axvline(x=E_end, color='red', linestyle='--', alpha=0.5, linewidth=1) + # Atomic numbers for common elements + atomic_numbers = { + 'C': 6, 'N': 7, 'O': 8, 'F': 9, 'Na': 11, 'Mg': 12, 'Al': 13, 'Si': 14, + 'P': 15, 'S': 16, 'Cl': 17, 'K': 19, 'Ca': 20, 'Ti': 22, 'Cr': 24, + 'Mn': 25, 'Fe': 26, 'Co': 27, 'Ni': 28, 'Cu': 29, 'Zn': 30, 'Ag': 47, + 'Pt': 78, 'Au': 79 + } - ax1.set_xlabel(f'Energy ({self.units[0] if hasattr(self, "units") else "eV"})') - ax1.set_ylabel('Intensity (arb. units)') - ax1.set_title('Energy Spectrum') - ax1.grid(True, alpha=0.3) - ax1.legend() - - y_pos = ax1.get_ylim()[1] * 0.9 - ax1.text(E0 + dE/2, y_pos, f'E: {E0:.1f} - {E_end:.1f}\nΔE: {dE:.1f}', - ha='center', va='top', fontsize=9, - bbox=dict(boxstyle='round,pad=0.5', facecolor='yellow', alpha=0.5)) + # Use first element as reference (k = 1.0) + reference_element = elements[0] + ref_z = atomic_numbers.get(reference_element, 26) # Default to Fe + + k_factors = {reference_element: 1.0} + + # Rough approximation: k_factor scales with atomic number ratio + # This is very approximate - real k-factors depend on X-ray cross sections, + # fluorescence yields, detector efficiency, etc. + for element in elements[1:]: + element_z = atomic_numbers.get(element, 26) + # Simplified relationship - should be replaced with proper theoretical calculation + k_factors[element] = (ref_z / element_z) ** 0.7 # Approximate scaling + + print(f"Using theoretical k-factors: {k_factors}") + print("Note: For accurate quantification, use experimentally determined k-factors") - # Plot 2: Virtual image - im = ax2.imshow(virtual_image, cmap=cmap, origin='lower') - ax2.set_xlabel(f'X ({self.units[2] if hasattr(self, "units") else "pixels"})') - ax2.set_ylabel(f'Y ({self.units[1] if hasattr(self, "units") else "pixels"})') - ax2.set_title(f'Virtual Image (E: {E0:.1f} - {E_end:.1f})') + return k_factors + + def _cliff_lorimer_quantification(self, elements, intensities, k_factors): + """Apply Cliff-Lorimer quantification method.""" + # Cliff-Lorimer equation: CA/CB = kAB * (IA/IB) + # For multiple elements: CA = kA * IA / Σ(ki * Ii) + + # Calculate weighted intensities + weighted_sum = 0.0 + weighted_intensities = {} + + for element in elements: + weighted_intensity = k_factors[element] * intensities[element] + weighted_intensities[element] = weighted_intensity + weighted_sum += weighted_intensity + + # Calculate atomic percentages + atomic_percent = {} + for element in elements: + if weighted_sum > 0: + atomic_percent[element] = (weighted_intensities[element] / weighted_sum) * 100.0 + else: + atomic_percent[element] = 0.0 + + # Calculate weight percentages (requires atomic weights) + atomic_weights = { + 'C': 12.01, 'N': 14.01, 'O': 16.00, 'F': 19.00, 'Na': 22.99, 'Mg': 24.31, + 'Al': 26.98, 'Si': 28.09, 'P': 30.97, 'S': 32.07, 'Cl': 35.45, 'K': 39.10, + 'Ca': 40.08, 'Ti': 47.87, 'Cr': 52.00, 'Mn': 54.94, 'Fe': 55.85, 'Co': 58.93, + 'Ni': 58.69, 'Cu': 63.55, 'Zn': 65.38, 'Ag': 107.87, 'Pt': 195.08, 'Au': 196.97 + } + + # Convert atomic % to weight % + weight_sum = 0.0 + for element in elements: + atomic_wt = atomic_weights.get(element, 55.85) # Default to Fe + weight_sum += (atomic_percent[element] / 100.0) * atomic_wt + + weight_percent = {} + for element in elements: + if weight_sum > 0: + atomic_wt = atomic_weights.get(element, 55.85) + weight_percent[element] = ((atomic_percent[element] / 100.0) * atomic_wt / weight_sum) * 100.0 + else: + weight_percent[element] = 0.0 + + # Print summary for verification + print(f"\n=== Quantification Results ===") + print(f"Method: {method}") + print(f"Elements: {elements}") + print(f"ROI: {'Full image' if roi is None else roi}") + print(f"\nRaw Intensities:") + for elem in elements: + print(f" {elem}: {intensities[elem]:.1f}") + print(f"\nK-factors used:") + for elem in elements: + print(f" {elem}: {k_factors[elem]:.3f}") + print(f"\nAtomic Composition:") + total_atomic = sum(atomic_percent.values()) + for elem in elements: + print(f" {elem}: {atomic_percent[elem]:.1f} at%") + print(f" Total: {total_atomic:.1f} at%") + print(f"\nWeight Composition:") + total_weight = sum(weight_percent.values()) + for elem in elements: + print(f" {elem}: {weight_percent[elem]:.1f} wt%") + print(f" Total: {total_weight:.1f} wt%") + + return { + 'atomic_percent': atomic_percent, + 'weight_percent': weight_percent, + 'intensities': intensities, + 'k_factors': k_factors, + 'method': 'cliff_lorimer', + 'total_atomic': total_atomic, + 'total_weight': total_weight + } + + ''' - cbar = plt.colorbar(im, ax=ax2) - cbar.set_label('Integrated Intensity', rotation=270, labelpad=15) + def _find_best_element_combinations(self, peak_energies, peak_intensities, tolerance=0.15): + """ + Find the best combination of elements that explains the detected peaks using a cost function. + + Parameters: + peak_energies : array-like + Detected peak positions in keV + peak_intensities : array-like + Detected peak intensities + tolerance : float, default 0.15 + Energy tolerance for peak matching in keV + + Returns: + set : Set of element symbols that best explain the detected peaks + """ + from itertools import combinations - if title is None: - title = f'Virtual Image Analysis - Energy Integration' - fig.suptitle(title, fontsize=14, fontweight='bold') + # Get element database + all_info = type(self).element_info + if all_info is None: + return set() - plt.tight_layout() - return fig, (ax1, ax2) + # Consider combinations of 1-4 elements (reasonable for most samples) + best_elements = set() + best_score = float('inf') + + # Get commonly analyzed elements (general EDS candidates) + general_elements = ['Fe', 'Pt', 'Cu', 'C', 'O', 'Ni', 'Co', 'Al', 'Si', 'Ti', 'Cr', 'Mn', 'Au', 'Ag', 'Zn', 'Ca', 'K', 'Na', 'Mg'] + available_elements = [el for el in general_elements if el in all_info] + + # Test combinations of different sizes + top_combinations = [] # Store combinations for analysis + for num_elements in range(1, min(5, len(available_elements)+1)): + for element_combo in combinations(available_elements, num_elements): + score = self._calculate_element_combo_score( + element_combo, peak_energies, peak_intensities, all_info, tolerance + ) + + top_combinations.append((score, element_combo)) + + if score < best_score: + best_score = score + best_elements = set(element_combo) + return best_elements - ## specturm picking + def _calculate_element_combo_score(self, element_combo, peak_energies, peak_intensities, all_info, tolerance): + """ + Calculate a cost function score for a given combination of elements. + Lower scores are better. + """ + score = 0.0 + explained_peaks = set() + + # General element categories (no specific element bias) + # Only penalize obvious contaminants/artifacts, don't favor specific elements + substrate_elements = {'Cu': 0.5, 'C': 0.3} # Mild penalty for substrate/grid elements + very_rare_elements = {'Ir': -1.0, 'Os': -1.0, 'Ru': -1.0} # Small penalty for very unlikely elements + + # Apply minimal element category adjustments + for element in element_combo: + if element in substrate_elements: + score += substrate_elements[element] # Small penalty for substrate + elif element in very_rare_elements: + score -= very_rare_elements[element] # Small penalty for very rare elements + + # For each detected peak, find if it can be explained by the element combination + for i, (peak_energy, peak_intensity) in enumerate(zip(peak_energies, peak_intensities)): + best_match_distance = float('inf') + best_line_weight = 0.0 + found_match = False + + # Check all elements in the combination + for element in element_combo: + if element in all_info: + for line_name, line_info in all_info[element].items(): + line_energy = line_info['energy (keV)'] + line_weight = line_info.get('weight', 0.5) + distance = abs(peak_energy - line_energy) + + if distance <= tolerance: + found_match = True + if distance < best_match_distance: + best_match_distance = distance + best_line_weight = line_weight + + if found_match: + explained_peaks.add(i) + # Add distance penalty (smaller is better) + score += best_match_distance + # Bonus for high-weight lines (major lines like Kα vs minor lines like M-lines) + score -= best_line_weight * 1.0 + else: + # Heavy penalty for unexplained peaks + score += 10.0 + + # Add penalty for unused elements (prefer simpler explanations) + unused_element_penalty = (len(element_combo) - 1) * 2.0 + score += unused_element_penalty + + # Add penalty for unexplained peaks + unexplained_peaks = len(peak_energies) - len(explained_peaks) + score += unexplained_peaks * 5.0 + + # Bonus for explaining multiple peaks with common elements (like Fe Kα + Kβ) + multi_peak_bonus = 0.0 + for element in element_combo: + if element in all_info: + element_peaks = 0 + major_peaks = 0 # Count major lines (weight > 0.5) + for line_name, line_info in all_info[element].items(): + line_energy = line_info['energy (keV)'] + line_weight = line_info.get('weight', 0.5) + for peak_energy in peak_energies: + if abs(peak_energy - line_energy) <= tolerance: + element_peaks += 1 + if line_weight > 0.5: + major_peaks += 1 + + if element_peaks > 1: + multi_peak_bonus += 2.0 # Bonus for elements with multiple matched peaks + if major_peaks > 0: + multi_peak_bonus += 1.0 # Additional bonus for major line matches + + score -= multi_peak_bonus + + return score - def image_to_spec(self, y, x, dy=None, dx=None, title=None): + def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_range=None, threshold=5.0, tolerance=0.15, mask=None, show_lines=True): """ Make and show a spectrum plot from a spatial ROI in a 3D EDS cube (E, Y, X). Parameters ---------- - y, x : int - Top-left pixel of the ROI. - dy, dx : int, optional - ROI size (height, width). Defaults to full image if None. - title : str, optional - Plot title (auto-filled if None). + roi : list or tuple, optional + Region of interest as [y, x, dy, dx] where: + - y, x: top-left pixel coordinates + - dy, dx: height and width of ROI + Use None for default values: + - [y, None, dy, None] → row y with height dy, full width + - [None, x, None, dx] → column x with width dx, full height + - [y, x, None, None] → from (y,x) to bottom-right corner + If roi=None, uses full image. Can also be [y, x] for single pixel. + energy_range : list or tuple, optional + Energy range to display as [min_energy, max_energy] in keV. + elements : list or dict, optional + Element symbols to plot as X-ray lines (e.g., ['Fe', 'Pt']). + If None, automatically detects elements from statistically significant peaks. + ignore_range : list or tuple, optional + Energy range to ignore during peak detection as [min_energy, max_energy] in keV. + E.g., [0, 2.5] ignores 0-2.5 keV during auto-detection. + threshold : float, optional + Statistical significance threshold (multiple of background noise). Default: 5.0 + tolerance : float, optional + Energy tolerance for X-ray line matching in keV. Default: 0.15 + mask : array, optional + Boolean mask for pixel selection. + show_lines : bool, optional + Whether to show element lines and/or auto-detected peaks. + Auto-enabled if elements are specified or auto-detection is used. Returns ------- (fig, ax) : tuple The Matplotlib Figure and Axes of the spectrum plot. """ - if dy is None: - dy = self.shape[1] - if dx is None: - dx = self.shape[2] + + + # ADJUST ROI BASED ON GIVEN FLAGS ----------------------------------------------- + # Parse ROI parameter + if roi is None: + # Full image + y, x, dy, dx = 0, 0, int(self.shape[1]), int(self.shape[2]) + elif len(roi) == 2: + # Single pixel [y, x] + y, x, dy, dx = int(roi[0]), int(roi[1]), 1, 1 + elif len(roi) == 4: + # Full ROI [y, x, dy, dx] with None support for defaults + y_val, x_val, dy_val, dx_val = roi + + # Handle None values with defaults + y = 0 if y_val is None else int(y_val) + x = 0 if x_val is None else int(x_val) + dy = int(self.shape[1]) - y if dy_val is None else int(dy_val) + dx = int(self.shape[2]) - x if dx_val is None else int(dx_val) + else: + raise ValueError("roi must be None, [y, x], or [y, x, dy, dx] (with None for defaults)") + + + # ERROR HANDLING ------------------------------------------------------------------- + errs = [] + Ymax = int(self.shape[1]) + Xmax = int(self.shape[2]) + + # type/NaN checks (optional if you already cast to int above) + for name, val in (("y", y), ("x", x), ("dy", dy), ("dx", dx)): + if val is None: + errs.append(f"{name} is None (missing after normalization).") + + # if any None, bail early to avoid arithmetic errors + if errs: + raise ValueError("Invalid ROI:\n - " + "\n - ".join(errs)) + + # basic constraints + if y < 0: errs.append(f"y={y} < 0") + if x < 0: errs.append(f"x={x} < 0") + if dy < 1: errs.append(f"dy={dy} < 1") + if dx < 1: errs.append(f"dx={dx} < 1") + + # starts within image + if y >= Ymax: errs.append(f"y start {y} out of bounds [0, {Ymax-1}]") + if x >= Xmax: errs.append(f"x start {x} out of bounds [0, {Xmax-1}]") + + # ends within image + end_y = y + dy + end_x = x + dx + if end_y > Ymax: errs.append(f"y+dy = {end_y} exceeds height {Ymax}") + if end_x > Xmax: errs.append(f"x+dx = {end_x} exceeds width {Xmax}") + + if errs: + raise ValueError("Invalid ROI:\n - " + "\n - ".join(errs)) + + + # SPECTRUM CALCULATION -------------------------------------------------------------- dE = float(self.sampling[0]) E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 E = E0 + dE * np.arange(self.shape[0]) - spec = np.empty(self.shape[0], dtype=float) - for k in range(self.shape[0]): - img = np.asarray(self.array[k], dtype=float) - roi = img[y:y+dy, x:x+dx] - if roi.size == 0: - raise ValueError("ROI is empty; check y/x/dy/dx.") - spec[k] = roi.mean() - - fig, ax = plt.subplots(figsize=(6, 4)) - ax.plot(E, spec) - ax.set_xlabel("Energy (keV)") - ax.set_ylabel("Intensity") - ax.set_title(title or f"Spectrum ROI y={y}:{y+dy}, x={x}:{x+dx}") + + # MASK HANDLING --------------------------------------------------------------------- + if mask is not None: + # Convert to ndarray and validate + mask = np.asarray(mask) + + # Check that it's a proper ndarray + if not isinstance(mask, np.ndarray): + raise TypeError(f"Mask must be a numpy ndarray, got {type(mask)}") + + # Check dimensions - must be 1D + if mask.ndim != 1: + raise ValueError(f"Mask must be 1-dimensional, got {mask.ndim}D array with shape {mask.shape}") + + # Convert to bool dtype and validate + if mask.dtype != bool: + try: + mask = mask.astype(bool) + except (ValueError, TypeError): + raise TypeError(f"Mask cannot be converted to boolean dtype from {mask.dtype}") + + # Check shape matches energy axis + arr = np.asarray(self.array, dtype=float) + if mask.shape != (arr.shape[0],): + raise ValueError(f"Mask shape {mask.shape} does not match energy axis shape ({arr.shape[0]},)") + + arr = arr[mask, :, :] # select only masked energy channels + spec = arr.sum(axis=(1,2)) if arr.shape[0] > 0 else np.zeros(0) + E = E[mask] # Mask the energy axis as well + else: + spec = np.empty(self.shape[0], dtype=float) + for k in range(self.shape[0]): + img = np.asarray(self.array[k], dtype=float) + roi = img[y:y+dy, x:x+dx] + if roi.size == 0: + raise ValueError("ROI is empty; check y/x/dy/dx.") + spec[k] = roi.mean() + + # Store ignore_range for later use in element line filtering + if ignore_range is None: + ignore_range = [0, 0.25] # Default: ignore 0-0.25 keV for element lines only + + + # PLOTTING --------------------------------------------------------------------------- + + # Create subplot layout: image on left, spectrum on right + fig, (ax_img, ax_spec) = plt.subplots(1, 2, figsize=(12, 4)) + + # LEFT PLOT: Show sum image with ROI highlighted + # Create sum image across all energy channels (or masked channels) + if mask is not None: + sum_img = np.asarray(self.array, dtype=float)[mask, :, :].sum(axis=0) + title_suffix = " (masked energies)" + else: + sum_img = np.asarray(self.array, dtype=float).sum(axis=0) + title_suffix = "" + + im = ax_img.imshow(sum_img, cmap='viridis', origin='lower') + ax_img.set_title(f"EDS Sum Image{title_suffix}") + ax_img.set_xlabel("X (pixels)") + ax_img.set_ylabel("Y (pixels)") + + # Highlight the ROI with a rectangle + rect = Rectangle((x-0.5, y-0.5), dx, dy, linewidth=2, edgecolor='red', facecolor='none', alpha=0.8) + ax_img.add_patch(rect) + + # Add colorbar for the image + plt.colorbar(im, ax=ax_img, label='Intensity') + + # RIGHT PLOT: Show spectrum + ax_spec.plot(E, spec, linewidth=1.5) + ax_spec.set_xlabel("Energy (keV)") + ax_spec.set_ylabel("Intensity") + ax_spec.set_title(f"Spectrum from ROI [{y}:{y+dy}, {x}:{x+dx}]") + ax_spec.grid(True, alpha=0.1) + + # Use ax_spec for all subsequent peak/line plotting + ax = ax_spec + + # HANDLE SHOW_LINES FLAG AND MODEL ELEMENTS ------------------------------------ + # Auto-enable show_lines if elements are specified or if auto-detection is needed + if show_lines is None: + show_lines = (elements is not None) or (hasattr(self, 'model_elements') and self.model_elements is not None) + + # Use model elements if no elements specified but model has elements + if elements is None and hasattr(self, 'model_elements') and self.model_elements is not None: + elements = list(self.model_elements.keys()) + + # Skip all line plotting if show_lines is False + if not show_lines: + fig.tight_layout() + plt.show() + return fig, (ax_img, ax_spec) + + # AUTO-DETECT ELEMENTS FROM STATISTICALLY SIGNIFICANT PEAKS ------------------- + auto_peak_labels = [] # Store label positions to avoid overlap + if elements is None: + try: + # Statistical peak detection based on intensity distribution + # Step 1: Find all potential peaks + peak_indices, peak_properties = find_peaks(spec, height=0, distance=5) + peak_heights = peak_properties['peak_heights'] + + # Step 2: Calculate background statistics + # Use lower percentiles to estimate background level + background_level = np.percentile(spec, 25) # 25th percentile as background + background_std = np.std(spec[spec <= np.percentile(spec, 50)]) # Std of lower half + + # Step 3: Statistical significance threshold + # A peak is significant if it's above background + N*sigma + significance_threshold = background_level + 3.0 * background_std + + # Step 4: Filter peaks by statistical significance + significant_peaks = [] + for i, (peak_idx, height) in enumerate(zip(peak_indices, peak_heights)): + peak_energy = E[peak_idx] + + # Skip peaks in ignore range + if ignore_range is not None and len(ignore_range) == 2: + min_ignore, max_ignore = ignore_range + if min_ignore <= peak_energy <= max_ignore: + continue + + # Check statistical significance + if height > significance_threshold: + significant_peaks.append((peak_idx, height, peak_energy)) + + # Step 5: Additional filtering based on relative prominence + if len(significant_peaks) > 0: + # Calculate signal-to-noise ratio for each significant peak + max_intensity = max([height for _, height, _ in significant_peaks]) + + # Use a more inclusive approach: take significant peaks with good SNR + # Lower prominence threshold to 5% and include more peaks + prominence_threshold = 0.05 * max_intensity + important_peaks = [] + + for peak_idx, height, energy in significant_peaks: + snr = height / background_std if background_std > 0 else float('inf') + # Include peaks above prominence OR with good SNR + if height >= prominence_threshold or snr >= 5.0: + important_peaks.append((peak_idx, height, energy, snr)) + + print(f" {len(important_peaks)} peaks above prominence threshold (5% of max) or SNR >= 5") + print(f" → ALL {len(important_peaks)} peaks used for element identification analysis") + + # Sort by signal-to-noise ratio for analysis + important_peaks.sort(key=lambda x: x[3], reverse=True) # Sort by SNR + + # Use all important peaks for cost function analysis + analysis_peak_indices = [peak[0] for peak in important_peaks] + + # For display, limit to most significant peaks if there are many + display_limit = min(len(important_peaks), 15) # Show max 15 in table + print(f" → Showing top {display_limit} peaks in detailed table below") + top_peaks = [peak[0] for peak in important_peaks[:display_limit]] + else: + analysis_peak_indices = [] + top_peaks = [] + + # Simple element line matching - only include significant lines + element_lines_db = [] + all_info = type(self).element_info + if all_info is not None: + for elem, lines in all_info.items(): + # Get top 3 weighted lines per element + weighted_lines = [(info['weight'], info['energy (keV)'], elem, line) + for line, info in lines.items() + if info['energy (keV)'] <= 12.0] # Ignore > 12 keV + # Sort by weight (highest first) and take top 3 + weighted_lines.sort(reverse=True) + for weight, energy, elem, line in weighted_lines[:3]: + if weight > 0.1: # Only include lines with significant weight + element_lines_db.append((energy, elem, line, weight)) + + # Use cost function with all statistically significant peaks + if len(analysis_peak_indices) > 0: + analysis_energies = E[analysis_peak_indices] + analysis_intensities = spec[analysis_peak_indices] + + # Find optimal element combination using all significant peaks + best_elements = self._find_best_element_combinations( + analysis_energies, analysis_intensities, tolerance + ) + + peak_energies = E[top_peaks] # For display table + peak_intensities = spec[top_peaks] + else: + best_elements = set() + peak_energies = [] + peak_intensities = [] + + # Now create detailed peak matching report (only if we found significant peaks) + if len(top_peaks) > 0: + peak_data = [] + for idx in top_peaks: + peak_energy = E[idx] + peak_intensity = spec[idx] + # Find matches within the identified elements first, then others + matches = [] + if element_lines_db: + distances = [] + for el_energy, elem, line, weight in element_lines_db: + distance = abs(el_energy - peak_energy) + if distance <= tolerance: + # Prioritize matches from identified elements + priority = 0 if elem in best_elements else 1 + distances.append((priority, distance, elem, line, el_energy, weight)) + + # Sort by priority (identified elements first), then distance + distances.sort(key=lambda x: (x[0], x[1])) + matches = [(d[1], d[2], d[3], d[4], d[5]) for d in distances[:3]] # distance, elem, line, energy, weight + + peak_data.append((peak_energy, peak_intensity, matches, idx)) + + # Sort by energy for table display + peak_data_sorted = sorted(peak_data, key=lambda x: x[0]) + + # Print detailed peak summary table with backup options + print(f"\nIdentified Elements: {', '.join(sorted(best_elements)) if best_elements else 'None detected'}") + print(f"{'Energy (keV)':<12} {'Intensity':<12} {'Primary Match':<20} {'2nd Option':<20} {'3rd Option':<20}") + print("-"*84) + + for energy, intensity, matches, idx in peak_data_sorted: + # Primary match + if len(matches) > 0: + elem = matches[0][1] + line = matches[0][2] + if elem == 'Cu': + primary = f"{elem} {line} (grid)" + elif elem == 'C': + primary = f"{elem} {line} (carbon)" + else: + primary = f"{elem} {line}" + else: + primary = "Unknown" + + # Secondary match + if len(matches) > 1: + elem2 = matches[1][1] + line2 = matches[1][2] + if elem2 == 'Cu': + secondary = f"{elem2} {line2} (grid)" + elif elem2 == 'C': + secondary = f"{elem2} {line2} (carbon)" + else: + secondary = f"{elem2} {line2}" + else: + secondary = "-" + + # Tertiary match + if len(matches) > 2: + elem3 = matches[2][1] + line3 = matches[2][2] + if elem3 == 'Cu': + tertiary = f"{elem3} {line3} (grid)" + elif elem3 == 'C': + tertiary = f"{elem3} {line3} (carbon)" + else: + tertiary = f"{elem3} {line3}" + else: + tertiary = "-" + + print(f"{energy:<12.3f} {intensity:<12.1f} {primary:<20} {secondary:<20} {tertiary:<20}") + print("-"*84) + + # Plot lines for the identified elements + y_max = ax_spec.get_ylim()[1] + element_colors = ['red', 'blue', 'green', 'orange', 'purple', 'brown', 'pink', 'gray'] + + # Plot lines for cost-function identified elements + for color_idx, elem in enumerate(list(best_elements)): + if elem in all_info: + elem_color = element_colors[color_idx % len(element_colors)] + + # Get top 3 weighted lines for this element (same logic as above) + weighted_lines = [(info['weight'], info['energy (keV)'], line_name) + for line_name, info in all_info[elem].items() + if info['energy (keV)'] <= 12.0] + weighted_lines.sort(reverse=True) + + # Plot only top 3 significant lines + for weight, line_energy, line_name in weighted_lines[:3]: + if weight > 0.1 and line_energy >= E[0] and line_energy <= E[-1]: + # Skip lines in ignore range + if ignore_range is not None and len(ignore_range) == 2: + min_ignore, max_ignore = ignore_range + if min_ignore <= line_energy <= max_ignore: + continue + + # Plot line at theoretical position + line_alpha = 0.3 + 0.2 * weight # More prominent for higher weight + ax_spec.axvline(line_energy, color=elem_color, linestyle='--', + alpha=min(line_alpha, 0.8), linewidth=1.5) + + # Add label with better positioning and spacing + y_pos = y_max * (0.85 - color_idx * 0.08) # Start higher, space down + label_x = line_energy + 0.1 # More offset from line + + # Add special labeling for substrate elements + if elem == 'Cu': + label_text = f"{elem} {line_name} (grid)" + elif elem == 'C': + label_text = f"{elem} {line_name} (carbon)" + else: + label_text = f"{elem} {line_name}" + + ax_spec.text(label_x, y_pos, label_text, + rotation=90, va='bottom', ha='left', + fontsize=8, color=elem_color, weight='normal', + alpha=0.8) + except ImportError: + print("scipy is required for auto peak labeling. Please install scipy.") + + # ELEMENT LINES --------------------------------------------------------------------- + lines_to_plot = None + if elements is not None: + if isinstance(elements, list): + # Convert list of element symbols to dict using class element_info + all_info = type(self).element_info + if all_info is not None: + lines_to_plot = {el: all_info[el] for el in elements if el in all_info} + elif isinstance(elements, dict): + lines_to_plot = elements + elif hasattr(self, 'model_elements'): + # Use model elements if available + lines_to_plot = self.model_elements + + if lines_to_plot is not None: + E_min = E[0] if len(E) > 0 else 0 + E_max = E[-1] if len(E) > 0 else 20 + element_labels = [] # Store element label positions (energy, y_position) + element_line_data = [] # Store element line data for summary table + y_max = ax.get_ylim()[1] + colors = ['orange', 'red', 'blue', 'purple', 'brown', 'pink', 'gray', 'olive', 'cyan', 'magenta'] + + # Collect all lines to plot first for better positioning + all_lines_to_plot = [] + + # First pass: collect all lines that will be plotted and track elements + elements_to_label = {} # Track which element gets labeled and where + + for color_idx, (elem, lines) in enumerate(lines_to_plot.items()): + color = colors[color_idx % len(colors)] + + # Get top 3 weighted lines for this element + weighted_lines = [(info['weight'], info['energy (keV)'], line, info) + for line, info in lines.items() + if info['energy (keV)'] <= 12.0] # Ignore > 12 keV + weighted_lines.sort(reverse=True) + + element_lines = [] # Lines for this element that will be plotted + + # Process only top 3 significant lines + for weight, energy, line, info in weighted_lines[:3]: + if weight <= 0.1: # Skip lines with very low weight + continue + + # Skip lines outside energy range + if energy < E_min or energy > E_max: + continue + + # Skip element lines in ignore range + if ignore_range is not None and len(ignore_range) == 2: + min_ignore, max_ignore = ignore_range + if min_ignore <= energy <= max_ignore: + continue + + all_lines_to_plot.append((energy, elem, line, color, weight)) + element_lines.append((energy, line, weight)) + + # Determine which line should get the element label (highest weight) + if element_lines: + # Sort by weight and choose the most prominent line for labeling + element_lines.sort(key=lambda x: x[2], reverse=True) + label_energy, label_line, _ = element_lines[0] + elements_to_label[elem] = (label_energy, label_line, color) + + # Sort all lines by energy for better positioning + all_lines_to_plot.sort(key=lambda x: x[0]) + + # Second pass: plot all lines (but only label once per element) + for i, (energy, elem, line, color, weight) in enumerate(all_lines_to_plot): + # Find the closest channel + idx = np.abs(E - energy).argmin() + + # Weight-based alpha (more prominent for higher weights) + line_alpha = 0.3 + 0.4 * weight + ax.axvline(E[idx], color=color, linestyle='-', alpha=min(line_alpha, 0.8), linewidth=1.5) + + # Store element line data for summary table + intensity = spec[idx] if 'spec' in locals() else 0 + element_line_data.append((E[idx], intensity, elem, line, weight)) + + # Third pass: Add labels only for the most prominent line of each element + # Sort elements by energy for systematic positioning + sorted_elements = sorted(elements_to_label.items(), key=lambda x: x[1][0]) + + for i, (elem, (label_energy, label_line, color)) in enumerate(sorted_elements): + # Find the closest channel for the label energy + idx = np.abs(E - label_energy).argmin() + + # Simple vertical spacing - each element gets its own height level + base_y = y_max * 0.9 # Start near top + vertical_spacing = y_max * 0.08 # 8% spacing between labels + + # Position labels at regular intervals going down + final_y_pos = base_y - (i * vertical_spacing) + + # Keep within reasonable bounds + final_y_pos = max(final_y_pos, y_max * 0.2) # Don't go below 20% + + element_labels.append((E[idx], final_y_pos)) + + # Add label with better offset and styling + label_x = E[idx] - 0.1 # Offset from line + + # Add special labeling for substrate elements (element name only, no line designation) + if elem == 'Cu': + label_text = f"{elem} (grid)" + elif elem == 'C': + label_text = f"{elem} (carbon)" + else: + label_text = elem # Just the element symbol + + ax.text(label_x, final_y_pos, label_text, rotation=90, va='bottom', ha='right', + fontsize=8, color=color, weight='normal', alpha=0.8, clip_on=True) + + # Print concise element lines summary + if element_line_data: + element_line_data_sorted = sorted(element_line_data, key=lambda x: x[0]) # Sort by energy + print(f"\nElement Lines: {', '.join([f'{elem} {line}' for _, _, elem, line, _ in element_line_data_sorted])}") + fig.tight_layout() plt.show() - return fig, ax + return fig, (ax_img, ax_spec) + + + +Dataset3dspectroscopy.load_element_info() diff --git a/src/quantem/spectroscopy/xray_lines.json b/src/quantem/spectroscopy/xray_lines.json new file mode 100644 index 00000000..8a465ad2 --- /dev/null +++ b/src/quantem/spectroscopy/xray_lines.json @@ -0,0 +1,3985 @@ +{ + "elements": { + "Ac": { + "Ka": { + "energy (keV)": 90.884, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 102.846, + "weight": 0.15 + }, + "La": { + "energy (keV)": 12.652, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 15.713, + "weight": 0.4 + }, + "Lb2": { + "energy (keV)": 15.234, + "weight": 0.236 + }, + "Lb3": { + "energy (keV)": 15.931, + "weight": 0.06 + }, + "Lg1": { + "energy (keV)": 18.4083, + "weight": 0.08 + }, + "Lg3": { + "energy (keV)": 18.95, + "weight": 0.017 + }, + "Ll": { + "energy (keV)": 10.869, + "weight": 0.06549 + }, + "Ln": { + "energy (keV)": 14.0812, + "weight": 0.0133 + }, + "M2N4": { + "energy (keV)": 3.9811, + "weight": 0.00674 + }, + "M3O4": { + "energy (keV)": 3.82586, + "weight": 0.01 + }, + "M3O5": { + "energy (keV)": 3.83206, + "weight": 0.01 + }, + "Ma": { + "energy (keV)": 2.9239330000000003, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 3.06626, + "weight": 0.64124 + }, + "Mg": { + "energy (keV)": 3.272, + "weight": 0.33505 + }, + "Mz": { + "energy (keV)": 2.329, + "weight": 0.03512 + } + }, + "Ag": { + "Ka": { + "energy (keV)": 22.1629, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 24.9426, + "weight": 0.15 + }, + "La": { + "energy (keV)": 2.9844, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 3.1509, + "weight": 0.35175 + }, + "Lb2": { + "energy (keV)": 3.3478, + "weight": 0.1165 + }, + "Lb3": { + "energy (keV)": 3.2344, + "weight": 0.0737 + }, + "Lb4": { + "energy (keV)": 3.2034, + "weight": 0.0444 + }, + "Lg1": { + "energy (keV)": 3.5204, + "weight": 0.03735 + }, + "Lg3": { + "energy (keV)": 3.7499, + "weight": 0.014 + }, + "Ll": { + "energy (keV)": 2.6336, + "weight": 0.04129 + }, + "Ln": { + "energy (keV)": 2.8062, + "weight": 0.0131 + } + }, + "Al": { + "Ka": { + "energy (keV)": 1.4865, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 1.5596, + "weight": 0.0132 + } + }, + "Am": {}, + "Ar": { + "Ka": { + "energy (keV)": 2.9577, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 3.1905, + "weight": 0.10169 + } + }, + "As": { + "Ka": { + "energy (keV)": 10.5436, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 11.7262, + "weight": 0.14589 + }, + "La": { + "energy (keV)": 1.2819, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 1.3174, + "weight": 0.16704 + }, + "Lb3": { + "energy (keV)": 1.386, + "weight": 0.04769 + }, + "Ll": { + "energy (keV)": 1.1196, + "weight": 0.04929 + }, + "Ln": { + "energy (keV)": 1.1551, + "weight": 0.01929 + } + }, + "At": { + "Ka": { + "energy (keV)": 81.5164, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 92.3039, + "weight": 0.15 + }, + "La": { + "energy (keV)": 11.4268, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 13.876, + "weight": 0.38048 + }, + "Lb2": { + "energy (keV)": 13.73812, + "weight": 0.2305 + }, + "Lb3": { + "energy (keV)": 14.067, + "weight": 0.06 + }, + "Lb4": { + "energy (keV)": 13.485, + "weight": 0.05809 + }, + "Lg1": { + "energy (keV)": 16.2515, + "weight": 0.08 + }, + "Lg3": { + "energy (keV)": 16.753, + "weight": 0.017 + }, + "Ll": { + "energy (keV)": 9.8965, + "weight": 0.06179 + }, + "Ln": { + "energy (keV)": 12.4677, + "weight": 0.0132 + }, + "M2N4": { + "energy (keV)": 3.4748, + "weight": 0.00863 + }, + "Mb": { + "energy (keV)": 2.71162, + "weight": 0.64124 + }, + "Mg": { + "energy (keV)": 2.95061, + "weight": 0.21845 + }, + "Mz": { + "energy (keV)": 2.0467, + "weight": 0.00354 + } + }, + "Au": { + "Ka": { + "energy (keV)": 68.8062, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 77.9819, + "weight": 0.15 + }, + "La": { + "energy (keV)": 9.713, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 11.4425, + "weight": 0.40151 + }, + "Lb2": { + "energy (keV)": 11.5848, + "weight": 0.21949 + }, + "Lb3": { + "energy (keV)": 11.6098, + "weight": 0.069 + }, + "Lb4": { + "energy (keV)": 11.205, + "weight": 0.0594 + }, + "Lg1": { + "energy (keV)": 13.3816, + "weight": 0.08407 + }, + "Lg3": { + "energy (keV)": 13.8074, + "weight": 0.0194 + }, + "Ll": { + "energy (keV)": 8.4938, + "weight": 0.0562 + }, + "Ln": { + "energy (keV)": 10.3087, + "weight": 0.01379 + }, + "M2N4": { + "energy (keV)": 2.7958, + "weight": 0.02901 + }, + "M3O4": { + "energy (keV)": 2.73469, + "weight": 0.005 + }, + "M3O5": { + "energy (keV)": 2.73621, + "weight": 0.01 + }, + "Ma": { + "energy (keV)": 2.1229, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 2.2047, + "weight": 0.59443 + }, + "Mg": { + "energy (keV)": 2.4091, + "weight": 0.08505 + }, + "Mz": { + "energy (keV)": 1.6603, + "weight": 0.01344 + } + }, + "B": { + "Ka": { + "energy (keV)": 0.1833, + "weight": 1.0 + } + }, + "Ba": { + "Ka": { + "energy (keV)": 32.1936, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 36.3784, + "weight": 0.15 + }, + "La": { + "energy (keV)": 4.4663, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 4.8275, + "weight": 0.43048 + }, + "Lb2": { + "energy (keV)": 5.1571, + "weight": 0.1905 + }, + "Lb3": { + "energy (keV)": 4.9266, + "weight": 0.13779 + }, + "Lb4": { + "energy (keV)": 4.8521, + "weight": 0.08859 + }, + "Lg1": { + "energy (keV)": 5.5311, + "weight": 0.07487 + }, + "Lg3": { + "energy (keV)": 5.8091, + "weight": 0.0331 + }, + "Ll": { + "energy (keV)": 3.9542, + "weight": 0.04299 + }, + "Ln": { + "energy (keV)": 4.3308, + "weight": 0.0151 + } + }, + "Be": { + "Ka": { + "energy (keV)": 0.10258, + "weight": 1.0 + } + }, + "Bi": { + "Ka": { + "energy (keV)": 77.1073, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 87.349, + "weight": 0.15 + }, + "La": { + "energy (keV)": 10.839, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 13.0235, + "weight": 0.4 + }, + "Lb2": { + "energy (keV)": 12.9786, + "weight": 0.2278 + }, + "Lb3": { + "energy (keV)": 13.2106, + "weight": 0.0607 + }, + "Lb4": { + "energy (keV)": 12.6912, + "weight": 0.05639 + }, + "Lg1": { + "energy (keV)": 15.2475, + "weight": 0.08 + }, + "Lg3": { + "energy (keV)": 15.7086, + "weight": 0.017 + }, + "Ll": { + "energy (keV)": 9.4195, + "weight": 0.06 + }, + "Ln": { + "energy (keV)": 11.712, + "weight": 0.0134 + }, + "M2N4": { + "energy (keV)": 3.2327, + "weight": 0.00863 + }, + "M3O4": { + "energy (keV)": 3.1504, + "weight": 0.01 + }, + "M3O5": { + "energy (keV)": 3.1525, + "weight": 0.01 + }, + "Ma": { + "energy (keV)": 2.4222, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 2.5257, + "weight": 0.64124 + }, + "Mg": { + "energy (keV)": 2.7369, + "weight": 0.21845 + }, + "Mz": { + "energy (keV)": 1.9007, + "weight": 0.0058 + } + }, + "Br": { + "Ka": { + "energy (keV)": 11.9238, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 13.2922, + "weight": 0.15289 + }, + "La": { + "energy (keV)": 1.4809, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 1.5259, + "weight": 0.39175 + }, + "Lb3": { + "energy (keV)": 1.6005, + "weight": 0.04629 + }, + "Ll": { + "energy (keV)": 1.2934, + "weight": 0.0462 + }, + "Ln": { + "energy (keV)": 1.3395, + "weight": 0.0182 + } + }, + "C": { + "Ka": { + "energy (keV)": 0.2774, + "weight": 1.0 + } + }, + "Ca": { + "Ka": { + "energy (keV)": 3.6917, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 4.0127, + "weight": 0.112 + }, + "La": { + "energy (keV)": 0.3464, + "weight": 0.0 + }, + "Ll": { + "energy (keV)": 0.3027, + "weight": 1.0 + }, + "Ln": { + "energy (keV)": 0.3063, + "weight": 0.23 + } + }, + "Cd": { + "Ka": { + "energy (keV)": 23.1737, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 26.0947, + "weight": 0.15 + }, + "La": { + "energy (keV)": 3.1338, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 3.3165, + "weight": 0.35704 + }, + "Lb2": { + "energy (keV)": 3.5282, + "weight": 0.1288 + }, + "Lb3": { + "energy (keV)": 3.4015, + "weight": 0.07719 + }, + "Lb4": { + "energy (keV)": 3.3673, + "weight": 0.0469 + }, + "Lg1": { + "energy (keV)": 3.7177, + "weight": 0.0416 + }, + "Lg3": { + "energy (keV)": 3.9511, + "weight": 0.0151 + }, + "Ll": { + "energy (keV)": 2.7673, + "weight": 0.04169 + }, + "Ln": { + "energy (keV)": 2.9568, + "weight": 0.0132 + } + }, + "Ce": { + "Ka": { + "energy (keV)": 34.7196, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 39.2576, + "weight": 0.15 + }, + "La": { + "energy (keV)": 4.8401, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 5.2629, + "weight": 0.43 + }, + "Lb2": { + "energy (keV)": 5.6134, + "weight": 0.19399 + }, + "Lb3": { + "energy (keV)": 5.3634, + "weight": 0.1325 + }, + "Lb4": { + "energy (keV)": 5.276, + "weight": 0.08699 + }, + "Lg1": { + "energy (keV)": 6.0542, + "weight": 0.0764 + }, + "Lg3": { + "energy (keV)": 6.3416, + "weight": 0.0324 + }, + "Ll": { + "energy (keV)": 4.2888, + "weight": 0.0436 + }, + "Ln": { + "energy (keV)": 4.7296, + "weight": 0.015 + }, + "M2N4": { + "energy (keV)": 1.1628, + "weight": 0.08 + }, + "Ma": { + "energy (keV)": 0.8455, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 0.8154, + "weight": 0.91 + }, + "Mg": { + "energy (keV)": 1.0754, + "weight": 0.5 + }, + "Mz": { + "energy (keV)": 0.6761, + "weight": 0.07 + } + }, + "Cl": { + "Ka": { + "energy (keV)": 2.6224, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 2.8156, + "weight": 0.0838 + } + }, + "Co": { + "Ka": { + "energy (keV)": 6.9303, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 7.6494, + "weight": 0.1277 + }, + "La": { + "energy (keV)": 0.7757, + "weight": 1.0 + }, + "Lb3": { + "energy (keV)": 0.8661, + "weight": 0.0238 + }, + "Ll": { + "energy (keV)": 0.6779, + "weight": 0.2157 + }, + "Ln": { + "energy (keV)": 0.6929, + "weight": 0.0833 + } + }, + "Cr": { + "Ka": { + "energy (keV)": 5.4147, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 5.9467, + "weight": 0.134 + }, + "La": { + "energy (keV)": 0.5722, + "weight": 1.0 + }, + "Lb3": { + "energy (keV)": 0.6521, + "weight": 0.0309 + }, + "Ll": { + "energy (keV)": 0.5004, + "weight": 0.6903 + }, + "Ln": { + "energy (keV)": 0.5096, + "weight": 0.2353 + } + }, + "Cs": { + "Ka": { + "energy (keV)": 30.9727, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 34.987, + "weight": 0.15 + }, + "La": { + "energy (keV)": 4.2864, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 4.6199, + "weight": 0.42983 + }, + "Lb2": { + "energy (keV)": 4.9354, + "weight": 0.19589 + }, + "Lb3": { + "energy (keV)": 4.7167, + "weight": 0.1399 + }, + "Lb4": { + "energy (keV)": 4.6493, + "weight": 0.08869 + }, + "Lg1": { + "energy (keV)": 5.2806, + "weight": 0.07215 + }, + "Lg3": { + "energy (keV)": 5.5527, + "weight": 0.0325 + }, + "Ll": { + "energy (keV)": 3.7948, + "weight": 0.04269 + }, + "Ln": { + "energy (keV)": 4.1423, + "weight": 0.0152 + } + }, + "Cu": { + "Ka": { + "energy (keV)": 8.0478, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 8.9053, + "weight": 0.13157 + }, + "La": { + "energy (keV)": 0.9295, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 0.9494, + "weight": 0.03197 + }, + "Lb3": { + "energy (keV)": 1.0225, + "weight": 0.00114 + }, + "Ll": { + "energy (keV)": 0.8113, + "weight": 0.08401 + }, + "Ln": { + "energy (keV)": 0.8312, + "weight": 0.01984 + } + }, + "Dy": { + "Ka": { + "energy (keV)": 45.9984, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 52.1129, + "weight": 0.15 + }, + "La": { + "energy (keV)": 6.4952, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 7.2481, + "weight": 0.444 + }, + "Lb2": { + "energy (keV)": 7.6359, + "weight": 0.2 + }, + "Lb3": { + "energy (keV)": 7.3702, + "weight": 0.12529 + }, + "Lb4": { + "energy (keV)": 7.204, + "weight": 0.0891 + }, + "Lg1": { + "energy (keV)": 8.4264, + "weight": 0.08295 + }, + "Lg3": { + "energy (keV)": 8.7529, + "weight": 0.0319 + }, + "Ll": { + "energy (keV)": 5.7433, + "weight": 0.0473 + }, + "Ln": { + "energy (keV)": 6.5338, + "weight": 0.01489 + }, + "M2N4": { + "energy (keV)": 1.6876, + "weight": 0.008 + }, + "Ma": { + "energy (keV)": 1.2907, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 1.3283, + "weight": 0.76 + }, + "Mg": { + "energy (keV)": 1.5214, + "weight": 0.08505 + }, + "Mz": { + "energy (keV)": 1.002, + "weight": 0.06 + } + }, + "Er": { + "Ka": { + "energy (keV)": 49.1276, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 55.6737, + "weight": 0.15 + }, + "La": { + "energy (keV)": 6.9486, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 7.811, + "weight": 0.45263 + }, + "Lb2": { + "energy (keV)": 8.1903, + "weight": 0.2005 + }, + "Lb3": { + "energy (keV)": 7.9395, + "weight": 0.1258 + }, + "Lb4": { + "energy (keV)": 7.7455, + "weight": 0.0922 + }, + "Lg1": { + "energy (keV)": 9.0876, + "weight": 0.08487 + }, + "Lg3": { + "energy (keV)": 9.4313, + "weight": 0.0324 + }, + "Ll": { + "energy (keV)": 6.1514, + "weight": 0.0482 + }, + "Ln": { + "energy (keV)": 7.0578, + "weight": 0.0153 + }, + "M2N4": { + "energy (keV)": 1.8291, + "weight": 0.0045 + }, + "Ma": { + "energy (keV)": 1.405, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 1.449, + "weight": 0.59443 + }, + "Mg": { + "energy (keV)": 1.6442, + "weight": 0.08505 + }, + "Mz": { + "energy (keV)": 1.0893, + "weight": 0.06 + } + }, + "Eu": { + "Ka": { + "energy (keV)": 41.5421, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 47.0384, + "weight": 0.15 + }, + "La": { + "energy (keV)": 5.846, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 6.4565, + "weight": 0.43904 + }, + "Lb2": { + "energy (keV)": 6.8437, + "weight": 0.1985 + }, + "Lb3": { + "energy (keV)": 6.5714, + "weight": 0.1265 + }, + "Lb4": { + "energy (keV)": 6.4381, + "weight": 0.0874 + }, + "Lg1": { + "energy (keV)": 7.4839, + "weight": 0.08064 + }, + "Lg3": { + "energy (keV)": 7.7954, + "weight": 0.0318 + }, + "Ll": { + "energy (keV)": 5.1769, + "weight": 0.04559 + }, + "Ln": { + "energy (keV)": 5.8171, + "weight": 0.015 + }, + "M2N4": { + "energy (keV)": 1.4807, + "weight": 0.013 + }, + "Ma": { + "energy (keV)": 1.0991, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 1.15769, + "weight": 0.87 + }, + "Mg": { + "energy (keV)": 1.3474, + "weight": 0.26 + }, + "Mz": { + "energy (keV)": 0.8743, + "weight": 0.06 + } + }, + "F": { + "Ka": { + "energy (keV)": 0.6768, + "weight": 1.0 + } + }, + "Fe": { + "Ka": { + "energy (keV)": 6.4039, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 7.058, + "weight": 0.1272 + }, + "La": { + "energy (keV)": 0.7045, + "weight": 1.0 + }, + "Lb3": { + "energy (keV)": 0.7921, + "weight": 0.02448 + }, + "Ll": { + "energy (keV)": 0.6152, + "weight": 0.3086 + }, + "Ln": { + "energy (keV)": 0.6282, + "weight": 0.12525 + } + }, + "Fr": { + "Ka": { + "energy (keV)": 86.1058, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 97.474, + "weight": 0.15 + }, + "La": { + "energy (keV)": 12.0315, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 14.7703, + "weight": 0.38327 + }, + "Lb2": { + "energy (keV)": 14.4542, + "weight": 0.2337 + }, + "Lb3": { + "energy (keV)": 14.976, + "weight": 0.05969 + }, + "Lb4": { + "energy (keV)": 14.312, + "weight": 0.0603 + }, + "Lg1": { + "energy (keV)": 17.3032, + "weight": 0.08 + }, + "Lg3": { + "energy (keV)": 17.829, + "weight": 0.017 + }, + "Ll": { + "energy (keV)": 10.3792, + "weight": 0.06339 + }, + "Ln": { + "energy (keV)": 13.2545, + "weight": 0.0134 + }, + "M2N4": { + "energy (keV)": 3.7237, + "weight": 0.00674 + }, + "Mb": { + "energy (keV)": 2.88971, + "weight": 0.64124 + }, + "Mg": { + "energy (keV)": 3.086, + "weight": 0.21845 + }, + "Mz": { + "energy (keV)": 2.1897, + "weight": 0.0058 + } + }, + "Ga": { + "Ka": { + "energy (keV)": 9.2517, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 10.2642, + "weight": 0.1287 + }, + "La": { + "energy (keV)": 1.098, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 1.1249, + "weight": 0.16704 + }, + "Lb3": { + "energy (keV)": 1.1948, + "weight": 0.0461 + }, + "Ll": { + "energy (keV)": 0.9573, + "weight": 0.0544 + }, + "Ln": { + "energy (keV)": 0.9842, + "weight": 0.02509 + } + }, + "Gd": { + "Ka": { + "energy (keV)": 42.9963, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 48.6951, + "weight": 0.15 + }, + "La": { + "energy (keV)": 6.0576, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 6.7131, + "weight": 0.44127 + }, + "Lb2": { + "energy (keV)": 7.1023, + "weight": 0.2014 + }, + "Lb3": { + "energy (keV)": 6.8316, + "weight": 0.1255 + }, + "Lb4": { + "energy (keV)": 6.6873, + "weight": 0.08789 + }, + "Lg1": { + "energy (keV)": 7.7898, + "weight": 0.08207 + }, + "Lg3": { + "energy (keV)": 8.1047, + "weight": 0.032 + }, + "Ll": { + "energy (keV)": 5.362, + "weight": 0.04629 + }, + "Ln": { + "energy (keV)": 6.0495, + "weight": 0.01489 + }, + "M2N4": { + "energy (keV)": 1.5478, + "weight": 0.014 + }, + "Ma": { + "energy (keV)": 1.17668, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 1.20792, + "weight": 0.88 + }, + "Mg": { + "energy (keV)": 1.4035, + "weight": 0.261 + }, + "Mz": { + "energy (keV)": 0.9143, + "weight": 0.06 + } + }, + "Ge": { + "Ka": { + "energy (keV)": 9.8864, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 10.9823, + "weight": 0.1322 + }, + "La": { + "energy (keV)": 1.188, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 1.2191, + "weight": 0.16704 + }, + "Lb3": { + "energy (keV)": 1.2935, + "weight": 0.04429 + }, + "Ll": { + "energy (keV)": 1.0367, + "weight": 0.0511 + }, + "Ln": { + "energy (keV)": 1.0678, + "weight": 0.02 + } + }, + "H": { + "Ka": { + "energy (keV)": 0.0013598, + "weight": 1.0 + } + }, + "He": { + "Ka": { + "energy (keV)": 0.0024587, + "weight": 1.0 + } + }, + "Hf": { + "Ka": { + "energy (keV)": 55.7901, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 63.2432, + "weight": 0.15 + }, + "La": { + "energy (keV)": 7.899, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 9.023, + "weight": 0.46231 + }, + "Lb2": { + "energy (keV)": 9.347, + "weight": 0.2048 + }, + "Lb3": { + "energy (keV)": 9.1631, + "weight": 0.1316 + }, + "Lb4": { + "energy (keV)": 8.9053, + "weight": 0.10189 + }, + "Lg1": { + "energy (keV)": 10.5156, + "weight": 0.08968 + }, + "Lg3": { + "energy (keV)": 10.8903, + "weight": 0.0347 + }, + "Ll": { + "energy (keV)": 6.9598, + "weight": 0.05089 + }, + "Ln": { + "energy (keV)": 8.1385, + "weight": 0.0158 + }, + "M2N4": { + "energy (keV)": 2.1416, + "weight": 0.01 + }, + "Ma": { + "energy (keV)": 1.6446, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 1.6993, + "weight": 0.59443 + }, + "Mg": { + "energy (keV)": 1.8939, + "weight": 0.08505 + }, + "Mz": { + "energy (keV)": 1.2813, + "weight": 0.06 + } + }, + "Hg": { + "Ka": { + "energy (keV)": 70.8184, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 80.2552, + "weight": 0.15 + }, + "La": { + "energy (keV)": 9.989, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 11.8238, + "weight": 0.39504 + }, + "Lb2": { + "energy (keV)": 11.9241, + "weight": 0.2221 + }, + "Lb3": { + "energy (keV)": 11.9922, + "weight": 0.06469 + }, + "Lb4": { + "energy (keV)": 11.5608, + "weight": 0.0566 + }, + "Lg1": { + "energy (keV)": 13.8304, + "weight": 0.0832 + }, + "Lg3": { + "energy (keV)": 14.2683, + "weight": 0.0184 + }, + "Ll": { + "energy (keV)": 8.7223, + "weight": 0.05709 + }, + "Ln": { + "energy (keV)": 10.6471, + "weight": 0.0136 + }, + "M2N4": { + "energy (keV)": 2.9002, + "weight": 0.02901 + }, + "M3O4": { + "energy (keV)": 2.8407, + "weight": 0.005 + }, + "M3O5": { + "energy (keV)": 2.8407, + "weight": 0.01 + }, + "Ma": { + "energy (keV)": 2.1964, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 2.2827, + "weight": 0.64124 + }, + "Mg": { + "energy (keV)": 2.4873, + "weight": 0.08505 + }, + "Mz": { + "energy (keV)": 1.7239, + "weight": 0.01344 + } + }, + "Ho": { + "Ka": { + "energy (keV)": 47.5466, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 53.8765, + "weight": 0.15 + }, + "La": { + "energy (keV)": 6.7197, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 7.5263, + "weight": 0.45056 + }, + "Lb2": { + "energy (keV)": 7.9101, + "weight": 0.23563 + }, + "Lb3": { + "energy (keV)": 7.653, + "weight": 0.06 + }, + "Lb4": { + "energy (keV)": 7.4714, + "weight": 0.09039 + }, + "Lg1": { + "energy (keV)": 8.7568, + "weight": 0.08448 + }, + "Lg3": { + "energy (keV)": 9.0876, + "weight": 0.0321 + }, + "Ll": { + "energy (keV)": 5.9428, + "weight": 0.04759 + }, + "Ln": { + "energy (keV)": 6.7895, + "weight": 0.0151 + }, + "M2N4": { + "energy (keV)": 1.7618, + "weight": 0.072 + }, + "Ma": { + "energy (keV)": 1.3477, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 1.3878, + "weight": 0.59443 + }, + "Mg": { + "energy (keV)": 1.5802, + "weight": 0.1418 + }, + "Mz": { + "energy (keV)": 1.0448, + "weight": 0.6629 + } + }, + "I": { + "Ka": { + "energy (keV)": 28.6123, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 32.2948, + "weight": 0.15 + }, + "La": { + "energy (keV)": 3.9377, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 4.2208, + "weight": 0.43087 + }, + "Lb2": { + "energy (keV)": 4.5075, + "weight": 0.17059 + }, + "Lb3": { + "energy (keV)": 4.3135, + "weight": 0.1464 + }, + "Lb4": { + "energy (keV)": 4.2576, + "weight": 0.09189 + }, + "Lg1": { + "energy (keV)": 4.8025, + "weight": 0.06704 + }, + "Lg3": { + "energy (keV)": 5.0654, + "weight": 0.0327 + }, + "Ll": { + "energy (keV)": 3.485, + "weight": 0.0423 + }, + "Ln": { + "energy (keV)": 3.78, + "weight": 0.0154 + } + }, + "In": { + "Ka": { + "energy (keV)": 24.2098, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 27.2756, + "weight": 0.15 + }, + "La": { + "energy (keV)": 3.287, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 3.4872, + "weight": 0.3616 + }, + "Lb2": { + "energy (keV)": 3.7139, + "weight": 0.1371 + }, + "Lb3": { + "energy (keV)": 3.5732, + "weight": 0.08779 + }, + "Lb4": { + "energy (keV)": 3.5353, + "weight": 0.05349 + }, + "Lg1": { + "energy (keV)": 3.9218, + "weight": 0.04535 + }, + "Lg3": { + "energy (keV)": 4.1601, + "weight": 0.0177 + }, + "Ll": { + "energy (keV)": 2.9045, + "weight": 0.0415 + }, + "Ln": { + "energy (keV)": 3.1124, + "weight": 0.0132 + } + }, + "Ir": { + "Ka": { + "energy (keV)": 64.8958, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 73.5603, + "weight": 0.15 + }, + "La": { + "energy (keV)": 9.1748, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 10.708, + "weight": 0.4168 + }, + "Lb2": { + "energy (keV)": 10.9203, + "weight": 0.216 + }, + "Lb3": { + "energy (keV)": 10.8678, + "weight": 0.0874 + }, + "Lb4": { + "energy (keV)": 10.5098, + "weight": 0.07269 + }, + "Lg1": { + "energy (keV)": 12.5127, + "weight": 0.08543 + }, + "Lg3": { + "energy (keV)": 12.9242, + "weight": 0.024 + }, + "Ll": { + "energy (keV)": 8.0415, + "weight": 0.05429 + }, + "Ln": { + "energy (keV)": 9.6504, + "weight": 0.01429 + }, + "M2N4": { + "energy (keV)": 2.5973, + "weight": 0.02901 + }, + "M3O4": { + "energy (keV)": 2.54264, + "weight": 0.005 + }, + "M3O5": { + "energy (keV)": 2.54385, + "weight": 0.01 + }, + "Ma": { + "energy (keV)": 1.9799, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 2.0527, + "weight": 0.59443 + }, + "Mg": { + "energy (keV)": 2.2558, + "weight": 0.08505 + }, + "Mz": { + "energy (keV)": 1.5461, + "weight": 0.01344 + } + }, + "K": { + "Ka": { + "energy (keV)": 3.3138, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 3.5896, + "weight": 0.1039 + } + }, + "Kr": { + "Ka": { + "energy (keV)": 12.6507, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 14.1118, + "weight": 0.1538 + }, + "La": { + "energy (keV)": 1.586, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 1.6383, + "weight": 0.39031 + }, + "Lb3": { + "energy (keV)": 1.7072, + "weight": 0.0465 + }, + "Lg3": { + "energy (keV)": 1.921, + "weight": 0.005 + }, + "Ll": { + "energy (keV)": 1.38657, + "weight": 0.04509 + }, + "Ln": { + "energy (keV)": 1.43887, + "weight": 0.0175 + } + }, + "La": { + "Ka": { + "energy (keV)": 33.4419, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 37.8012, + "weight": 0.15 + }, + "La": { + "energy (keV)": 4.651, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 5.0421, + "weight": 0.42631 + }, + "Lb2": { + "energy (keV)": 5.3838, + "weight": 0.19579 + }, + "Lb3": { + "energy (keV)": 5.1429, + "weight": 0.1341 + }, + "Lb4": { + "energy (keV)": 5.0619, + "weight": 0.0872 + }, + "Lg1": { + "energy (keV)": 5.7917, + "weight": 0.07656 + }, + "Lg3": { + "energy (keV)": 6.0749, + "weight": 0.0329 + }, + "Ll": { + "energy (keV)": 4.1214, + "weight": 0.0432 + }, + "Ln": { + "energy (keV)": 4.5293, + "weight": 0.015 + }, + "M2N4": { + "energy (keV)": 1.1055, + "weight": 0.022 + }, + "Ma": { + "energy (keV)": 0.8173, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 0.8162, + "weight": 0.9 + }, + "Mg": { + "energy (keV)": 1.0245, + "weight": 0.4 + }, + "Mz": { + "energy (keV)": 0.6403, + "weight": 0.06 + } + }, + "Li": {}, + "Lu": { + "Ka": { + "energy (keV)": 54.0697, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 61.2902, + "weight": 0.15 + }, + "La": { + "energy (keV)": 7.6556, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 8.7092, + "weight": 0.46975 + }, + "Lb2": { + "energy (keV)": 9.0491, + "weight": 0.20359 + }, + "Lb3": { + "energy (keV)": 8.8468, + "weight": 0.13099 + }, + "Lb4": { + "energy (keV)": 8.6069, + "weight": 0.0996 + }, + "Lg1": { + "energy (keV)": 10.1438, + "weight": 0.08968 + }, + "Lg3": { + "energy (keV)": 10.5111, + "weight": 0.0342 + }, + "Ll": { + "energy (keV)": 6.7529, + "weight": 0.05009 + }, + "Ln": { + "energy (keV)": 7.8574, + "weight": 0.016 + }, + "M2N4": { + "energy (keV)": 2.0587, + "weight": 0.01 + }, + "Ma": { + "energy (keV)": 1.5816, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 1.6325, + "weight": 0.59443 + }, + "Mg": { + "energy (keV)": 1.8286, + "weight": 0.08505 + }, + "Mz": { + "energy (keV)": 1.2292, + "weight": 0.06 + } + }, + "Mg": { + "Ka": { + "energy (keV)": 1.2536, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 1.305, + "weight": 0.01 + } + }, + "Mn": { + "Ka": { + "energy (keV)": 5.8987, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 6.4904, + "weight": 0.1252 + }, + "La": { + "energy (keV)": 0.63316, + "weight": 1.0 + }, + "Lb3": { + "energy (keV)": 0.7204, + "weight": 0.0263 + }, + "Ll": { + "energy (keV)": 0.5564, + "weight": 0.3898 + }, + "Ln": { + "energy (keV)": 0.5675, + "weight": 0.1898 + } + }, + "Mo": { + "Ka": { + "energy (keV)": 17.4793, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 19.6072, + "weight": 0.15 + }, + "La": { + "energy (keV)": 2.2932, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 2.3948, + "weight": 0.32736 + }, + "Lb2": { + "energy (keV)": 2.5184, + "weight": 0.04509 + }, + "Lb3": { + "energy (keV)": 2.4732, + "weight": 0.06299 + }, + "Lg1": { + "energy (keV)": 2.6233, + "weight": 0.01335 + }, + "Lg3": { + "energy (keV)": 2.8307, + "weight": 0.0105 + }, + "Ll": { + "energy (keV)": 2.0156, + "weight": 0.0415 + }, + "Ln": { + "energy (keV)": 2.1205, + "weight": 0.0128 + } + }, + "N": { + "Ka": { + "energy (keV)": 0.3924, + "weight": 1.0 + } + }, + "Na": { + "Ka": { + "energy (keV)": 1.041, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 1.0721, + "weight": 0.01 + } + }, + "Nb": { + "Ka": { + "energy (keV)": 16.6151, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 18.6226, + "weight": 0.15 + }, + "La": { + "energy (keV)": 2.1659, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 2.2573, + "weight": 0.32519 + }, + "Lb2": { + "energy (keV)": 2.3705, + "weight": 0.03299 + }, + "Lb3": { + "energy (keV)": 2.3347, + "weight": 0.06429 + }, + "Lg1": { + "energy (keV)": 2.4615, + "weight": 0.00975 + }, + "Lg3": { + "energy (keV)": 2.6638, + "weight": 0.0103 + }, + "Ll": { + "energy (keV)": 1.9021, + "weight": 0.04169 + }, + "Ln": { + "energy (keV)": 1.9963, + "weight": 0.0129 + } + }, + "Nd": { + "Ka": { + "energy (keV)": 37.361, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 42.2715, + "weight": 0.15 + }, + "La": { + "energy (keV)": 5.2302, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 5.722, + "weight": 0.42672 + }, + "Lb2": { + "energy (keV)": 6.0904, + "weight": 0.1957 + }, + "Lb3": { + "energy (keV)": 5.8286, + "weight": 0.12869 + }, + "Lb4": { + "energy (keV)": 5.7232, + "weight": 0.0858 + }, + "Lg1": { + "energy (keV)": 6.604, + "weight": 0.07712 + }, + "Lg3": { + "energy (keV)": 6.9014, + "weight": 0.0318 + }, + "Ll": { + "energy (keV)": 4.6326, + "weight": 0.04429 + }, + "Ln": { + "energy (keV)": 5.1462, + "weight": 0.01469 + }, + "M2N4": { + "energy (keV)": 1.2853, + "weight": 0.052 + }, + "Ma": { + "energy (keV)": 0.9402, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 0.9965, + "weight": 0.99 + }, + "Mg": { + "energy (keV)": 1.1799, + "weight": 0.625 + }, + "Mz": { + "energy (keV)": 0.7531, + "weight": 0.069 + } + }, + "Ne": { + "Ka": { + "energy (keV)": 0.8486, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 0.8669, + "weight": 0.01 + } + }, + "Ni": { + "Ka": { + "energy (keV)": 7.4781, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 8.2647, + "weight": 0.1277 + }, + "La": { + "energy (keV)": 0.8511, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 0.8683, + "weight": 0.1677 + }, + "Lb3": { + "energy (keV)": 0.94, + "weight": 0.00199 + }, + "Ll": { + "energy (keV)": 0.7429, + "weight": 0.14133 + }, + "Ln": { + "energy (keV)": 0.7601, + "weight": 0.09693 + } + }, + "Np": {}, + "O": { + "Ka": { + "energy (keV)": 0.5249, + "weight": 1.0 + } + }, + "Os": { + "Ka": { + "energy (keV)": 62.9999, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 71.4136, + "weight": 0.15 + }, + "La": { + "energy (keV)": 8.9108, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 10.3542, + "weight": 0.43207 + }, + "Lb2": { + "energy (keV)": 10.5981, + "weight": 0.2146 + }, + "Lb3": { + "energy (keV)": 10.5108, + "weight": 0.1024 + }, + "Lb4": { + "energy (keV)": 10.1758, + "weight": 0.08369 + }, + "Lg1": { + "energy (keV)": 12.0956, + "weight": 0.08768 + }, + "Lg3": { + "energy (keV)": 12.4998, + "weight": 0.028 + }, + "Ll": { + "energy (keV)": 7.8224, + "weight": 0.05389 + }, + "Ln": { + "energy (keV)": 9.3365, + "weight": 0.01479 + }, + "M2N4": { + "energy (keV)": 2.5028, + "weight": 0.02901 + }, + "M3O4": { + "energy (keV)": 2.45015, + "weight": 0.005 + }, + "M3O5": { + "energy (keV)": 2.45117, + "weight": 0.01 + }, + "Ma": { + "energy (keV)": 1.9138, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 1.9845, + "weight": 0.59443 + }, + "Mg": { + "energy (keV)": 2.1844, + "weight": 0.08505 + }, + "Mz": { + "energy (keV)": 1.4919, + "weight": 0.01344 + } + }, + "P": { + "Ka": { + "energy (keV)": 2.0133, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 2.13916, + "weight": 0.0498 + } + }, + "Pa": { + "Ka": { + "energy (keV)": 95.8679, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 108.4272, + "weight": 0.15 + }, + "La": { + "energy (keV)": 13.2913, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 16.7025, + "weight": 0.4 + }, + "Lb2": { + "energy (keV)": 16.0249, + "weight": 0.236 + }, + "Lb3": { + "energy (keV)": 16.9308, + "weight": 0.06 + }, + "Lb4": { + "energy (keV)": 16.1037, + "weight": 0.04 + }, + "Lg1": { + "energy (keV)": 19.5703, + "weight": 0.08 + }, + "Lg3": { + "energy (keV)": 20.0979, + "weight": 0.017 + }, + "Ll": { + "energy (keV)": 11.3662, + "weight": 0.0682 + }, + "Ln": { + "energy (keV)": 14.9468, + "weight": 0.0126 + }, + "M2N4": { + "energy (keV)": 4.2575, + "weight": 0.00674 + }, + "M3O4": { + "energy (keV)": 4.07712, + "weight": 0.01 + }, + "M3O5": { + "energy (keV)": 4.08456, + "weight": 0.01 + }, + "Ma": { + "energy (keV)": 3.0823, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 3.24, + "weight": 0.64124 + }, + "Mg": { + "energy (keV)": 3.4656, + "weight": 0.33505 + }, + "Mz": { + "energy (keV)": 2.4351, + "weight": 0.03512 + } + }, + "Pb": { + "Ka": { + "energy (keV)": 74.9693, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 84.9381, + "weight": 0.15 + }, + "La": { + "energy (keV)": 10.5512, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 12.6144, + "weight": 0.3836 + }, + "Lb2": { + "energy (keV)": 12.6223, + "weight": 0.2244 + }, + "Lb3": { + "energy (keV)": 12.7944, + "weight": 0.06049 + }, + "Lb4": { + "energy (keV)": 12.3066, + "weight": 0.055 + }, + "Lg1": { + "energy (keV)": 14.7648, + "weight": 0.08256 + }, + "Lg3": { + "energy (keV)": 15.2163, + "weight": 0.017 + }, + "Ll": { + "energy (keV)": 9.1845, + "weight": 0.0586 + }, + "Ln": { + "energy (keV)": 11.3493, + "weight": 0.0132 + }, + "M2N4": { + "energy (keV)": 3.119, + "weight": 0.00863 + }, + "M3O4": { + "energy (keV)": 3.0446, + "weight": 0.01 + }, + "M3O5": { + "energy (keV)": 3.0472, + "weight": 0.01 + }, + "Ma": { + "energy (keV)": 2.3459, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 2.4427, + "weight": 0.64124 + }, + "Mg": { + "energy (keV)": 2.6535, + "weight": 0.21845 + }, + "Mz": { + "energy (keV)": 1.8395, + "weight": 0.0058 + } + }, + "Pd": { + "Ka": { + "energy (keV)": 21.177, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 23.8188, + "weight": 0.15 + }, + "La": { + "energy (keV)": 2.8386, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 2.9903, + "weight": 0.34375 + }, + "Lb2": { + "energy (keV)": 3.16828, + "weight": 0.10349 + }, + "Lb3": { + "energy (keV)": 3.0728, + "weight": 0.0682 + }, + "Lb4": { + "energy (keV)": 3.0452, + "weight": 0.0407 + }, + "Lg1": { + "energy (keV)": 3.32485, + "weight": 0.03256 + }, + "Lg3": { + "energy (keV)": 3.5532, + "weight": 0.0125 + }, + "Ll": { + "energy (keV)": 2.5034, + "weight": 0.0412 + }, + "Ln": { + "energy (keV)": 2.6604, + "weight": 0.0129 + } + }, + "Pm": { + "Ka": { + "energy (keV)": 38.7247, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 43.8271, + "weight": 0.15 + }, + "La": { + "energy (keV)": 5.4324, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 5.9613, + "weight": 0.4308 + }, + "Lb2": { + "energy (keV)": 6.3389, + "weight": 0.196 + }, + "Lb3": { + "energy (keV)": 6.071, + "weight": 0.1247 + }, + "Lb4": { + "energy (keV)": 5.9565, + "weight": 0.07799 + }, + "Lg1": { + "energy (keV)": 6.8924, + "weight": 0.0784 + }, + "Lg3": { + "energy (keV)": 7.1919, + "weight": 0.0311 + }, + "Ll": { + "energy (keV)": 4.8128, + "weight": 0.0448 + }, + "Ln": { + "energy (keV)": 5.3663, + "weight": 0.01479 + }, + "M2N4": { + "energy (keV)": 1.351, + "weight": 0.028 + }, + "Ma": { + "energy (keV)": 0.9894, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 1.0475, + "weight": 0.89 + }, + "Mg": { + "energy (keV)": 1.2365, + "weight": 0.4 + }, + "Mz": { + "energy (keV)": 0.7909, + "weight": 0.068 + } + }, + "Po": { + "Ka": { + "energy (keV)": 79.2912, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 89.8031, + "weight": 0.15 + }, + "La": { + "energy (keV)": 11.1308, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 13.4463, + "weight": 0.38536 + }, + "Lb2": { + "energy (keV)": 13.3404, + "weight": 0.2289 + }, + "Lb3": { + "energy (keV)": 13.6374, + "weight": 0.0603 + }, + "Lb4": { + "energy (keV)": 13.0852, + "weight": 0.05709 + }, + "Lg1": { + "energy (keV)": 15.7441, + "weight": 0.08 + }, + "Lg3": { + "energy (keV)": 16.2343, + "weight": 0.017 + }, + "Ll": { + "energy (keV)": 9.6644, + "weight": 0.0607 + }, + "Ln": { + "energy (keV)": 12.0949, + "weight": 0.0133 + }, + "M2N4": { + "energy (keV)": 3.3539, + "weight": 0.00863 + }, + "Mb": { + "energy (keV)": 2.62266, + "weight": 0.64124 + }, + "Mg": { + "energy (keV)": 2.8285, + "weight": 0.21845 + }, + "Mz": { + "energy (keV)": 1.978, + "weight": 0.00354 + } + }, + "Pr": { + "Ka": { + "energy (keV)": 36.0263, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 40.7484, + "weight": 0.15 + }, + "La": { + "energy (keV)": 5.0333, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 5.4893, + "weight": 0.42872 + }, + "Lb2": { + "energy (keV)": 5.8511, + "weight": 0.19519 + }, + "Lb3": { + "energy (keV)": 5.5926, + "weight": 0.13089 + }, + "Lb4": { + "energy (keV)": 5.4974, + "weight": 0.0864 + }, + "Lg1": { + "energy (keV)": 6.3272, + "weight": 0.07687 + }, + "Lg3": { + "energy (keV)": 6.6172, + "weight": 0.0321 + }, + "Ll": { + "energy (keV)": 4.4533, + "weight": 0.044 + }, + "Ln": { + "energy (keV)": 4.9294, + "weight": 0.01489 + }, + "M2N4": { + "energy (keV)": 1.2242, + "weight": 0.055 + }, + "Ma": { + "energy (keV)": 0.8936, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 0.9476, + "weight": 0.85 + }, + "Mg": { + "energy (keV)": 1.129, + "weight": 0.6 + }, + "Mz": { + "energy (keV)": 0.7134, + "weight": 0.068 + } + }, + "Pt": { + "Ka": { + "energy (keV)": 66.8311, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 75.7494, + "weight": 0.15 + }, + "La": { + "energy (keV)": 9.4421, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 11.0707, + "weight": 0.4088 + }, + "Lb2": { + "energy (keV)": 11.2504, + "weight": 0.21829 + }, + "Lb3": { + "energy (keV)": 11.2345, + "weight": 0.0783 + }, + "Lb4": { + "energy (keV)": 10.8534, + "weight": 0.0662 + }, + "Lg1": { + "energy (keV)": 12.9418, + "weight": 0.08448 + }, + "Lg3": { + "energy (keV)": 13.3609, + "weight": 0.0218 + }, + "Ll": { + "energy (keV)": 8.2677, + "weight": 0.0554 + }, + "Ln": { + "energy (keV)": 9.9766, + "weight": 0.01399 + }, + "M2N4": { + "energy (keV)": 2.6957, + "weight": 0.02901 + }, + "M3O4": { + "energy (keV)": 2.63796, + "weight": 0.005 + }, + "M3O5": { + "energy (keV)": 2.63927, + "weight": 0.01 + }, + "Ma": { + "energy (keV)": 2.0505, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 2.1276, + "weight": 0.59443 + }, + "Mg": { + "energy (keV)": 2.3321, + "weight": 0.08505 + }, + "Mz": { + "energy (keV)": 1.6026, + "weight": 0.01344 + } + }, + "Pu": {}, + "Ra": { + "Ka": { + "energy (keV)": 88.4776, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 100.1302, + "weight": 0.15 + }, + "La": { + "energy (keV)": 12.3395, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 15.2359, + "weight": 0.4 + }, + "Lb2": { + "energy (keV)": 14.8417, + "weight": 0.23579 + }, + "Lb3": { + "energy (keV)": 15.4449, + "weight": 0.06 + }, + "Lb4": { + "energy (keV)": 14.7472, + "weight": 0.06209 + }, + "Lg1": { + "energy (keV)": 17.8484, + "weight": 0.08 + }, + "Lg3": { + "energy (keV)": 18.3576, + "weight": 0.017 + }, + "Ll": { + "energy (keV)": 10.6224, + "weight": 0.06429 + }, + "Ln": { + "energy (keV)": 13.6623, + "weight": 0.0133 + }, + "M2N4": { + "energy (keV)": 3.8536, + "weight": 0.00674 + }, + "Mb": { + "energy (keV)": 2.9495, + "weight": 0.64124 + }, + "Mg": { + "energy (keV)": 3.1891, + "weight": 0.33505 + }, + "Mz": { + "energy (keV)": 2.2258, + "weight": 0.03512 + } + }, + "Rb": { + "Ka": { + "energy (keV)": 13.3953, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 14.9612, + "weight": 0.1558 + }, + "La": { + "energy (keV)": 1.6941, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 1.7521, + "weight": 0.39095 + }, + "Lb3": { + "energy (keV)": 1.8266, + "weight": 0.04709 + }, + "Lg3": { + "energy (keV)": 2.0651, + "weight": 0.0058 + }, + "Ll": { + "energy (keV)": 1.4823, + "weight": 0.0441 + }, + "Ln": { + "energy (keV)": 1.5418, + "weight": 0.01709 + } + }, + "Re": { + "Ka": { + "energy (keV)": 61.1411, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 69.3091, + "weight": 0.15 + }, + "La": { + "energy (keV)": 8.6524, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 10.0098, + "weight": 0.4408 + }, + "Lb2": { + "energy (keV)": 10.2751, + "weight": 0.21219 + }, + "Lb3": { + "energy (keV)": 10.1594, + "weight": 0.1222 + }, + "Lb4": { + "energy (keV)": 9.8451, + "weight": 0.09869 + }, + "Lg1": { + "energy (keV)": 11.685, + "weight": 0.08864 + }, + "Lg3": { + "energy (keV)": 12.0823, + "weight": 0.0331 + }, + "Ll": { + "energy (keV)": 7.6036, + "weight": 0.05299 + }, + "Ln": { + "energy (keV)": 9.027, + "weight": 0.0151 + }, + "M2N4": { + "energy (keV)": 2.4079, + "weight": 0.01 + }, + "M3O4": { + "energy (keV)": 2.36124, + "weight": 0.001 + }, + "M3O5": { + "energy (keV)": 2.36209, + "weight": 0.01 + }, + "Ma": { + "energy (keV)": 1.8423, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 1.9083, + "weight": 0.59443 + }, + "Mg": { + "energy (keV)": 2.1071, + "weight": 0.08505 + }, + "Mz": { + "energy (keV)": 1.4385, + "weight": 0.01344 + } + }, + "Rh": { + "Ka": { + "energy (keV)": 20.2161, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 22.7237, + "weight": 0.15 + }, + "La": { + "energy (keV)": 2.6968, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 2.8344, + "weight": 0.33463 + }, + "Lb2": { + "energy (keV)": 3.0013, + "weight": 0.08539 + }, + "Lb3": { + "energy (keV)": 2.9157, + "weight": 0.06669 + }, + "Lb4": { + "energy (keV)": 2.8909, + "weight": 0.0395 + }, + "Lg1": { + "energy (keV)": 3.1436, + "weight": 0.02623 + }, + "Lg3": { + "energy (keV)": 3.364, + "weight": 0.0121 + }, + "Ll": { + "energy (keV)": 2.3767, + "weight": 0.0411 + }, + "Ln": { + "energy (keV)": 2.519, + "weight": 0.0126 + } + }, + "Rn": { + "Ka": { + "energy (keV)": 83.7846, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 94.866, + "weight": 0.15 + }, + "La": { + "energy (keV)": 11.727, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 14.3156, + "weight": 0.38463 + }, + "Lb2": { + "energy (keV)": 14.0824, + "weight": 0.2325 + }, + "Lb3": { + "energy (keV)": 14.511, + "weight": 0.0607 + }, + "Lb4": { + "energy (keV)": 13.89, + "weight": 0.06 + }, + "Lg1": { + "energy (keV)": 16.7705, + "weight": 0.08 + }, + "Lg3": { + "energy (keV)": 17.281, + "weight": 0.017 + }, + "Ll": { + "energy (keV)": 10.1374, + "weight": 0.0625 + }, + "Ln": { + "energy (keV)": 12.8551, + "weight": 0.0134 + }, + "M2N4": { + "energy (keV)": 3.5924, + "weight": 0.00863 + }, + "Mb": { + "energy (keV)": 2.80187, + "weight": 0.64124 + }, + "Mg": { + "energy (keV)": 3.001, + "weight": 0.21845 + }, + "Mz": { + "energy (keV)": 2.1244, + "weight": 0.0058 + } + }, + "Ru": { + "Ka": { + "energy (keV)": 19.2793, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 21.6566, + "weight": 0.15 + }, + "La": { + "energy (keV)": 2.5585, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 2.6833, + "weight": 0.33039 + }, + "Lb2": { + "energy (keV)": 2.8359, + "weight": 0.07259 + }, + "Lb3": { + "energy (keV)": 2.7634, + "weight": 0.0654 + }, + "Lg1": { + "energy (keV)": 2.9649, + "weight": 0.02176 + }, + "Lg3": { + "energy (keV)": 3.1809, + "weight": 0.0115 + }, + "Ll": { + "energy (keV)": 2.2529, + "weight": 0.0411 + }, + "Ln": { + "energy (keV)": 2.3819, + "weight": 0.0126 + } + }, + "S": { + "Ka": { + "energy (keV)": 2.3072, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 2.46427, + "weight": 0.06525 + } + }, + "Sb": { + "Ka": { + "energy (keV)": 26.359, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 29.7256, + "weight": 0.15 + }, + "La": { + "energy (keV)": 3.6047, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 3.8435, + "weight": 0.4276 + }, + "Lb2": { + "energy (keV)": 4.1008, + "weight": 0.1556 + }, + "Lb3": { + "energy (keV)": 3.9327, + "weight": 0.15099 + }, + "Lb4": { + "energy (keV)": 3.8864, + "weight": 0.0932 + }, + "Lg1": { + "energy (keV)": 4.349, + "weight": 0.06064 + }, + "Lg3": { + "energy (keV)": 4.5999, + "weight": 0.0321 + }, + "Ll": { + "energy (keV)": 3.1885, + "weight": 0.0419 + }, + "Ln": { + "energy (keV)": 3.4367, + "weight": 0.0155 + } + }, + "Sc": { + "Ka": { + "energy (keV)": 4.0906, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 4.4605, + "weight": 0.12839 + }, + "La": { + "energy (keV)": 0.4022, + "weight": 0.308 + }, + "Lb3": { + "energy (keV)": 0.4681, + "weight": 0.037 + }, + "Ll": { + "energy (keV)": 0.3484, + "weight": 1.0 + }, + "Ln": { + "energy (keV)": 0.3529, + "weight": 0.488 + } + }, + "Se": { + "Ka": { + "energy (keV)": 11.222, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 12.4959, + "weight": 0.1505 + }, + "La": { + "energy (keV)": 1.3791, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 1.4195, + "weight": 0.38848 + }, + "Lb3": { + "energy (keV)": 1.492, + "weight": 0.047 + }, + "Ll": { + "energy (keV)": 1.2043, + "weight": 0.04759 + }, + "Ln": { + "energy (keV)": 1.2447, + "weight": 0.0187 + } + }, + "Si": { + "Ka": { + "energy (keV)": 1.7397, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 1.8389, + "weight": 0.02779 + } + }, + "Sm": { + "Ka": { + "energy (keV)": 40.118, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 45.4144, + "weight": 0.15 + }, + "La": { + "energy (keV)": 5.636, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 6.2058, + "weight": 0.43512 + }, + "Lb2": { + "energy (keV)": 6.5872, + "weight": 0.19769 + }, + "Lb3": { + "energy (keV)": 6.317, + "weight": 0.12669 + }, + "Lb4": { + "energy (keV)": 6.1961, + "weight": 0.08689 + }, + "Lg1": { + "energy (keV)": 7.1828, + "weight": 0.07951 + }, + "Lg3": { + "energy (keV)": 7.4894, + "weight": 0.0318 + }, + "Ll": { + "energy (keV)": 4.9934, + "weight": 0.04519 + }, + "Ln": { + "energy (keV)": 5.589, + "weight": 0.01489 + }, + "M2N4": { + "energy (keV)": 1.4117, + "weight": 0.012 + }, + "Ma": { + "energy (keV)": 1.0428, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 1.1005, + "weight": 0.88 + }, + "Mg": { + "energy (keV)": 1.2908, + "weight": 0.26 + }, + "Mz": { + "energy (keV)": 0.8328, + "weight": 0.06 + } + }, + "Sn": { + "Ka": { + "energy (keV)": 25.2713, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 28.4857, + "weight": 0.15 + }, + "La": { + "energy (keV)": 3.444, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 3.6628, + "weight": 0.43456 + }, + "Lb2": { + "energy (keV)": 3.9049, + "weight": 0.14689 + }, + "Lb3": { + "energy (keV)": 3.7503, + "weight": 0.1547 + }, + "Lb4": { + "energy (keV)": 3.7083, + "weight": 0.0948 + }, + "Lg1": { + "energy (keV)": 4.1322, + "weight": 0.058 + }, + "Lg3": { + "energy (keV)": 4.3761, + "weight": 0.0321 + }, + "Ll": { + "energy (keV)": 3.045, + "weight": 0.0416 + }, + "Ln": { + "energy (keV)": 3.2723, + "weight": 0.0158 + } + }, + "Sr": { + "Ka": { + "energy (keV)": 14.165, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 15.8355, + "weight": 0.15 + }, + "La": { + "energy (keV)": 1.8065, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 1.8718, + "weight": 0.37975 + }, + "Lb3": { + "energy (keV)": 1.9472, + "weight": 0.047 + }, + "Lg3": { + "energy (keV)": 2.1964, + "weight": 0.0065 + }, + "Ll": { + "energy (keV)": 1.5821, + "weight": 0.04309 + }, + "Ln": { + "energy (keV)": 1.6493, + "weight": 0.01669 + } + }, + "Ta": { + "Ka": { + "energy (keV)": 57.5353, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 65.2224, + "weight": 0.15 + }, + "La": { + "energy (keV)": 8.146, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 9.3429, + "weight": 0.46248 + }, + "Lb2": { + "energy (keV)": 9.6518, + "weight": 0.2076 + }, + "Lb3": { + "energy (keV)": 9.4875, + "weight": 0.1333 + }, + "Lb4": { + "energy (keV)": 9.2128, + "weight": 0.10449 + }, + "Lg1": { + "energy (keV)": 10.8948, + "weight": 0.09071 + }, + "Lg3": { + "energy (keV)": 11.277, + "weight": 0.0354 + }, + "Ll": { + "energy (keV)": 7.1731, + "weight": 0.0515 + }, + "Ln": { + "energy (keV)": 8.4281, + "weight": 0.0158 + }, + "M2N4": { + "energy (keV)": 2.2274, + "weight": 0.01 + }, + "M3O4": { + "energy (keV)": 2.1883, + "weight": 0.0001 + }, + "M3O5": { + "energy (keV)": 2.194, + "weight": 0.01 + }, + "Ma": { + "energy (keV)": 1.7101, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 1.7682, + "weight": 0.59443 + }, + "Mg": { + "energy (keV)": 1.9647, + "weight": 0.08505 + }, + "Mz": { + "energy (keV)": 1.3306, + "weight": 0.01344 + } + }, + "Tb": { + "Ka": { + "energy (keV)": 44.4817, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 50.3844, + "weight": 0.15 + }, + "La": { + "energy (keV)": 6.2728, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 6.9766, + "weight": 0.44104 + }, + "Lb2": { + "energy (keV)": 7.367, + "weight": 0.19929 + }, + "Lb3": { + "energy (keV)": 7.0967, + "weight": 0.124 + }, + "Lb4": { + "energy (keV)": 6.9403, + "weight": 0.0874 + }, + "Lg1": { + "energy (keV)": 8.1046, + "weight": 0.08168 + }, + "Lg3": { + "energy (keV)": 8.423, + "weight": 0.0315 + }, + "Ll": { + "energy (keV)": 5.5465, + "weight": 0.0465 + }, + "Ln": { + "energy (keV)": 6.2841, + "weight": 0.01479 + }, + "M2N4": { + "energy (keV)": 1.6207, + "weight": 0.014 + }, + "Ma": { + "energy (keV)": 1.2326, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 1.2656, + "weight": 0.78 + }, + "Mg": { + "energy (keV)": 1.4643, + "weight": 0.2615 + }, + "Mz": { + "energy (keV)": 0.9562, + "weight": 0.06 + } + }, + "Tc": { + "Ka": { + "energy (keV)": 18.3671, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 20.619, + "weight": 0.15 + }, + "La": { + "energy (keV)": 2.424, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 2.5368, + "weight": 0.32951 + }, + "Lb2": { + "energy (keV)": 2.67017, + "weight": 0.05839 + }, + "Lb3": { + "energy (keV)": 2.6175, + "weight": 0.0644 + }, + "Lg1": { + "energy (keV)": 2.78619, + "weight": 0.01744 + }, + "Lg3": { + "energy (keV)": 3.0036, + "weight": 0.0111 + }, + "Ll": { + "energy (keV)": 2.1293, + "weight": 0.0412 + }, + "Ln": { + "energy (keV)": 2.2456, + "weight": 0.0127 + } + }, + "Te": { + "Ka": { + "energy (keV)": 27.4724, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 30.9951, + "weight": 0.15 + }, + "La": { + "energy (keV)": 3.7693, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 4.0295, + "weight": 0.43183 + }, + "Lb2": { + "energy (keV)": 4.3016, + "weight": 0.16269 + }, + "Lb3": { + "energy (keV)": 4.1205, + "weight": 0.1458 + }, + "Lb4": { + "energy (keV)": 4.0695, + "weight": 0.0906 + }, + "Lg1": { + "energy (keV)": 4.5722, + "weight": 0.06375 + }, + "Lg3": { + "energy (keV)": 4.829, + "weight": 0.0317 + }, + "Ll": { + "energy (keV)": 3.3354, + "weight": 0.0419 + }, + "Ln": { + "energy (keV)": 3.606, + "weight": 0.0154 + } + }, + "Th": { + "Ka": { + "energy (keV)": 93.3507, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 105.6049, + "weight": 0.15 + }, + "La": { + "energy (keV)": 12.9683, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 16.2024, + "weight": 0.4 + }, + "Lb2": { + "energy (keV)": 15.6239, + "weight": 0.236 + }, + "Lb3": { + "energy (keV)": 16.426, + "weight": 0.06 + }, + "Lb4": { + "energy (keV)": 15.6417, + "weight": 0.05 + }, + "Lg1": { + "energy (keV)": 18.9791, + "weight": 0.08 + }, + "Lg3": { + "energy (keV)": 19.5048, + "weight": 0.017 + }, + "Ll": { + "energy (keV)": 11.118, + "weight": 0.06709 + }, + "Ln": { + "energy (keV)": 14.5109, + "weight": 0.0134 + }, + "M2N4": { + "energy (keV)": 4.1163, + "weight": 0.00674 + }, + "M3O4": { + "energy (keV)": 3.9518, + "weight": 0.01 + }, + "M3O5": { + "energy (keV)": 3.9582, + "weight": 0.01 + }, + "Ma": { + "energy (keV)": 2.9968, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 3.1464, + "weight": 0.64124 + }, + "Mg": { + "energy (keV)": 3.3697, + "weight": 0.33505 + }, + "Mz": { + "energy (keV)": 2.3647, + "weight": 0.03512 + } + }, + "Ti": { + "Ka": { + "energy (keV)": 4.5109, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 4.9318, + "weight": 0.11673 + }, + "La": { + "energy (keV)": 0.4555, + "weight": 0.694 + }, + "Lb3": { + "energy (keV)": 0.5291, + "weight": 0.166 + }, + "Ll": { + "energy (keV)": 0.3952, + "weight": 1.0 + }, + "Ln": { + "energy (keV)": 0.4012, + "weight": 0.491 + } + }, + "Tl": { + "Ka": { + "energy (keV)": 72.8729, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 82.5738, + "weight": 0.15 + }, + "La": { + "energy (keV)": 10.2682, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 12.2128, + "weight": 0.39112 + }, + "Lb2": { + "energy (keV)": 12.2713, + "weight": 0.22289 + }, + "Lb3": { + "energy (keV)": 12.3901, + "weight": 0.0607 + }, + "Lb4": { + "energy (keV)": 11.931, + "weight": 0.05419 + }, + "Lg1": { + "energy (keV)": 14.2913, + "weight": 0.08304 + }, + "Lg3": { + "energy (keV)": 14.7377, + "weight": 0.0175 + }, + "Ll": { + "energy (keV)": 8.9534, + "weight": 0.0578 + }, + "Ln": { + "energy (keV)": 10.9938, + "weight": 0.0134 + }, + "M2N4": { + "energy (keV)": 3.0091, + "weight": 0.00863 + }, + "M3O4": { + "energy (keV)": 2.9413, + "weight": 0.005 + }, + "M3O5": { + "energy (keV)": 2.9435, + "weight": 0.01 + }, + "Ma": { + "energy (keV)": 2.2708, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 2.3623, + "weight": 0.64124 + }, + "Mg": { + "energy (keV)": 2.5704, + "weight": 0.21845 + }, + "Mz": { + "energy (keV)": 1.7803, + "weight": 0.0058 + } + }, + "Tm": { + "Ka": { + "energy (keV)": 50.7416, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 57.5051, + "weight": 0.15 + }, + "La": { + "energy (keV)": 7.1803, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 8.1023, + "weight": 0.45831 + }, + "Lb2": { + "energy (keV)": 8.4684, + "weight": 0.20059 + }, + "Lb3": { + "energy (keV)": 8.2312, + "weight": 0.1273 + }, + "Lb4": { + "energy (keV)": 8.0259, + "weight": 0.09449 + }, + "Lg1": { + "energy (keV)": 9.4373, + "weight": 0.08615 + }, + "Lg3": { + "energy (keV)": 9.7791, + "weight": 0.0329 + }, + "Ll": { + "energy (keV)": 6.3412, + "weight": 0.04889 + }, + "Ln": { + "energy (keV)": 7.3101, + "weight": 0.0156 + }, + "M2N4": { + "energy (keV)": 1.9102, + "weight": 0.01 + }, + "Ma": { + "energy (keV)": 1.4624, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 1.5093, + "weight": 0.59443 + }, + "Mg": { + "energy (keV)": 1.7049, + "weight": 0.08505 + }, + "Mz": { + "energy (keV)": 1.1311, + "weight": 0.06 + } + }, + "U": { + "Ka": { + "energy (keV)": 98.4397, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 111.3026, + "weight": 0.15 + }, + "La": { + "energy (keV)": 13.6146, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 17.22, + "weight": 0.4 + }, + "Lb2": { + "energy (keV)": 16.4286, + "weight": 0.236 + }, + "Lb3": { + "energy (keV)": 17.454, + "weight": 0.06 + }, + "Lb4": { + "energy (keV)": 16.5752, + "weight": 0.04 + }, + "Lg1": { + "energy (keV)": 20.1672, + "weight": 0.08 + }, + "Lg3": { + "energy (keV)": 20.7125, + "weight": 0.017 + }, + "Ll": { + "energy (keV)": 11.6183, + "weight": 0.069 + }, + "Ln": { + "energy (keV)": 15.3996, + "weight": 0.01199 + }, + "M2N4": { + "energy (keV)": 4.4018, + "weight": 0.00674 + }, + "M3O4": { + "energy (keV)": 4.1984, + "weight": 0.01 + }, + "M3O5": { + "energy (keV)": 4.2071, + "weight": 0.01 + }, + "Ma": { + "energy (keV)": 3.1708, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 3.3363, + "weight": 0.6086 + }, + "Mg": { + "energy (keV)": 3.5657, + "weight": 0.33505 + }, + "Mz": { + "energy (keV)": 2.5068, + "weight": 0.03512 + } + }, + "V": { + "Ka": { + "energy (keV)": 4.9522, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 5.4273, + "weight": 0.1225 + }, + "La": { + "energy (keV)": 0.5129, + "weight": 1.0 + }, + "Lb3": { + "energy (keV)": 0.5904, + "weight": 0.0154 + }, + "Ll": { + "energy (keV)": 0.4464, + "weight": 0.5745 + }, + "Ln": { + "energy (keV)": 0.454, + "weight": 0.2805 + } + }, + "W": { + "Ka": { + "energy (keV)": 59.3182, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 67.244, + "weight": 0.15 + }, + "La": { + "energy (keV)": 8.3976, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 9.6724, + "weight": 0.3679 + }, + "Lb2": { + "energy (keV)": 9.9614, + "weight": 0.21385 + }, + "Lb3": { + "energy (keV)": 9.8188, + "weight": 0.07077 + }, + "Lb4": { + "energy (keV)": 9.5249, + "weight": 0.05649 + }, + "Lg1": { + "energy (keV)": 11.2852, + "weight": 0.05658 + }, + "Lg3": { + "energy (keV)": 11.6745, + "weight": 0.0362 + }, + "Ll": { + "energy (keV)": 7.3872, + "weight": 0.04169 + }, + "Ln": { + "energy (keV)": 8.7244, + "weight": 0.01155 + }, + "M2N4": { + "energy (keV)": 2.3161, + "weight": 0.01 + }, + "M3O4": { + "energy (keV)": 2.2749, + "weight": 0.001 + }, + "M3O5": { + "energy (keV)": 2.281, + "weight": 0.01 + }, + "Ma": { + "energy (keV)": 1.7756, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 1.8351, + "weight": 0.59443 + }, + "Mg": { + "energy (keV)": 2.0356, + "weight": 0.08505 + }, + "Mz": { + "energy (keV)": 1.3839, + "weight": 0.01344 + } + }, + "Xe": { + "Ka": { + "energy (keV)": 29.7792, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 33.6244, + "weight": 0.15 + }, + "La": { + "energy (keV)": 4.1099, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 4.4183, + "weight": 0.42248 + }, + "Lb2": { + "energy (keV)": 4.7182, + "weight": 0.17699 + }, + "Lb3": { + "energy (keV)": 4.5158, + "weight": 0.14119 + }, + "Lb4": { + "energy (keV)": 4.4538, + "weight": 0.08929 + }, + "Lg1": { + "energy (keV)": 5.0397, + "weight": 0.06848 + }, + "Lg3": { + "energy (keV)": 5.3061, + "weight": 0.0323 + }, + "Ll": { + "energy (keV)": 3.6376, + "weight": 0.0424 + }, + "Ln": { + "energy (keV)": 3.9591, + "weight": 0.015 + } + }, + "Y": { + "Ka": { + "energy (keV)": 14.9584, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 16.7381, + "weight": 0.15 + }, + "La": { + "energy (keV)": 1.9226, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 1.9959, + "weight": 0.39127 + }, + "Lb2": { + "energy (keV)": 2.08, + "weight": 0.00739 + }, + "Lb3": { + "energy (keV)": 2.0722, + "weight": 0.05059 + }, + "Lg1": { + "energy (keV)": 2.1555, + "weight": 0.00264 + }, + "Lg3": { + "energy (keV)": 2.3469, + "weight": 0.0075 + }, + "Ll": { + "energy (keV)": 1.6864, + "weight": 0.0428 + }, + "Ln": { + "energy (keV)": 1.7619, + "weight": 0.0162 + } + }, + "Yb": { + "Ka": { + "energy (keV)": 52.3887, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 59.3825, + "weight": 0.15 + }, + "La": { + "energy (keV)": 7.4158, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 8.4019, + "weight": 0.46224 + }, + "Lb2": { + "energy (keV)": 8.7587, + "weight": 0.2017 + }, + "Lb3": { + "energy (keV)": 8.5366, + "weight": 0.12789 + }, + "Lb4": { + "energy (keV)": 8.3134, + "weight": 0.09589 + }, + "Lg1": { + "energy (keV)": 9.7801, + "weight": 0.08728 + }, + "Lg3": { + "energy (keV)": 10.1429, + "weight": 0.0331 + }, + "Ll": { + "energy (keV)": 6.5455, + "weight": 0.0494 + }, + "Ln": { + "energy (keV)": 7.5801, + "weight": 0.0157 + }, + "M2N4": { + "energy (keV)": 1.9749, + "weight": 0.01 + }, + "Ma": { + "energy (keV)": 1.5215, + "weight": 1.0 + }, + "Mb": { + "energy (keV)": 1.57, + "weight": 0.59443 + }, + "Mg": { + "energy (keV)": 1.7649, + "weight": 0.08505 + }, + "Mz": { + "energy (keV)": 1.1843, + "weight": 0.06 + } + }, + "Zn": { + "Ka": { + "energy (keV)": 8.6389, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 9.572, + "weight": 0.12605 + }, + "La": { + "energy (keV)": 1.0116, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 1.0347, + "weight": 0.1679 + }, + "Lb3": { + "energy (keV)": 1.107, + "weight": 0.002 + }, + "Ll": { + "energy (keV)": 0.8838, + "weight": 0.0603 + }, + "Ln": { + "energy (keV)": 0.9069, + "weight": 0.0368 + } + }, + "Zr": { + "Ka": { + "energy (keV)": 15.7753, + "weight": 1.0 + }, + "Kb": { + "energy (keV)": 17.6671, + "weight": 0.15 + }, + "La": { + "energy (keV)": 2.0423, + "weight": 1.0 + }, + "Lb1": { + "energy (keV)": 2.1243, + "weight": 0.37912 + }, + "Lb2": { + "energy (keV)": 2.2223, + "weight": 0.0177 + }, + "Lb3": { + "energy (keV)": 2.2011, + "weight": 0.05219 + }, + "Lg1": { + "energy (keV)": 2.30268, + "weight": 0.006 + }, + "Lg3": { + "energy (keV)": 2.5029, + "weight": 0.0082 + }, + "Ll": { + "energy (keV)": 1.792, + "weight": 0.04209 + }, + "Ln": { + "energy (keV)": 1.8764, + "weight": 0.0153 + } + } + }, + "metadata": { + "last_updated": "2025-06-04", + "notes": "Am, Li, Np and Pu lines are missing", + "source": "Chantler, C.T., Olsen, K., Dragoset, R.A., Kishore, A.R., Kotochigova, S.A., and Zucker, D.S. (2005), X-Ray Form Factor, Attenuation and Scattering Tables (version 2.1). https://dx.doi.org/10.18434/T4HS32", + "units": "keV", + "version": "1.0" + } +} From bd78da102bbf1e0e41c80c675ff70ea6c6135293 Mon Sep 17 00:00:00 2001 From: Sangoda <87961379+Sangoda@users.noreply.github.com> Date: Fri, 19 Dec 2025 15:44:46 -0800 Subject: [PATCH 014/136] Updated the show_mean_spectrum function --- .../spectroscopy/dataset3dspectroscopy.py | 959 ++++++++++-------- .../spectroscopy/kfacs_Titan_300_keV.csv | 119 +++ 2 files changed, 664 insertions(+), 414 deletions(-) create mode 100644 src/quantem/spectroscopy/kfacs_Titan_300_keV.csv diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index b53a4e75..9488ba6c 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -2,6 +2,7 @@ import os import json +import csv from scipy.signal import find_peaks import numpy as np @@ -17,15 +18,11 @@ class Dataset3dspectroscopy(Dataset3d): - """ - Class for handling 3D spectroscopy data and extracting spectra from ROIs. - Accepts either a dataset-like object or explicit arguments, and works as a base class. - """ # stores the element line info so you don't need to reload each time element_info = None - # loads the element info + # loads the xray lines dataset @classmethod def load_element_info(cls, path='xray_lines.json'): if cls.element_info is not None: @@ -50,8 +47,6 @@ def __init__(self, array, name=None, origin=None, sampling=None, units=None, sig signal_units=getattr(array, "signal_units", signal_units), _token=type(self)._token if _token is None else _token, ) - # Initialize model elements storage - self.model_elements = None else: super().__init__( array=array, @@ -269,7 +264,8 @@ def _plot_pca_results( plt.tight_layout() plt.show() - ''' +# QUANTIFICATION ----------------------------------------------- + def quantify_composition(self, roi=None, elements=None, k_factors=None, method='cliff_lorimer', mask=None): """ Quantify elemental composition from EDS spectrum using Cliff-Lorimer approach. @@ -334,7 +330,7 @@ def quantify_composition(self, roi=None, elements=None, k_factors=None, method=' intensities[element] = intensity # Handle k-factors - if k_factors is None: + if k_factors is None: # if they arent provided, calculate from kfacs_Titan_300_keV.csv k_factors = self._calculate_theoretical_k_factors(elements) else: # Validate k-factors @@ -343,7 +339,7 @@ def quantify_composition(self, roi=None, elements=None, k_factors=None, method=' # Apply Cliff-Lorimer quantification if method == 'cliff_lorimer': - results = self._cliff_lorimer_quantification(elements, intensities, k_factors) + results = self._cliff_lorimer_quantification(elements, intensities, k_factors, method, roi) else: raise ValueError(f"Unknown quantification method: {method}") @@ -408,7 +404,7 @@ def _integrate_element_intensity(self, element, spectrum, energy): for weight, line_energy, line_name in weighted_lines[:3]: if weight > 0.1: # Only significant lines # Find integration window around the line - # Use ±0.1 keV window or adaptive based on energy resolution + # Use +/- 0.1 keV window or adaptive based on energy resolution window_width = max(0.1, line_energy * 0.01) # 1% of energy or 0.1 keV minimum # Find energy indices for integration @@ -427,41 +423,96 @@ def _integrate_element_intensity(self, element, spectrum, energy): return total_intensity def _calculate_theoretical_k_factors(self, elements): - """Calculate theoretical k-factors using atomic number approximation.""" - # This is a simplified approach - in practice you'd use more sophisticated - # quantum mechanical calculations or experimental values - - # Atomic numbers for common elements - atomic_numbers = { - 'C': 6, 'N': 7, 'O': 8, 'F': 9, 'Na': 11, 'Mg': 12, 'Al': 13, 'Si': 14, - 'P': 15, 'S': 16, 'Cl': 17, 'K': 19, 'Ca': 20, 'Ti': 22, 'Cr': 24, - 'Mn': 25, 'Fe': 26, 'Co': 27, 'Ni': 28, 'Cu': 29, 'Zn': 30, 'Ag': 47, - 'Pt': 78, 'Au': 79 - } + """Load k-factors from Titan 300 keV CSV file.""" + # Get the path to the CSV file (same directory as this Python file) + current_dir = os.path.dirname(os.path.abspath(__file__)) + csv_path = os.path.join(current_dir, 'kfacs_Titan_300_keV.csv') - # Use first element as reference (k = 1.0) - reference_element = elements[0] - ref_z = atomic_numbers.get(reference_element, 26) # Default to Fe + # Load k-factors from CSV + k_factor_data = {} + try: + with open(csv_path, 'r') as f: + reader = csv.DictReader(f) + for row in reader: + element = row['Element'] + k_factor_data[element] = { + 'K': float(row['K']), + 'L': float(row['L']), + 'M': float(row['M']) + } + except FileNotFoundError: + print(f"Warning: K-factor CSV file not found at {csv_path}") + print("Using simplified k-factors (all set to 1.0)") + return {elem: 1.0 for elem in elements} - k_factors = {reference_element: 1.0} + # Get element info database to determine which X-ray line to use + all_info = type(self).element_info - # Rough approximation: k_factor scales with atomic number ratio - # This is very approximate - real k-factors depend on X-ray cross sections, - # fluorescence yields, detector efficiency, etc. - for element in elements[1:]: - element_z = atomic_numbers.get(element, 26) - # Simplified relationship - should be replaced with proper theoretical calculation - k_factors[element] = (ref_z / element_z) ** 0.7 # Approximate scaling + k_factors = {} + for element in elements: + if element not in k_factor_data: + print(f"Warning: Element {element} not found in k-factor database, using 1.0") + k_factors[element] = 1.0 + continue + + # Determine which X-ray line (K, L, or M) to use based on the element's primary lines + if element in all_info: + element_lines = all_info[element] + + # Check which X-ray series is most prominent for this element + has_k_lines = any('Ka' in line or 'Kb' in line for line in element_lines.keys()) + has_l_lines = any('La' in line or 'Lb' in line for line in element_lines.keys()) + has_m_lines = any('Ma' in line or 'Mb' in line for line in element_lines.keys()) + + # Prioritize K-lines, then L-lines, then M-lines + if has_k_lines and k_factor_data[element]['K'] > 0: + k_factors[element] = k_factor_data[element]['K'] + line_type = 'K' + elif has_l_lines and k_factor_data[element]['L'] > 0: + k_factors[element] = k_factor_data[element]['L'] + line_type = 'L' + elif has_m_lines and k_factor_data[element]['M'] > 0: + k_factors[element] = k_factor_data[element]['M'] + line_type = 'M' + else: + # Default to K-line k-factor if available + if k_factor_data[element]['K'] > 0: + k_factors[element] = k_factor_data[element]['K'] + line_type = 'K' + elif k_factor_data[element]['L'] > 0: + k_factors[element] = k_factor_data[element]['L'] + line_type = 'L' + elif k_factor_data[element]['M'] > 0: + k_factors[element] = k_factor_data[element]['M'] + line_type = 'M' + else: + k_factors[element] = 1.0 + line_type = 'default' + else: + # Element not in database, use K-line if available + if k_factor_data[element]['K'] > 0: + k_factors[element] = k_factor_data[element]['K'] + line_type = 'K' + elif k_factor_data[element]['L'] > 0: + k_factors[element] = k_factor_data[element]['L'] + line_type = 'L' + elif k_factor_data[element]['M'] > 0: + k_factors[element] = k_factor_data[element]['M'] + line_type = 'M' + else: + k_factors[element] = 1.0 + line_type = 'default' - print(f"Using theoretical k-factors: {k_factors}") - print("Note: For accurate quantification, use experimentally determined k-factors") + print(f"Using k-factors from Titan 300 keV database: {csv_path}") + for elem in elements: + print(f" {elem}: {k_factors[elem]:.3f}") return k_factors - def _cliff_lorimer_quantification(self, elements, intensities, k_factors): + def _cliff_lorimer_quantification(self, elements, intensities, k_factors, method, roi): """Apply Cliff-Lorimer quantification method.""" # Cliff-Lorimer equation: CA/CB = kAB * (IA/IB) - # For multiple elements: CA = kA * IA / Σ(ki * Ii) + # For multiple elements: CA = kA * IA / SUM(ki * Ii) # Calculate weighted intensities weighted_sum = 0.0 @@ -502,39 +553,34 @@ def _cliff_lorimer_quantification(self, elements, intensities, k_factors): else: weight_percent[element] = 0.0 - # Print summary for verification - print(f"\n=== Quantification Results ===") - print(f"Method: {method}") - print(f"Elements: {elements}") + # Print summary in Cliff-Lorimer format + print(f"\n=== Quantification (Cliff-Lorimer) ===") print(f"ROI: {'Full image' if roi is None else roi}") + print(f"Elements: {', '.join(elements)}") + print(f"\nRaw Intensities:") for elem in elements: - print(f" {elem}: {intensities[elem]:.1f}") - print(f"\nK-factors used:") + print(f" {elem}: {intensities[elem]:.2f}") + + print(f"\nk-factors:") for elem in elements: - print(f" {elem}: {k_factors[elem]:.3f}") - print(f"\nAtomic Composition:") - total_atomic = sum(atomic_percent.values()) + print(f" {elem}: {k_factors[elem]:.2f}") + + print(f"\nAtomic %:") for elem in elements: print(f" {elem}: {atomic_percent[elem]:.1f} at%") - print(f" Total: {total_atomic:.1f} at%") - print(f"\nWeight Composition:") - total_weight = sum(weight_percent.values()) + + print(f"\nWeight %:") for elem in elements: print(f" {elem}: {weight_percent[elem]:.1f} wt%") - print(f" Total: {total_weight:.1f} wt%") return { 'atomic_percent': atomic_percent, 'weight_percent': weight_percent, 'intensities': intensities, 'k_factors': k_factors, - 'method': 'cliff_lorimer', - 'total_atomic': total_atomic, - 'total_weight': total_weight + 'method': 'cliff_lorimer' } - - ''' def _find_best_element_combinations(self, peak_energies, peak_intensities, tolerance=0.15): """ @@ -585,26 +631,18 @@ def _calculate_element_combo_score(self, element_combo, peak_energies, peak_inte """ Calculate a cost function score for a given combination of elements. Lower scores are better. + + Strategy: Prioritize explaining ALL major peaks with the FEWEST elements. + Only accept combinations that explain most peaks with high-weight lines. """ score = 0.0 - explained_peaks = set() - - # General element categories (no specific element bias) - # Only penalize obvious contaminants/artifacts, don't favor specific elements - substrate_elements = {'Cu': 0.5, 'C': 0.3} # Mild penalty for substrate/grid elements - very_rare_elements = {'Ir': -1.0, 'Os': -1.0, 'Ru': -1.0} # Small penalty for very unlikely elements - - # Apply minimal element category adjustments - for element in element_combo: - if element in substrate_elements: - score += substrate_elements[element] # Small penalty for substrate - elif element in very_rare_elements: - score -= very_rare_elements[element] # Small penalty for very rare elements + explained_peaks = {} # peak_idx -> (matched_distance, line_weight, element) - # For each detected peak, find if it can be explained by the element combination + # For each detected peak, find the BEST match in the element combination for i, (peak_energy, peak_intensity) in enumerate(zip(peak_energies, peak_intensities)): best_match_distance = float('inf') best_line_weight = 0.0 + best_element = None found_match = False # Check all elements in the combination @@ -615,55 +653,137 @@ def _calculate_element_combo_score(self, element_combo, peak_energies, peak_inte line_weight = line_info.get('weight', 0.5) distance = abs(peak_energy - line_energy) - if distance <= tolerance: - found_match = True - if distance < best_match_distance: + # Only consider lines with significant weight (major lines only) + if line_weight > 0.2 and distance <= tolerance: + # Update best match if this line is better + if distance < best_match_distance or (distance == best_match_distance and line_weight > best_line_weight): best_match_distance = distance best_line_weight = line_weight + best_element = element + found_match = True if found_match: - explained_peaks.add(i) - # Add distance penalty (smaller is better) - score += best_match_distance - # Bonus for high-weight lines (major lines like Kα vs minor lines like M-lines) - score -= best_line_weight * 1.0 + explained_peaks[i] = (best_match_distance, best_line_weight, best_element) + # Penalty for distance (prefer closer matches) + score += best_match_distance * 10.0 + # Bonus for high-weight lines (major lines score much better) + score -= best_line_weight * 3.0 else: - # Heavy penalty for unexplained peaks - score += 10.0 + # HEAVY penalty for unexplained peaks - this is the key constraint + score += 50.0 - # Add penalty for unused elements (prefer simpler explanations) - unused_element_penalty = (len(element_combo) - 1) * 2.0 - score += unused_element_penalty - - # Add penalty for unexplained peaks + # Primary objective: explain ALL detected peaks unexplained_peaks = len(peak_energies) - len(explained_peaks) - score += unexplained_peaks * 5.0 + if unexplained_peaks > 0: + score += unexplained_peaks * 100.0 # Very high penalty for unexplained peaks - # Bonus for explaining multiple peaks with common elements (like Fe Kα + Kβ) - multi_peak_bonus = 0.0 - for element in element_combo: - if element in all_info: - element_peaks = 0 - major_peaks = 0 # Count major lines (weight > 0.5) - for line_name, line_info in all_info[element].items(): - line_energy = line_info['energy (keV)'] - line_weight = line_info.get('weight', 0.5) - for peak_energy in peak_energies: - if abs(peak_energy - line_energy) <= tolerance: - element_peaks += 1 - if line_weight > 0.5: - major_peaks += 1 - - if element_peaks > 1: - multi_peak_bonus += 2.0 # Bonus for elements with multiple matched peaks - if major_peaks > 0: - multi_peak_bonus += 1.0 # Additional bonus for major line matches + # Secondary objective: prefer simpler explanations (fewer elements) + score += len(element_combo) * 5.0 + + # Tertiary objective: prefer explanations with multiple peaks per element + # This avoids one-off false matches and encourages coherent solutions + peaks_per_element = {} + for peak_idx, (dist, weight, elem) in explained_peaks.items(): + if elem not in peaks_per_element: + peaks_per_element[elem] = [] + peaks_per_element[elem].append((dist, weight)) - score -= multi_peak_bonus + # Bonus if each element explains multiple peaks (coherence - more likely correct) + for elem, matches in peaks_per_element.items(): + if len(matches) > 1: + # Elements with 2+ peak matches are much more likely correct + score -= len(matches) * 2.0 return score - def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_range=None, threshold=5.0, tolerance=0.15, mask=None, show_lines=True): + def _subtract_background_eds(self, spectrum, energy_axis): + """ + Subtract power-law background typical for EDS Bremsstrahlung. + Uses a conservative approach with heavy smoothing to avoid creating artifacts. + + Parameters + ---------- + spectrum : ndarray + 1D spectrum + energy_axis : ndarray + Energy axis corresponding to spectrum + + Returns + ------- + ndarray + Background-subtracted spectrum + """ + from scipy.ndimage import gaussian_filter + + # Use a larger window for more conservative background estimation + window_size = 15 # Larger window = smoother, less aggressive + background = np.zeros_like(spectrum) + half_window = window_size // 2 + + # Estimate background from sliding minimum + for i in range(len(spectrum)): + start = max(0, i - half_window) + end = min(len(spectrum), i + half_window + 1) + # Use percentile instead of minimum for more robustness + background[i] = np.percentile(spectrum[start:end], 10) + + # Apply heavy smoothing to avoid creating artificial features + background = gaussian_filter(background, sigma=5.0) + + # Be very conservative - only subtract 80% of estimated background + # This prevents over-subtraction that creates artificial peaks + background = background * 0.8 + + # Ensure background doesn't exceed spectrum + background = np.minimum(background, spectrum * 0.9) + + return np.maximum(spectrum - background, 0) + + def _subtract_background_eels(self, spectrum, energy_axis): + """ + Subtract background typical for EELS using iterative Gaussian fitting. + This method isolates the continuum background from the low-loss region. + + WARNING: Only use with EELS data! Will remove peaks if used with EDS. + + Parameters + ---------- + spectrum : ndarray + 1D EELS spectrum + energy_axis : ndarray + Energy axis corresponding to spectrum + + Returns + ------- + ndarray + Background-subtracted spectrum + """ + from scipy.stats import norm + from scipy.ndimage import gaussian_filter + + # Smooth for better fitting + spec_smooth = gaussian_filter(spectrum, sigma=1.0) + pixel_vals = spec_smooth.copy() + + # Iteratively fit Gaussian to low-intensity values (the continuum) + # Remove outliers (edge peaks) iteratively + num_iterations = 10 + cutoff = 3 # +/- 3 sigma + + for _ in range(num_iterations): + mu, std = norm.fit(pixel_vals) + if std == 0: + break + # Keep only values within +/- 3 sigma (removes edge contributions) + lower = mu - cutoff * std + upper = mu + cutoff * std + pixel_vals = pixel_vals[(pixel_vals >= lower) & (pixel_vals <= upper)] + + # Subtract the estimated background level + background_fit = mu + return np.maximum(spectrum - background_fit, 0) + + def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_range=None, threshold=5.0, tolerance=0.15, mask=None, show_lines=True, show_text=True, snr_min=None, snr_threshold=None, distance_threshold_for_sample=0.05, contamination_elements=None, grid_peaks=None, background_subtraction='none', data_type='eds',peaks=15): """ Make and show a spectrum plot from a spatial ROI in a 3D EDS cube (E, Y, X). @@ -674,9 +794,9 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ - y, x: top-left pixel coordinates - dy, dx: height and width of ROI Use None for default values: - - [y, None, dy, None] → row y with height dy, full width - - [None, x, None, dx] → column x with width dx, full height - - [y, x, None, None] → from (y,x) to bottom-right corner + - [y, None, dy, None] = row y with height dy, full width + - [None, x, None, dx] = column x with width dx, full height + - [y, x, None, None] = from (y,x) to bottom-right corner If roi=None, uses full image. Can also be [y, x] for single pixel. energy_range : list or tuple, optional Energy range to display as [min_energy, max_energy] in keV. @@ -695,12 +815,56 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ show_lines : bool, optional Whether to show element lines and/or auto-detected peaks. Auto-enabled if elements are specified or auto-detection is used. + show_text : bool, optional + Whether to show text labels for detected elements. Default: True. + When False, vertical lines are still shown but element labels are hidden. + snr_min : float, optional + Minimum SNR threshold for detecting any peak. If None, automatically determined + from peak distribution (typically 20-30 based on data characteristics). + Lower values detect more peaks, higher values are more selective. + snr_threshold : float, optional + Minimum SNR for identifying a peak as a sample element (not contamination). + If None, automatically determined based on peak statistics. For sparse spectra + (few strong peaks), uses lower threshold (~30). For dense spectra (many peaks), + uses higher threshold (~50-80) to filter noise. + distance_threshold_for_sample : float, optional + Maximum energy distance (keV) between detected peak and characteristic line + for identifying as a sample element. Default: 0.05. Stricter values (smaller) + reduce false positives. + contamination_elements : set or list, optional + Element symbols to exclude from sample detection (e.g., {'C', 'Cu', 'O'}). + Default: {'C', 'N', 'O', 'Cu', 'Si', 'K', 'Kr', 'Po', 'Pb', 'Os', 'Ir', 'At', 'Do', 'Po'} + These are common TEM support materials and artifacts. + grid_peaks : dict, optional + Dictionary of known grid/support peaks for labeling, e.g., {'C': 0.260, 'Cu': 8.020}. + Default: {'C': 0.260, 'Cu': 8.020} for carbon support film and copper TEM grid. + background_subtraction : str, optional + Background subtraction method. Options: + - 'none' (default): No background subtraction + - 'auto': Automatically choose best method for data_type (EDS -> power-law, EELS -> iterative Gaussian) + - 'powerlaw': Power-law background (best for EDS, suitable for Bremsstrahlung) + - 'iterative': Iterative Gaussian fitting (best for EELS, isolates continuum) + data_type : str, optional + Type of spectroscopy data. Options: 'eds' (default) or 'eels'. + Used with background_subtraction='auto' to select optimal method. + peaks : int, optional + Maximum number of peaks to display in the output table and plot as vertical lines. + Default: 15. Limits output to peaks with highest SNR (most statistically significant). Returns ------- (fig, ax) : tuple The Matplotlib Figure and Axes of the spectrum plot. """ + + # Set defaults for detection parameters + if contamination_elements is None: + contamination_elements = {'C', 'N', 'O', 'Cu', 'Si', 'K', 'Kr', 'Po', 'Pb', 'Os', 'Ir', 'At', 'Do', 'Po'} + else: + contamination_elements = set(contamination_elements) + + if grid_peaks is None: + grid_peaks = {'C': 0.260, 'Cu': 8.020} # ADJUST ROI BASED ON GIVEN FLAGS ----------------------------------------------- @@ -724,7 +888,7 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ raise ValueError("roi must be None, [y, x], or [y, x, dy, dx] (with None for defaults)") - # ERROR HANDLING ------------------------------------------------------------------- + # VALIDATE ROI BOUNDS --------------------------------------------------------------------------- errs = [] Ymax = int(self.shape[1]) Xmax = int(self.shape[2]) @@ -764,7 +928,6 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 E = E0 + dE * np.arange(self.shape[0]) - # MASK HANDLING --------------------------------------------------------------------- if mask is not None: # Convert to ndarray and validate @@ -805,12 +968,30 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ # Store ignore_range for later use in element line filtering if ignore_range is None: ignore_range = [0, 0.25] # Default: ignore 0-0.25 keV for element lines only + + # BACKGROUND SUBTRACTION ------------------------------------------------------------------- + # Apply appropriate background subtraction method + if background_subtraction == 'auto': + # Automatically select best method for the data type + if data_type.lower() == 'eels': + background_subtraction = 'iterative' + else: # Default to EDS + background_subtraction = 'powerlaw' + + if background_subtraction == 'powerlaw': + # EDS: Power-law Bremsstrahlung background + spec = self._subtract_background_eds(spec, E) + elif background_subtraction == 'iterative': + # EELS: Iterative Gaussian fitting for continuum + spec = self._subtract_background_eels(spec, E) + # else: 'none' - no subtraction # PLOTTING --------------------------------------------------------------------------- # Create subplot layout: image on left, spectrum on right fig, (ax_img, ax_spec) = plt.subplots(1, 2, figsize=(12, 4)) + # LEFT PLOT: Show sum image with ROI highlighted # Create sum image across all energy channels (or masked channels) @@ -858,27 +1039,71 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ plt.show() return fig, (ax_img, ax_spec) - # AUTO-DETECT ELEMENTS FROM STATISTICALLY SIGNIFICANT PEAKS ------------------- - auto_peak_labels = [] # Store label positions to avoid overlap - if elements is None: + # AUTO-DETECT PEAKS AND MATCH TO DATABASE ------------------- + if elements is None or (isinstance(elements, list) and len(elements) > 0): + # elements is either None (full auto-detection) or a list of specific elements to search for try: - # Statistical peak detection based on intensity distribution # Step 1: Find all potential peaks peak_indices, peak_properties = find_peaks(spec, height=0, distance=5) peak_heights = peak_properties['peak_heights'] # Step 2: Calculate background statistics - # Use lower percentiles to estimate background level - background_level = np.percentile(spec, 25) # 25th percentile as background - background_std = np.std(spec[spec <= np.percentile(spec, 50)]) # Std of lower half + # Use nanpercentile to handle any NaN values in the spectrum + background_level = np.nanpercentile(spec, 25) + background_std = np.nanstd(spec[spec <= np.nanpercentile(spec, 50)]) - # Step 3: Statistical significance threshold - # A peak is significant if it's above background + N*sigma - significance_threshold = background_level + 3.0 * background_std + # Step 3: Determine dynamic SNR thresholds if not provided + # Calculate initial SNR for all peaks to assess data characteristics + initial_snrs = [] + for peak_idx, height in zip(peak_indices, peak_heights): + snr = height / background_std if background_std > 0 else float('inf') + initial_snrs.append(snr) + + # Calculate statistics of SNR distribution + if len(initial_snrs) > 0: + snr_median = np.nanmedian(initial_snrs) + snr_75th = np.nanpercentile(initial_snrs, 75) + snr_95th = np.nanpercentile(initial_snrs, 95) + num_high_snr_peaks = np.sum(np.array(initial_snrs) > 50) + else: + snr_median = 0 + snr_75th = 0 + snr_95th = 0 + num_high_snr_peaks = 0 - # Step 4: Filter peaks by statistical significance + # Set snr_min (detection threshold) if not provided + if snr_min is None: + # Use adaptive threshold based on SNR distribution + # For noisy data with many weak peaks, use higher threshold + if snr_median > 30: + min_snr = 25.0 # Many peaks -> slightly higher threshold + else: + min_snr = 20.0 # Sparse peaks -> standard threshold + else: + min_snr = snr_min + + # Set snr_threshold (sample element threshold) if not provided + if snr_threshold is None: + # Adaptive threshold based on peak density and SNR distribution + # Sparse spectra (few strong peaks) -> lower threshold + # Dense spectra (many peaks) -> higher threshold to filter noise + if num_high_snr_peaks > 50: # Many high-SNR peaks (dense spectrum like map1) + snr_threshold_for_sample = min(80.0, snr_75th * 1.2) + elif num_high_snr_peaks > 20: # Moderate number of peaks + snr_threshold_for_sample = min(60.0, snr_75th * 1.1) + elif num_high_snr_peaks < 10: # Few peaks (sparse spectrum like Bare) + snr_threshold_for_sample = max(30.0, snr_75th * 0.8) + else: # Default case + snr_threshold_for_sample = 40.0 + + print(f"Auto-determined thresholds: snr_min={min_snr:.1f}, snr_threshold={snr_threshold_for_sample:.1f}") + print(f" (Based on: median_snr={snr_median:.1f}, 75th_percentile={snr_75th:.1f}, high_snr_peaks={num_high_snr_peaks})") + else: + snr_threshold_for_sample = snr_threshold + + # Step 4: Filter peaks by SNR significant_peaks = [] - for i, (peak_idx, height) in enumerate(zip(peak_indices, peak_heights)): + for peak_idx, height in zip(peak_indices, peak_heights): peak_energy = E[peak_idx] # Skip peaks in ignore range @@ -887,321 +1112,227 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ if min_ignore <= peak_energy <= max_ignore: continue - # Check statistical significance - if height > significance_threshold: - significant_peaks.append((peak_idx, height, peak_energy)) + snr = height / background_std if background_std > 0 else float('inf') + + # Keep peaks with good SNR + if snr >= min_snr: + significant_peaks.append((peak_idx, height, peak_energy, snr)) - # Step 5: Additional filtering based on relative prominence if len(significant_peaks) > 0: - # Calculate signal-to-noise ratio for each significant peak - max_intensity = max([height for _, height, _ in significant_peaks]) + # Sort by SNR (signal-to-noise ratio) for most statistically significant peaks + significant_peaks.sort(key=lambda x: x[3], reverse=True) - # Use a more inclusive approach: take significant peaks with good SNR - # Lower prominence threshold to 5% and include more peaks - prominence_threshold = 0.05 * max_intensity - important_peaks = [] + # Limit to top N peaks for display + display_peaks = significant_peaks[:peaks] - for peak_idx, height, energy in significant_peaks: - snr = height / background_std if background_std > 0 else float('inf') - # Include peaks above prominence OR with good SNR - if height >= prominence_threshold or snr >= 5.0: - important_peaks.append((peak_idx, height, energy, snr)) + # Match detected peaks to xray_lines.json + all_info = type(self).element_info + peak_matches = [] # List of (peak_idx, height, peak_energy, snr, element, match_string, distance) - print(f" {len(important_peaks)} peaks above prominence threshold (5% of max) or SNR >= 5") - print(f" → ALL {len(important_peaks)} peaks used for element identification analysis") + # If specific elements are requested, filter the database to only those + if elements is not None and isinstance(elements, list): + search_elements = set(elements) + search_mode = f"for {search_elements}" + else: + search_elements = None + search_mode = "for all elements" - # Sort by signal-to-noise ratio for analysis - important_peaks.sort(key=lambda x: x[3], reverse=True) # Sort by SNR + print(f"\nDetected {len(significant_peaks)} peaks (SNR >= {min_snr:.1f}) {search_mode}") + if len(significant_peaks) > peaks: + print(f"Showing top {peaks} peaks by SNR (most statistically significant)") + print(f"{'Energy (keV)':<12} {'Intensity':<12} {'SNR':<8} {'Best Match':<25}") + print("-"*60) - # Use all important peaks for cost function analysis - analysis_peak_indices = [peak[0] for peak in important_peaks] - - # For display, limit to most significant peaks if there are many - display_limit = min(len(important_peaks), 15) # Show max 15 in table - print(f" → Showing top {display_limit} peaks in detailed table below") - top_peaks = [peak[0] for peak in important_peaks[:display_limit]] - else: - analysis_peak_indices = [] - top_peaks = [] - - # Simple element line matching - only include significant lines - element_lines_db = [] - all_info = type(self).element_info - if all_info is not None: - for elem, lines in all_info.items(): - # Get top 3 weighted lines per element - weighted_lines = [(info['weight'], info['energy (keV)'], elem, line) - for line, info in lines.items() - if info['energy (keV)'] <= 12.0] # Ignore > 12 keV - # Sort by weight (highest first) and take top 3 - weighted_lines.sort(reverse=True) - for weight, energy, elem, line in weighted_lines[:3]: - if weight > 0.1: # Only include lines with significant weight - element_lines_db.append((energy, elem, line, weight)) - - # Use cost function with all statistically significant peaks - if len(analysis_peak_indices) > 0: - analysis_energies = E[analysis_peak_indices] - analysis_intensities = spec[analysis_peak_indices] - - # Find optimal element combination using all significant peaks - best_elements = self._find_best_element_combinations( - analysis_energies, analysis_intensities, tolerance - ) - - peak_energies = E[top_peaks] # For display table - peak_intensities = spec[top_peaks] - else: - best_elements = set() - peak_energies = [] - peak_intensities = [] - - # Now create detailed peak matching report (only if we found significant peaks) - if len(top_peaks) > 0: - peak_data = [] - for idx in top_peaks: - peak_energy = E[idx] - peak_intensity = spec[idx] - # Find matches within the identified elements first, then others - matches = [] - if element_lines_db: - distances = [] - for el_energy, elem, line, weight in element_lines_db: - distance = abs(el_energy - peak_energy) - if distance <= tolerance: - # Prioritize matches from identified elements - priority = 0 if elem in best_elements else 1 - distances.append((priority, distance, elem, line, el_energy, weight)) - - # Sort by priority (identified elements first), then distance - distances.sort(key=lambda x: (x[0], x[1])) - matches = [(d[1], d[2], d[3], d[4], d[5]) for d in distances[:3]] # distance, elem, line, energy, weight + # For each detected peak, find the best match in the database + for peak_idx, height, peak_energy, snr in display_peaks: + best_match = None + best_distance = float('inf') + best_element = None + + # Search through elements in database + if all_info: + for elem, lines in all_info.items(): + # If specific elements requested, only search those + if search_elements is not None and elem not in search_elements: + continue + + for line_name, line_info in lines.items(): + line_energy = line_info['energy (keV)'] + line_weight = line_info.get('weight', 0.5) + distance = abs(peak_energy - line_energy) + + # Prioritize K and L lines over M lines for element identification + # M-lines are very weak and prone to false positives at low energies + is_m_line = 'M' in line_name and not ('Ma' in line_name or 'Mb' in line_name) + + # Match to characteristic lines within tolerance + # Require weight > 0.3 (filters weakest M-lines) + # Penalize M-line matches by requiring closer distance + effective_tolerance = tolerance * 0.5 if is_m_line else tolerance + + if line_weight > 0.3 and distance <= effective_tolerance and distance < best_distance: + best_distance = distance + best_match = f"{elem} {line_name}" + best_element = elem - peak_data.append((peak_energy, peak_intensity, matches, idx)) + if best_match: + peak_matches.append((peak_idx, height, peak_energy, snr, best_element, best_match, best_distance)) + print(f"{peak_energy:<12.3f} {height:<12.1f} {snr:<8.1f} {best_match:<25}") + else: + print(f"{peak_energy:<12.3f} {height:<12.1f} {snr:<8.1f} {'Unknown':<25}") - # Sort by energy for table display - peak_data_sorted = sorted(peak_data, key=lambda x: x[0]) + print("-"*60) - # Print detailed peak summary table with backup options - print(f"\nIdentified Elements: {', '.join(sorted(best_elements)) if best_elements else 'None detected'}") - print(f"{'Energy (keV)':<12} {'Intensity':<12} {'Primary Match':<20} {'2nd Option':<20} {'3rd Option':<20}") - print("-"*84) + # Detect elements: use only the strongest peaks that match VERY well + # Strategy: keep only peaks that: + # 1. Match a characteristic line within distance_threshold_for_sample (very tight tolerance) + # 2. Have SNR > snr_threshold_for_sample (strong peaks) + # 3. Are from non-contamination elements (or requested elements if specified) + detected_elements = set() + detected_sample_peaks = {} # Map peak_energy -> is_sample_element for line styling - for energy, intensity, matches, idx in peak_data_sorted: - # Primary match - if len(matches) > 0: - elem = matches[0][1] - line = matches[0][2] - if elem == 'Cu': - primary = f"{elem} {line} (grid)" - elif elem == 'C': - primary = f"{elem} {line} (carbon)" - else: - primary = f"{elem} {line}" - else: - primary = "Unknown" - - # Secondary match - if len(matches) > 1: - elem2 = matches[1][1] - line2 = matches[1][2] - if elem2 == 'Cu': - secondary = f"{elem2} {line2} (grid)" - elif elem2 == 'C': - secondary = f"{elem2} {line2} (carbon)" - else: - secondary = f"{elem2} {line2}" - else: - secondary = "-" - - # Tertiary match - if len(matches) > 2: - elem3 = matches[2][1] - line3 = matches[2][2] - if elem3 == 'Cu': - tertiary = f"{elem3} {line3} (grid)" - elif elem3 == 'C': - tertiary = f"{elem3} {line3} (carbon)" + for peak_idx, height, peak_energy, snr, element, match_str, distance in peak_matches: + # Very strict criteria for element detection + if (snr > snr_threshold_for_sample and # Strong peak + distance < distance_threshold_for_sample): # Very close match to characteristic line + + # If specific elements requested, only keep those; otherwise exclude contamination + if search_elements is not None: + if element in search_elements: + detected_elements.add(element) + detected_sample_peaks[peak_energy] = True else: - tertiary = f"{elem3} {line3}" - else: - tertiary = "-" - - print(f"{energy:<12.3f} {intensity:<12.1f} {primary:<20} {secondary:<20} {tertiary:<20}") - print("-"*84) + if element not in contamination_elements: # Not a known contamination + detected_elements.add(element) + detected_sample_peaks[peak_energy] = True - # Plot lines for the identified elements - y_max = ax_spec.get_ylim()[1] - element_colors = ['red', 'blue', 'green', 'orange', 'purple', 'brown', 'pink', 'gray'] + # MULTI-PEAK COHERENCE CHECK: Filter out elements with only single weak matches + # Count DISTINCT characteristic lines for each element (Ka vs Kb, La vs Lb, etc.) + element_line_types = {} # element -> set of line types (e.g., 'Ka', 'Lb') + element_total_snr = {} + element_has_major_lines = {} # Track if element has K or L lines (not just M) - # Plot lines for cost-function identified elements - for color_idx, elem in enumerate(list(best_elements)): - if elem in all_info: - elem_color = element_colors[color_idx % len(element_colors)] + for peak_idx, height, peak_energy, snr, element, match_str, distance in peak_matches: + # Count ALL good matches for each element (not just sample-quality ones) + if distance < tolerance * 2: # Within 2x tolerance for counting + if element not in element_line_types: + element_line_types[element] = set() + element_total_snr[element] = 0 + element_has_major_lines[element] = False - # Get top 3 weighted lines for this element (same logic as above) - weighted_lines = [(info['weight'], info['energy (keV)'], line_name) - for line_name, info in all_info[elem].items() - if info['energy (keV)'] <= 12.0] - weighted_lines.sort(reverse=True) + # Extract line type from match_str (e.g., "Pt La" -> "La") + line_type = match_str.split()[-1] if match_str else "" + element_line_types[element].add(line_type) + element_total_snr[element] += snr - # Plot only top 3 significant lines - for weight, line_energy, line_name in weighted_lines[:3]: - if weight > 0.1 and line_energy >= E[0] and line_energy <= E[-1]: - # Skip lines in ignore range - if ignore_range is not None and len(ignore_range) == 2: - min_ignore, max_ignore = ignore_range - if min_ignore <= line_energy <= max_ignore: - continue - - # Plot line at theoretical position - line_alpha = 0.3 + 0.2 * weight # More prominent for higher weight - ax_spec.axvline(line_energy, color=elem_color, linestyle='--', - alpha=min(line_alpha, 0.8), linewidth=1.5) - - # Add label with better positioning and spacing - y_pos = y_max * (0.85 - color_idx * 0.08) # Start higher, space down - label_x = line_energy + 0.1 # More offset from line - - # Add special labeling for substrate elements - if elem == 'Cu': - label_text = f"{elem} {line_name} (grid)" - elif elem == 'C': - label_text = f"{elem} {line_name} (carbon)" - else: - label_text = f"{elem} {line_name}" - - ax_spec.text(label_x, y_pos, label_text, - rotation=90, va='bottom', ha='left', - fontsize=8, color=elem_color, weight='normal', - alpha=0.8) - except ImportError: - print("scipy is required for auto peak labeling. Please install scipy.") - - # ELEMENT LINES --------------------------------------------------------------------- - lines_to_plot = None - if elements is not None: - if isinstance(elements, list): - # Convert list of element symbols to dict using class element_info - all_info = type(self).element_info - if all_info is not None: - lines_to_plot = {el: all_info[el] for el in elements if el in all_info} - elif isinstance(elements, dict): - lines_to_plot = elements - elif hasattr(self, 'model_elements'): - # Use model elements if available - lines_to_plot = self.model_elements - - if lines_to_plot is not None: - E_min = E[0] if len(E) > 0 else 0 - E_max = E[-1] if len(E) > 0 else 20 - element_labels = [] # Store element label positions (energy, y_position) - element_line_data = [] # Store element line data for summary table - y_max = ax.get_ylim()[1] - colors = ['orange', 'red', 'blue', 'purple', 'brown', 'pink', 'gray', 'olive', 'cyan', 'magenta'] - - # Collect all lines to plot first for better positioning - all_lines_to_plot = [] - - # First pass: collect all lines that will be plotted and track elements - elements_to_label = {} # Track which element gets labeled and where - - for color_idx, (elem, lines) in enumerate(lines_to_plot.items()): - color = colors[color_idx % len(colors)] - - # Get top 3 weighted lines for this element - weighted_lines = [(info['weight'], info['energy (keV)'], line, info) - for line, info in lines.items() - if info['energy (keV)'] <= 12.0] # Ignore > 12 keV - weighted_lines.sort(reverse=True) - - element_lines = [] # Lines for this element that will be plotted - - # Process only top 3 significant lines - for weight, energy, line, info in weighted_lines[:3]: - if weight <= 0.1: # Skip lines with very low weight - continue + # Check if this is a major line (K or L series) + if any(x in line_type for x in ['Ka', 'Kb', 'La', 'Lb', 'Lg']): + element_has_major_lines[element] = True + + # Filter detected_elements: keep only if multiple DISTINCT lines OR very high SNR + # CRITICAL: Reject elements with only M-lines (no K or L confirmation) + filtered_detected_elements = set() + for element in detected_elements: + distinct_line_count = len(element_line_types.get(element, set())) + total_snr = element_total_snr.get(element, 0) + avg_snr = total_snr / distinct_line_count if distinct_line_count > 0 else 0 + has_major_lines = element_has_major_lines.get(element, False) + + # Keep element if: + # - Has K or L lines (not just M-lines) - required for heavy elements + # - AND (has 2+ DISTINCT lines OR 1 line with very high SNR >70) + if has_major_lines and (distinct_line_count >= 2 or avg_snr > 70): + filtered_detected_elements.add(element) - # Skip lines outside energy range - if energy < E_min or energy > E_max: - continue + # Update detected_elements with filtered set + detected_elements = filtered_detected_elements - # Skip element lines in ignore range - if ignore_range is not None and len(ignore_range) == 2: - min_ignore, max_ignore = ignore_range - if min_ignore <= energy <= max_ignore: - continue + # Update detected_sample_peaks to only include filtered elements + filtered_sample_peaks = {} + for peak_energy, is_sample in detected_sample_peaks.items(): + # Find which element this peak belongs to + for peak_idx, height, pe, snr, element, match_str, distance in peak_matches: + if abs(pe - peak_energy) < 0.001 and element in detected_elements: + filtered_sample_peaks[peak_energy] = is_sample + break + detected_sample_peaks = filtered_sample_peaks - all_lines_to_plot.append((energy, elem, line, color, weight)) - element_lines.append((energy, line, weight)) - - # Determine which line should get the element label (highest weight) - if element_lines: - # Sort by weight and choose the most prominent line for labeling - element_lines.sort(key=lambda x: x[2], reverse=True) - label_energy, label_line, _ = element_lines[0] - elements_to_label[elem] = (label_energy, label_line, color) - - # Sort all lines by energy for better positioning - all_lines_to_plot.sort(key=lambda x: x[0]) - - # Second pass: plot all lines (but only label once per element) - for i, (energy, elem, line, color, weight) in enumerate(all_lines_to_plot): - # Find the closest channel - idx = np.abs(E - energy).argmin() - - # Weight-based alpha (more prominent for higher weights) - line_alpha = 0.3 + 0.4 * weight - ax.axvline(E[idx], color=color, linestyle='-', alpha=min(line_alpha, 0.8), linewidth=1.5) - - # Store element line data for summary table - intensity = spec[idx] if 'spec' in locals() else 0 - element_line_data.append((E[idx], intensity, elem, line, weight)) - - # Third pass: Add labels only for the most prominent line of each element - # Sort elements by energy for systematic positioning - sorted_elements = sorted(elements_to_label.items(), key=lambda x: x[1][0]) - - for i, (elem, (label_energy, label_line, color)) in enumerate(sorted_elements): - # Find the closest channel for the label energy - idx = np.abs(E - label_energy).argmin() - - # Simple vertical spacing - each element gets its own height level - base_y = y_max * 0.9 # Start near top - vertical_spacing = y_max * 0.08 # 8% spacing between labels - - # Position labels at regular intervals going down - final_y_pos = base_y - (i * vertical_spacing) - - # Keep within reasonable bounds - final_y_pos = max(final_y_pos, y_max * 0.2) # Don't go below 20% - - element_labels.append((E[idx], final_y_pos)) - - # Add label with better offset and styling - label_x = E[idx] - 0.1 # Offset from line - - # Add special labeling for substrate elements (element name only, no line designation) - if elem == 'Cu': - label_text = f"{elem} (grid)" - elif elem == 'C': - label_text = f"{elem} (carbon)" + # Plot detected peaks with appropriate line style (limit to display_peaks) + for peak_idx, height, peak_energy, snr in display_peaks: + # Use solid line for sample elements, dotted for others + is_sample = detected_sample_peaks.get(peak_energy, False) + linestyle = '-' if is_sample else ':' + + ax_spec.axvline(peak_energy, color='red', linestyle=linestyle, alpha=0.3, linewidth=1.5) + + # Add labels for grid artifacts and sample elements (if show_text enabled) + if show_text: + y_pos = height * 0.7 # Position label at 70% of peak height + + # Check if this is a grid/contamination peak + is_grid_peak = False + for grid_elem, grid_energy in grid_peaks.items(): + if abs(peak_energy - grid_energy) < 0.1: # Within 0.1 keV of known grid peak + ax_spec.text(peak_energy, y_pos, f'{grid_elem}\n(grid)', + ha='center', va='bottom', fontsize=8, color='gray', style='italic') + is_grid_peak = True + break + + # If elements were detected, use them for element identification only (not for line plotting) + if detected_elements: + print(f"\nDetected elements: {', '.join(sorted(detected_elements))}") + + # Prepare labels with vertical orientation and offset handling + # Group labels by energy proximity (within 0.3 keV) + labels_to_plot = [] # List of (peak_energy, label_text, color, height) + colors_map = {'Fe': 'darkblue', 'Pt': 'darkred'} + + for peak_idx, height, peak_energy, snr, element, match_str, distance in peak_matches: + if element in detected_elements: + # Extract line name from match_str (e.g., "Fe Ka" -> "Ka") + line_name = match_str.split()[-1] if match_str else "" + label_text = f"{element} {line_name}" if line_name else element + color = colors_map.get(element, 'black') + labels_to_plot.append((peak_energy, label_text, color, height)) + + # Sort by energy to group nearby peaks + labels_to_plot.sort(key=lambda x: x[0]) + + # Offset overlapping labels vertically + label_offset_map = {} # Map peak_energy -> vertical offset multiplier + proximity_threshold = 1.5 # 1.5 keV + + for i, (energy, label, color, height) in enumerate(labels_to_plot): + # Check if this label is close to previous labels + offset_count = 0 + for j in range(i): + prev_energy, prev_label, prev_color, prev_height = labels_to_plot[j] + if abs(energy - prev_energy) < proximity_threshold: + offset_count += 1 + + label_offset_map[energy] = offset_count + + # Plot labels with vertical text and offsets (if show_text enabled) + if show_text: + for peak_energy, label_text, color, height in labels_to_plot: + # Position label above the peak + y_pos = height * 1.2 + + ax_spec.text(peak_energy, y_pos, label_text, + ha='center', va='bottom', fontsize=10, color=color, + weight='bold', rotation=90) else: - label_text = elem # Just the element symbol - - ax.text(label_x, final_y_pos, label_text, rotation=90, va='bottom', ha='right', - fontsize=8, color=color, weight='normal', alpha=0.8, clip_on=True) - - # Print concise element lines summary - if element_line_data: - element_line_data_sorted = sorted(element_line_data, key=lambda x: x[0]) # Sort by energy - print(f"\nElement Lines: {', '.join([f'{elem} {line}' for _, _, elem, line, _ in element_line_data_sorted])}") + print(f"\nNo peaks detected with SNR >= {min_snr:.1f}") + + except ImportError: + print("scipy is required for peak detection. Please install scipy.") + + # Skip element lines plotting - only show detected peaks + # (Element characteristic lines are not plotted when using auto-detection) fig.tight_layout() plt.show() - return fig, (ax_img, ax_spec) - Dataset3dspectroscopy.load_element_info() diff --git a/src/quantem/spectroscopy/kfacs_Titan_300_keV.csv b/src/quantem/spectroscopy/kfacs_Titan_300_keV.csv new file mode 100644 index 00000000..41431179 --- /dev/null +++ b/src/quantem/spectroscopy/kfacs_Titan_300_keV.csv @@ -0,0 +1,119 @@ +Element,K,L,M +H,0.000,0.000,0.000 +He,0.000,0.000,0.000 +Li,0.000,0.000,0.000 +Be,124.024,0.000,0.000 +B,5.043,0.000,0.000 +C,2.752,0.000,0.000 +N,1.667,0.000,0.000 +O,1.240,0.000,0.000 +F,1.179,0.000,0.000 +Ne,0.824,0.000,0.000 +Na,0.882,0.000,0.000 +Mg,0.939,0.000,0.000 +Al,1.002,0.000,0.000 +Si,1.000,0.000,0.000 +P,1.096,0.000,0.000 +S,1.039,0.000,0.000 +Cl,1.093,0.000,0.000 +Ar,1.188,0.000,0.000 +K,1.119,0.000,0.000 +Ca,1.200,31.337,0.000 +Sc,1.232,21.223,0.000 +Ti,1.249,15.857,0.000 +V,1.297,9.514,0.000 +Cr,1.314,5.235,0.000 +Mn,1.396,3.360,0.000 +Fe,1.440,2.116,0.000 +Co,1.552,1.922,0.000 +Ni,1.587,1.582,0.000 +Cu,1.773,1.523,0.000 +Zn,1.895,1.631,0.000 +Ga,2.112,2.026,0.000 +Ge,2.322,2.008,0.000 +As,2.561,2.109,0.000 +Se,2.923,2.156,0.000 +Br,3.255,2.135,0.000 +Kr,3.780,2.220,0.000 +Rb,4.364,2.227,0.000 +Sr,5.075,2.279,0.000 +Y,5.919,2.438,0.000 +Zr,7.018,2.458,0.000 +Nb,8.282,2.410,0.000 +Mo,10.021,2.464,0.000 +Tc,12.010,2.628,0.000 +Ru,14.672,2.595,0.000 +Rh,17.523,2.728,0.000 +Pd,21.416,2.847,0.000 +Ag,25.856,2.914,0.000 +Cd,31.748,3.052,0.000 +In,38.737,3.049,0.000 +Sn,47.291,3.427,0.000 +Sb,57.584,3.546,0.000 +Te,71.403,3.702,0.000 +I,83.812,3.679,0.000 +Xe,102.774,3.773,0.000 +Cs,122.868,3.798,0.000 +Ba,150.444,3.907,208.795 +La,179.606,3.894,32.017 +Ce,213.080,3.890,19.339 +Pr,252.760,3.879,14.609 +Nd,303.895,3.940,11.826 +Pm,357.415,3.948,9.249 +Sm,434.673,4.138,9.091 +Eu,513.444,4.153,6.834 +Gd,621.351,4.276,3.561 +Tb,733.816,4.282,5.246 +Dy,873.762,4.320,4.151 +Ho,1034.116,4.386,3.867 +Er,1218.828,4.407,3.773 +Tm,1432.640,4.451,2.730 +Yb,1702.171,4.387,2.623 +Lu,1998.379,4.643,1.892 +Hf,2360.330,4.751,1.831 +Ta,2763.368,4.815,1.764 +W,3246.236,4.901,1.740 +Re,3804.888,4.890,2.282 +Os,4484.376,4.915,0.765 +Ir,5218.560,4.899,1.332 +Pt,6103.642,4.986,1.500 +Au,7084.786,4.987,1.599 +Hg,8301.074,5.104,2.034 +Tl,9740.107,5.254,2.968 +Pb,11340.739,5.411,3.714 +Bi,13103.192,5.575,3.596 +Po,15023.039,5.713,3.644 +At,17307.534,5.871,3.624 +Rn,20926.528,6.421,3.874 +Fr,24049.540,6.657,3.727 +Ra,27822.490,6.973,3.636 +Ac,31894.971,7.301,3.523 +Th,37230.598,7.767,3.397 +Pa,42235.953,7.882,3.359 +U,49591.305,7.881,3.324 +Np,56282.882,8.047,3.247 +Pu,65884.009,8.698,3.342 +Am,74663.091,9.038,3.223 +Cm,86382.575,10.449,3.144 +Bk,98350.202,11.113,3.128 +Cf,113576.844,11.859,3.159 +Es,0,0,0 +Fm,0,0,0 +Md,0,0,0 +No,0,0,0 +Lr,0,0,0 +Rf,0,0,0 +Db,0,0,0 +Sg,0,0,0 +Bh,0,0,0 +Hs,0,0,0 +Mt,0,0,0 +Ds,0,0,0 +Rg,0,0,0 +Cn,0,0,0 +Uut,0,0,0 +Fl,0,0,0 +Uup,0,0,0 +Lv,0,0,0 +Uus,0,0,0 +Uuo,0,0,0 \ No newline at end of file From 97830e7237417e31b6c226b19128b716c6912882 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Wed, 7 Jan 2026 11:13:59 -0800 Subject: [PATCH 015/136] added comment line as test commit --- src/quantem/spectroscopy/dataset3dspectroscopy.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 9488ba6c..5f5167dd 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -1336,3 +1336,5 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ Dataset3dspectroscopy.load_element_info() + +#testcommit \ No newline at end of file From 447ce327710865a9a913be93b1222da4272f97aa Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Wed, 7 Jan 2026 11:26:33 -0800 Subject: [PATCH 016/136] removed test comment line --- src/quantem/spectroscopy/dataset3dspectroscopy.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 5f5167dd..ffbbaeda 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -1335,6 +1335,4 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ return fig, (ax_img, ax_spec) -Dataset3dspectroscopy.load_element_info() - -#testcommit \ No newline at end of file +Dataset3dspectroscopy.load_element_info() \ No newline at end of file From a54b63ab5f942c880e226ba7246785cf8fa35704 Mon Sep 17 00:00:00 2001 From: smribet Date: Thu, 8 Jan 2026 05:45:57 -0800 Subject: [PATCH 017/136] adding metadata to spectroscopy --- src/quantem/core/datastructures/dataset3d.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/quantem/core/datastructures/dataset3d.py b/src/quantem/core/datastructures/dataset3d.py index 53d6d02c..9c0a13ee 100644 --- a/src/quantem/core/datastructures/dataset3d.py +++ b/src/quantem/core/datastructures/dataset3d.py @@ -27,6 +27,7 @@ def __init__( sampling: NDArray | tuple | list | float | int, units: list[str] | tuple | list, signal_units: str = "arb. units", + metadata: dict = {}, _token: object | None = None, ): """Initialize a 3D dataset. @@ -55,6 +56,7 @@ def __init__( sampling=sampling, units=units, signal_units=signal_units, + metadata=metadata, _token=_token, ) From 3b2f97794ef272b0664c527bbfb6887e513a21c4 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Fri, 16 Jan 2026 09:51:21 -0800 Subject: [PATCH 018/136] adding subtract_background function and moving background subtraction routines to dataset3deds and dataset3deels --- src/quantem/spectroscopy/dataset3deds.py | 186 ++++++++++++++++++ src/quantem/spectroscopy/dataset3deels.py | 57 ++++++ .../spectroscopy/dataset3dspectroscopy.py | 50 ++++- 3 files changed, 292 insertions(+), 1 deletion(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index b5b47b69..c8468a73 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -54,3 +54,189 @@ def __init__( _token=_token, ) self._virtual_images = {} + +#Separate function for background subtraction to return a subtracted EDS spectra + + def calculate_background_powerlaw(self, roi=None, energy_range=None, ignore_range=None, mask=None): + + import numpy as np + import matplotlib as mpl + import matplotlib.pyplot as plt + from matplotlib.patches import Rectangle + from sklearn.decomposition import PCA + + from quantem.core.datastructures.dataset3d import Dataset3d + from quantem.core.utils.validators import ensure_valid_array + + # TEMP- COPY OF ROI SELECTION CODE FROM SHOW_MEAN_SPECTRUM--------------------------- + + # ADJUST ROI BASED ON GIVEN FLAGS ----------------------------------------------- + # Parse ROI parameter + if roi is None: + # Full image + y, x, dy, dx = 0, 0, int(self.shape[1]), int(self.shape[2]) + elif len(roi) == 2: + # Single pixel [y, x] + y, x, dy, dx = int(roi[0]), int(roi[1]), 1, 1 + elif len(roi) == 4: + # Full ROI [y, x, dy, dx] with None support for defaults + y_val, x_val, dy_val, dx_val = roi + + # Handle None values with defaults + y = 0 if y_val is None else int(y_val) + x = 0 if x_val is None else int(x_val) + dy = int(self.shape[1]) - y if dy_val is None else int(dy_val) + dx = int(self.shape[2]) - x if dx_val is None else int(dx_val) + else: + raise ValueError("roi must be None, [y, x], or [y, x, dy, dx] (with None for defaults)") + + + # VALIDATE ROI BOUNDS --------------------------------------------------------------------------- + errs = [] + Ymax = int(self.shape[1]) + Xmax = int(self.shape[2]) + + # type/NaN checks (optional if you already cast to int above) + for name, val in (("y", y), ("x", x), ("dy", dy), ("dx", dx)): + if val is None: + errs.append(f"{name} is None (missing after normalization).") + + # if any None, bail early to avoid arithmetic errors + if errs: + raise ValueError("Invalid ROI:\n - " + "\n - ".join(errs)) + + # basic constraints + if y < 0: errs.append(f"y={y} < 0") + if x < 0: errs.append(f"x={x} < 0") + if dy < 1: errs.append(f"dy={dy} < 1") + if dx < 1: errs.append(f"dx={dx} < 1") + + # starts within image + if y >= Ymax: errs.append(f"y start {y} out of bounds [0, {Ymax-1}]") + if x >= Xmax: errs.append(f"x start {x} out of bounds [0, {Xmax-1}]") + + # ends within image + end_y = y + dy + end_x = x + dx + if end_y > Ymax: errs.append(f"y+dy = {end_y} exceeds height {Ymax}") + if end_x > Xmax: errs.append(f"x+dx = {end_x} exceeds width {Xmax}") + + if errs: + raise ValueError("Invalid ROI:\n - " + "\n - ".join(errs)) + + #TEMP- COPY OF SPECTRUM CALCULATION FROM SHOW_MEAN_SPECTRUM--------------------------- + + # SPECTRUM CALCULATION -------------------------------------------------------------- + + dE = float(self.sampling[0]) + E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 + E = E0 + dE * np.arange(self.shape[0]) + + # MASK HANDLING --------------------------------------------------------------------- + if mask is not None: + # Convert to ndarray and validate + mask = np.asarray(mask) + + # Check that it's a proper ndarray + if not isinstance(mask, np.ndarray): + raise TypeError(f"Mask must be a numpy ndarray, got {type(mask)}") + + # Check dimensions - must be 1D + if mask.ndim != 1: + raise ValueError(f"Mask must be 1-dimensional, got {mask.ndim}D array with shape {mask.shape}") + + # Convert to bool dtype and validate + if mask.dtype != bool: + try: + mask = mask.astype(bool) + except (ValueError, TypeError): + raise TypeError(f"Mask cannot be converted to boolean dtype from {mask.dtype}") + + # Check shape matches energy axis + arr = np.asarray(self.array, dtype=float) + if mask.shape != (arr.shape[0],): + raise ValueError(f"Mask shape {mask.shape} does not match energy axis shape ({arr.shape[0]},)") + + arr = arr[mask, :, :] # select only masked energy channels + spec = arr.sum(axis=(1,2)) if arr.shape[0] > 0 else np.zeros(0) + E = E[mask] # Mask the energy axis as well + else: + spec = np.empty(self.shape[0], dtype=float) + for k in range(self.shape[0]): + img = np.asarray(self.array[k], dtype=float) + roi = img[y:y+dy, x:x+dx] + if roi.size == 0: + raise ValueError("ROI is empty; check y/x/dy/dx.") + spec[k] = roi.mean() + + # Store ignore_range for later use in element line filtering + if ignore_range is None: + ignore_range = [0, 0.25] # Default: ignore 0-0.25 keV for element lines only + + #----------------------------------------------------------------------------------- + + #POWER LAW BACKGROUND SUBTRACTION + + #TEMP- PORT OF SUBTRACT_BACKGROUND_EDS BODY + + """ + Subtract power-law background typical for EDS Bremsstrahlung. + Uses a conservative approach with heavy smoothing to avoid creating artifacts. + + Parameters + ---------- + spectrum : ndarray + 1D spectrum + energy_axis : ndarray + Energy axis corresponding to spectrum + + Returns + ------- + ndarray + Background-subtracted spectrum + """ + from scipy.ndimage import gaussian_filter + + # Use a larger window for more conservative background estimation + window_size = 15 # Larger window = smoother, less aggressive + background = np.zeros_like(spec) + half_window = window_size // 2 + + # Estimate background from sliding minimum + for i in range(len(spec)): + start = max(0, i - half_window) + end = min(len(spec), i + half_window + 1) + # Use percentile instead of minimum for more robustness + background[i] = np.percentile(spec[start:end], 10) + + # Apply heavy smoothing to avoid creating artificial features + background = gaussian_filter(background, sigma=5.0) + + # Be very conservative - only subtract 80% of estimated background + # This prevents over-subtraction that creates artificial peaks + background = background * 0.8 + + # Ensure background doesn't exceed spectrum + background = np.minimum(background, spec* 0.9) + + subtracted_mean_spectrum = np.maximum(spec - background, 0) + + #TEMP- PORT OF SPECTRUM PLOTTING CODE FROM SHOW_MEAN_SPECTRUM + # PLOTTING --------------------------------------------------------------------------- + + # Create subplot layout: image on left, spectrum on right + fig, (ax_spec) = plt.subplots(1, 1, figsize=(12, 4)) + + + + # RIGHT PLOT: Show spectrum + ax_spec.plot(E, subtracted_mean_spectrum, linewidth=1.5) + ax_spec.set_xlabel("Energy (keV)") + ax_spec.set_ylabel("Intensity") + ax_spec.set_title(f"Spectrum from ROI [{y}:{y+dy}, {x}:{x+dx}]") + ax_spec.grid(True, alpha=0.1) + + fig.tight_layout() + plt.show() + + return background \ No newline at end of file diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index 74c39733..5288e1f1 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -54,3 +54,60 @@ def __init__( _token=_token, ) self._virtual_images = {} + + + def calculate_background_iterative(self, roi=None, energy_range=None, ignore_range=None, mask=None): + """ + Subtract background typical for EELS using iterative Gaussian fitting. + This method isolates the continuum background from the low-loss region. + + WARNING: Only use with EELS data! Will remove peaks if used with EDS. + + Parameters + ---------- + spectrum : ndarray + 1D EELS spectrum + energy_axis : ndarray + Energy axis corresponding to spectrum + + Returns + ------- + ndarray + Background-subtracted spectrum + """ + + import numpy as np + import matplotlib as mpl + import matplotlib.pyplot as plt + from matplotlib.patches import Rectangle + from sklearn.decomposition import PCA + + from quantem.core.datastructures.dataset3d import Dataset3d + from quantem.core.utils.validators import ensure_valid_array + + + from scipy.stats import norm + from scipy.ndimage import gaussian_filter + + # Smooth for better fitting + spec_smooth = gaussian_filter(spectrum, sigma=1.0) + pixel_vals = spec_smooth.copy() + + # Iteratively fit Gaussian to low-intensity values (the continuum) + # Remove outliers (edge peaks) iteratively + num_iterations = 10 + cutoff = 3 # +/- 3 sigma + + for _ in range(num_iterations): + mu, std = norm.fit(pixel_vals) + if std == 0: + break + # Keep only values within +/- 3 sigma (removes edge contributions) + lower = mu - cutoff * std + upper = mu + cutoff * std + pixel_vals = pixel_vals[(pixel_vals >= lower) & (pixel_vals <= upper)] + + # Subtract the estimated background level + background_fit = mu + + return background_fit \ No newline at end of file diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index ffbbaeda..dbcd4cf0 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -60,6 +60,7 @@ def __init__(self, array, name=None, origin=None, sampling=None, units=None, sig # Initialize model elements storage self.model_elements = None + def add_elements_to_model(self, elements): """ @@ -696,6 +697,8 @@ def _calculate_element_combo_score(self, element_combo, peak_energies, peak_inte return score +# BACKGROUND SUBTRACTION + def _subtract_background_eds(self, spectrum, energy_axis): """ Subtract power-law background typical for EDS Bremsstrahlung. @@ -738,7 +741,7 @@ def _subtract_background_eds(self, spectrum, energy_axis): background = np.minimum(background, spectrum * 0.9) return np.maximum(spectrum - background, 0) - + def _subtract_background_eels(self, spectrum, energy_axis): """ Subtract background typical for EELS using iterative Gaussian fitting. @@ -782,6 +785,7 @@ def _subtract_background_eels(self, spectrum, energy_axis): # Subtract the estimated background level background_fit = mu return np.maximum(spectrum - background_fit, 0) + def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_range=None, threshold=5.0, tolerance=0.15, mask=None, show_lines=True, show_text=True, snr_min=None, snr_threshold=None, distance_threshold_for_sample=0.05, contamination_elements=None, grid_peaks=None, background_subtraction='none', data_type='eds',peaks=15): """ @@ -1335,4 +1339,48 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ return fig, (ax_img, ax_spec) + + def subtract_background(self, roi=None, energy_range=None, ignore_range=None, mask=None, data_type='eds'): + + from quantem.spectroscopy import ( + Dataset3deds as Dataset3deds, + ) + from quantem.spectroscopy import ( + Dataset3deels as Dataset3deels, + ) + + """ + Perform appropriate background subtraction routine on mean spectrum from a 3D spectroscopy dataset. + """ + + if data_type == 'eds': + background = self.calculate_background_powerlaw(roi, energy_range, ignore_range, mask) + elif data_type == 'eels': + background = self.calculate_background_iterative(roi, energy_range, ignore_range, mask) + + spec3D_subtracted = np.empty(self.shape, dtype=float) + + + for p in range(self.shape[1]): + for q in range(self.shape[2]): + spec3D_subtracted[:,p,q] = np.maximum(self.array[:,p,q] - background, 0) + + + if data_type == 'eds': + return Dataset3deds.from_array( + array = spec3D_subtracted, + sampling = self.sampling, + origin = self.origin, + units = self.units) + + elif data_type == 'eels': + return Dataset3deels.from_array( + array = spec3D_subtracted, + sampling = self.sampling, + origin = self.origin, + units = self.units) + + + + Dataset3dspectroscopy.load_element_info() \ No newline at end of file From 7bfa0a93fc524a88da23f3c01e731bf3684a6516 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Fri, 16 Jan 2026 16:43:04 -0800 Subject: [PATCH 019/136] removing redundant background subtraction function definitions from dataset3dspectroscopy --- src/quantem/spectroscopy/dataset3deds.py | 3 +- .../spectroscopy/dataset3dspectroscopy.py | 91 +------------------ 2 files changed, 3 insertions(+), 91 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index c8468a73..54dad660 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -222,7 +222,8 @@ def calculate_background_powerlaw(self, roi=None, energy_range=None, ignore_rang subtracted_mean_spectrum = np.maximum(spec - background, 0) #TEMP- PORT OF SPECTRUM PLOTTING CODE FROM SHOW_MEAN_SPECTRUM - # PLOTTING --------------------------------------------------------------------------- + + # PLOT MEAN BACKGROUND-SUBTRACTED SPECTRUM --------------------------------------------------------------------------- # Create subplot layout: image on left, spectrum on right fig, (ax_spec) = plt.subplots(1, 1, figsize=(12, 4)) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index dbcd4cf0..e3d4690c 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -696,95 +696,6 @@ def _calculate_element_combo_score(self, element_combo, peak_energies, peak_inte score -= len(matches) * 2.0 return score - -# BACKGROUND SUBTRACTION - - def _subtract_background_eds(self, spectrum, energy_axis): - """ - Subtract power-law background typical for EDS Bremsstrahlung. - Uses a conservative approach with heavy smoothing to avoid creating artifacts. - - Parameters - ---------- - spectrum : ndarray - 1D spectrum - energy_axis : ndarray - Energy axis corresponding to spectrum - - Returns - ------- - ndarray - Background-subtracted spectrum - """ - from scipy.ndimage import gaussian_filter - - # Use a larger window for more conservative background estimation - window_size = 15 # Larger window = smoother, less aggressive - background = np.zeros_like(spectrum) - half_window = window_size // 2 - - # Estimate background from sliding minimum - for i in range(len(spectrum)): - start = max(0, i - half_window) - end = min(len(spectrum), i + half_window + 1) - # Use percentile instead of minimum for more robustness - background[i] = np.percentile(spectrum[start:end], 10) - - # Apply heavy smoothing to avoid creating artificial features - background = gaussian_filter(background, sigma=5.0) - - # Be very conservative - only subtract 80% of estimated background - # This prevents over-subtraction that creates artificial peaks - background = background * 0.8 - - # Ensure background doesn't exceed spectrum - background = np.minimum(background, spectrum * 0.9) - - return np.maximum(spectrum - background, 0) - - def _subtract_background_eels(self, spectrum, energy_axis): - """ - Subtract background typical for EELS using iterative Gaussian fitting. - This method isolates the continuum background from the low-loss region. - - WARNING: Only use with EELS data! Will remove peaks if used with EDS. - - Parameters - ---------- - spectrum : ndarray - 1D EELS spectrum - energy_axis : ndarray - Energy axis corresponding to spectrum - - Returns - ------- - ndarray - Background-subtracted spectrum - """ - from scipy.stats import norm - from scipy.ndimage import gaussian_filter - - # Smooth for better fitting - spec_smooth = gaussian_filter(spectrum, sigma=1.0) - pixel_vals = spec_smooth.copy() - - # Iteratively fit Gaussian to low-intensity values (the continuum) - # Remove outliers (edge peaks) iteratively - num_iterations = 10 - cutoff = 3 # +/- 3 sigma - - for _ in range(num_iterations): - mu, std = norm.fit(pixel_vals) - if std == 0: - break - # Keep only values within +/- 3 sigma (removes edge contributions) - lower = mu - cutoff * std - upper = mu + cutoff * std - pixel_vals = pixel_vals[(pixel_vals >= lower) & (pixel_vals <= upper)] - - # Subtract the estimated background level - background_fit = mu - return np.maximum(spectrum - background_fit, 0) def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_range=None, threshold=5.0, tolerance=0.15, mask=None, show_lines=True, show_text=True, snr_min=None, snr_threshold=None, distance_threshold_for_sample=0.05, contamination_elements=None, grid_peaks=None, background_subtraction='none', data_type='eds',peaks=15): @@ -1338,7 +1249,7 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ plt.show() return fig, (ax_img, ax_spec) - +# BACKGROND SUBTRACTION def subtract_background(self, roi=None, energy_range=None, ignore_range=None, mask=None, data_type='eds'): From 0b2d5c72db6deaf14a0a71e43159d9a84fd063da Mon Sep 17 00:00:00 2001 From: smribet Date: Tue, 20 Jan 2026 18:16:40 -1000 Subject: [PATCH 020/136] first pass at torch model for EDS --- src/quantem/spectroscopy/dataset3deds.py | 414 ++++++++++++++--------- 1 file changed, 261 insertions(+), 153 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 54dad660..b11a774f 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -1,8 +1,13 @@ from typing import Any +import matplotlib.pyplot as plt +import numpy as np +import torch +import torch.nn as nn from numpy.typing import NDArray from quantem.spectroscopy import Dataset3dspectroscopy +from quantem.spectroscopy.spectroscopy_models import EDSModel, GaussianPeaks, PolynomialBackground class Dataset3deds(Dataset3dspectroscopy): @@ -55,131 +60,236 @@ def __init__( ) self._virtual_images = {} -#Separate function for background subtraction to return a subtracted EDS spectra + def fit_spectrum_pytorch( + self, + energy_range=None, + elements_to_fit=None, + num_iters=1000, + lr=0.01, + polynomial_background_degree=3, + ): + energy_axis = np.arange(self.shape[0]) * self.sampling[0] + self.origin[0] + energy_axis = torch.tensor(energy_axis, dtype=torch.float32) - def calculate_background_powerlaw(self, roi=None, energy_range=None, ignore_range=None, mask=None): + # TODO: make_more_flexible + spectrum_raw = torch.tensor(self.array.sum((-1, -2)), dtype=torch.float32) - import numpy as np - import matplotlib as mpl - import matplotlib.pyplot as plt - from matplotlib.patches import Rectangle - from sklearn.decomposition import PCA + if energy_range is not None: + ind = (energy_axis >= energy_range[0]) & (energy_axis <= energy_range[1]) + spectrum_raw = spectrum_raw[ind] + energy_axis = energy_axis[ind] + else: + energy_range = [energy_axis.min().numpy(), energy_axis.max().numpy()] - from quantem.core.datastructures.dataset3d import Dataset3d - from quantem.core.utils.validators import ensure_valid_array + # rescale 0 to 1 + spectrum_min = spectrum_raw.min() + spectrum_max = spectrum_raw.max() + spectrum = spectrum_raw - spectrum_min + spectrum = spectrum / (spectrum_max - spectrum_min) - # TEMP- COPY OF ROI SELECTION CODE FROM SHOW_MEAN_SPECTRUM--------------------------- + # initialize + background = PolynomialBackground(energy_axis, degree=polynomial_background_degree) - # ADJUST ROI BASED ON GIVEN FLAGS ----------------------------------------------- - # Parse ROI parameter - if roi is None: - # Full image - y, x, dy, dx = 0, 0, int(self.shape[1]), int(self.shape[2]) - elif len(roi) == 2: - # Single pixel [y, x] - y, x, dy, dx = int(roi[0]), int(roi[1]), 1, 1 - elif len(roi) == 4: - # Full ROI [y, x, dy, dx] with None support for defaults - y_val, x_val, dy_val, dx_val = roi - - # Handle None values with defaults - y = 0 if y_val is None else int(y_val) - x = 0 if x_val is None else int(x_val) - dy = int(self.shape[1]) - y if dy_val is None else int(dy_val) - dx = int(self.shape[2]) - x if dx_val is None else int(dx_val) - else: - raise ValueError("roi must be None, [y, x], or [y, x, dy, dx] (with None for defaults)") + peaks = GaussianPeaks(energy_axis, elements_to_fit=elements_to_fit) + model = EDSModel(peaks, background, energy_axis=energy_axis) + with torch.no_grad(): + model.peak_model.concentrations.fill_((1)) + model.peak_model.peak_width.fill_(0.1) - # VALIDATE ROI BOUNDS --------------------------------------------------------------------------- - errs = [] - Ymax = int(self.shape[1]) - Xmax = int(self.shape[2]) - - # type/NaN checks (optional if you already cast to int above) - for name, val in (("y", y), ("x", x), ("dy", dy), ("dx", dx)): - if val is None: - errs.append(f"{name} is None (missing after normalization).") - - # if any None, bail early to avoid arithmetic errors - if errs: - raise ValueError("Invalid ROI:\n - " + "\n - ".join(errs)) - - # basic constraints - if y < 0: errs.append(f"y={y} < 0") - if x < 0: errs.append(f"x={x} < 0") - if dy < 1: errs.append(f"dy={dy} < 1") - if dx < 1: errs.append(f"dx={dx} < 1") - - # starts within image - if y >= Ymax: errs.append(f"y start {y} out of bounds [0, {Ymax-1}]") - if x >= Xmax: errs.append(f"x start {x} out of bounds [0, {Xmax-1}]") - - # ends within image - end_y = y + dy - end_x = x + dx - if end_y > Ymax: errs.append(f"y+dy = {end_y} exceeds height {Ymax}") - if end_x > Xmax: errs.append(f"x+dx = {end_x} exceeds width {Xmax}") - - if errs: - raise ValueError("Invalid ROI:\n - " + "\n - ".join(errs)) - - #TEMP- COPY OF SPECTRUM CALCULATION FROM SHOW_MEAN_SPECTRUM--------------------------- - - # SPECTRUM CALCULATION -------------------------------------------------------------- + optimizer = torch.optim.Adam(model.parameters(), lr=lr) + loss_fn = nn.MSELoss() - dE = float(self.sampling[0]) - E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 - E = E0 + dE * np.arange(self.shape[0]) + loss_iter = [] + for iters in range(num_iters): + optimizer.zero_grad() - # MASK HANDLING --------------------------------------------------------------------- - if mask is not None: - # Convert to ndarray and validate - mask = np.asarray(mask) - - # Check that it's a proper ndarray - if not isinstance(mask, np.ndarray): - raise TypeError(f"Mask must be a numpy ndarray, got {type(mask)}") - - # Check dimensions - must be 1D - if mask.ndim != 1: - raise ValueError(f"Mask must be 1-dimensional, got {mask.ndim}D array with shape {mask.shape}") - - # Convert to bool dtype and validate - if mask.dtype != bool: - try: - mask = mask.astype(bool) - except (ValueError, TypeError): - raise TypeError(f"Mask cannot be converted to boolean dtype from {mask.dtype}") - - # Check shape matches energy axis - arr = np.asarray(self.array, dtype=float) - if mask.shape != (arr.shape[0],): - raise ValueError(f"Mask shape {mask.shape} does not match energy axis shape ({arr.shape[0]},)") - - arr = arr[mask, :, :] # select only masked energy channels - spec = arr.sum(axis=(1,2)) if arr.shape[0] > 0 else np.zeros(0) - E = E[mask] # Mask the energy axis as well - else: - spec = np.empty(self.shape[0], dtype=float) - for k in range(self.shape[0]): - img = np.asarray(self.array[k], dtype=float) - roi = img[y:y+dy, x:x+dx] - if roi.size == 0: - raise ValueError("ROI is empty; check y/x/dy/dx.") - spec[k] = roi.mean() + predicted = model() - # Store ignore_range for later use in element line filtering - if ignore_range is None: - ignore_range = [0, 0.25] # Default: ignore 0-0.25 keV for element lines only + loss = loss_fn(predicted, spectrum) - #----------------------------------------------------------------------------------- + loss.backward() + optimizer.step() - #POWER LAW BACKGROUND SUBTRACTION + loss_iter.append(loss.detach().numpy()) - #TEMP- PORT OF SUBTRACT_BACKGROUND_EDS BODY + loss_iter = np.asarray(loss_iter) + # plot_results + with torch.no_grad(): + final_pred = model().detach().numpy() * spectrum_max.numpy() + spectrum_min.numpy() + concs = nn.functional.softplus(model.peak_model.concentrations).detach().numpy() - """ + print(f"\nFinal: width={model.peak_model.peak_width.item():.3f} keV") + + # Sort and show top N + top_n = np.max((10, len(elements_to_fit))) + sorted_indices = np.argsort(concs)[::-1] + + print("\nTop elements:") + for i, idx in enumerate(sorted_indices[:top_n], 1): + elem = model.peak_model.element_names[idx] + conc = concs[idx] + print(f"{i:2d}. {elem:2s}: {conc:.3f}") + + fig, ax = plt.subplots(2, 1, figsize=(10, 6)) + ax[0].plot(np.arange(loss_iter.shape[0]), loss_iter, color="k") + ax[0].set_title("loss") + ax[0].set_xlabel("iterations") + ax[0].set_ylabel("loss") + + ax[1].plot(energy_axis, spectrum_raw.numpy(), "k-", label="Data", linewidth=1) + ax[1].plot(energy_axis, final_pred, "r-", label="Fit", linewidth=2) + + if model.background_model is not None: + background = ( + model.background_model().detach().numpy() * spectrum_max.numpy() + + spectrum_min.numpy() + ) + ax[1].plot(energy_axis, background, "b--", label="Background", linewidth=1.5) + + ax[1].set_xlim(energy_range[0], energy_range[1]) + ax[1].legend() + + ax[1].set_title("fit spectrum") + ax[1].set_xlabel("Energy (keV)") + ax[1].set_ylabel("Counts") + + plt.tight_layout() + plt.show() + + # Separate function for background subtraction to return a subtracted EDS spectra + + def calculate_background_powerlaw( + self, roi=None, energy_range=None, ignore_range=None, mask=None + ): + import matplotlib.pyplot as plt + import numpy as np + + # TEMP- COPY OF ROI SELECTION CODE FROM SHOW_MEAN_SPECTRUM--------------------------- + + # ADJUST ROI BASED ON GIVEN FLAGS ----------------------------------------------- + # Parse ROI parameter + if roi is None: + # Full image + y, x, dy, dx = 0, 0, int(self.shape[1]), int(self.shape[2]) + elif len(roi) == 2: + # Single pixel [y, x] + y, x, dy, dx = int(roi[0]), int(roi[1]), 1, 1 + elif len(roi) == 4: + # Full ROI [y, x, dy, dx] with None support for defaults + y_val, x_val, dy_val, dx_val = roi + + # Handle None values with defaults + y = 0 if y_val is None else int(y_val) + x = 0 if x_val is None else int(x_val) + dy = int(self.shape[1]) - y if dy_val is None else int(dy_val) + dx = int(self.shape[2]) - x if dx_val is None else int(dx_val) + else: + raise ValueError( + "roi must be None, [y, x], or [y, x, dy, dx] (with None for defaults)" + ) + + # VALIDATE ROI BOUNDS --------------------------------------------------------------------------- + errs = [] + Ymax = int(self.shape[1]) + Xmax = int(self.shape[2]) + + # type/NaN checks (optional if you already cast to int above) + for name, val in (("y", y), ("x", x), ("dy", dy), ("dx", dx)): + if val is None: + errs.append(f"{name} is None (missing after normalization).") + + # if any None, bail early to avoid arithmetic errors + if errs: + raise ValueError("Invalid ROI:\n - " + "\n - ".join(errs)) + + # basic constraints + if y < 0: + errs.append(f"y={y} < 0") + if x < 0: + errs.append(f"x={x} < 0") + if dy < 1: + errs.append(f"dy={dy} < 1") + if dx < 1: + errs.append(f"dx={dx} < 1") + + # starts within image + if y >= Ymax: + errs.append(f"y start {y} out of bounds [0, {Ymax - 1}]") + if x >= Xmax: + errs.append(f"x start {x} out of bounds [0, {Xmax - 1}]") + + # ends within image + end_y = y + dy + end_x = x + dx + if end_y > Ymax: + errs.append(f"y+dy = {end_y} exceeds height {Ymax}") + if end_x > Xmax: + errs.append(f"x+dx = {end_x} exceeds width {Xmax}") + + if errs: + raise ValueError("Invalid ROI:\n - " + "\n - ".join(errs)) + + # TEMP- COPY OF SPECTRUM CALCULATION FROM SHOW_MEAN_SPECTRUM--------------------------- + + # SPECTRUM CALCULATION -------------------------------------------------------------- + + dE = float(self.sampling[0]) + E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 + E = E0 + dE * np.arange(self.shape[0]) + + # MASK HANDLING --------------------------------------------------------------------- + if mask is not None: + # Convert to ndarray and validate + mask = np.asarray(mask) + + # Check that it's a proper ndarray + if not isinstance(mask, np.ndarray): + raise TypeError(f"Mask must be a numpy ndarray, got {type(mask)}") + + # Check dimensions - must be 1D + if mask.ndim != 1: + raise ValueError( + f"Mask must be 1-dimensional, got {mask.ndim}D array with shape {mask.shape}" + ) + + # Convert to bool dtype and validate + if mask.dtype != bool: + try: + mask = mask.astype(bool) + except (ValueError, TypeError): + raise TypeError(f"Mask cannot be converted to boolean dtype from {mask.dtype}") + + # Check shape matches energy axis + arr = np.asarray(self.array, dtype=float) + if mask.shape != (arr.shape[0],): + raise ValueError( + f"Mask shape {mask.shape} does not match energy axis shape ({arr.shape[0]},)" + ) + + arr = arr[mask, :, :] # select only masked energy channels + spec = arr.sum(axis=(1, 2)) if arr.shape[0] > 0 else np.zeros(0) + E = E[mask] # Mask the energy axis as well + else: + spec = np.empty(self.shape[0], dtype=float) + for k in range(self.shape[0]): + img = np.asarray(self.array[k], dtype=float) + roi = img[y : y + dy, x : x + dx] + if roi.size == 0: + raise ValueError("ROI is empty; check y/x/dy/dx.") + spec[k] = roi.mean() + + # Store ignore_range for later use in element line filtering + if ignore_range is None: + ignore_range = [0, 0.25] # Default: ignore 0-0.25 keV for element lines only + + # ----------------------------------------------------------------------------------- + + # POWER LAW BACKGROUND SUBTRACTION + + # TEMP- PORT OF SUBTRACT_BACKGROUND_EDS BODY + + """ Subtract power-law background typical for EDS Bremsstrahlung. Uses a conservative approach with heavy smoothing to avoid creating artifacts. @@ -195,49 +305,47 @@ def calculate_background_powerlaw(self, roi=None, energy_range=None, ignore_rang ndarray Background-subtracted spectrum """ - from scipy.ndimage import gaussian_filter - - # Use a larger window for more conservative background estimation - window_size = 15 # Larger window = smoother, less aggressive - background = np.zeros_like(spec) - half_window = window_size // 2 - - # Estimate background from sliding minimum - for i in range(len(spec)): - start = max(0, i - half_window) - end = min(len(spec), i + half_window + 1) - # Use percentile instead of minimum for more robustness - background[i] = np.percentile(spec[start:end], 10) - - # Apply heavy smoothing to avoid creating artificial features - background = gaussian_filter(background, sigma=5.0) - - # Be very conservative - only subtract 80% of estimated background - # This prevents over-subtraction that creates artificial peaks - background = background * 0.8 - - # Ensure background doesn't exceed spectrum - background = np.minimum(background, spec* 0.9) + from scipy.ndimage import gaussian_filter + + # Use a larger window for more conservative background estimation + window_size = 15 # Larger window = smoother, less aggressive + background = np.zeros_like(spec) + half_window = window_size // 2 + + # Estimate background from sliding minimum + for i in range(len(spec)): + start = max(0, i - half_window) + end = min(len(spec), i + half_window + 1) + # Use percentile instead of minimum for more robustness + background[i] = np.percentile(spec[start:end], 10) + + # Apply heavy smoothing to avoid creating artificial features + background = gaussian_filter(background, sigma=5.0) + + # Be very conservative - only subtract 80% of estimated background + # This prevents over-subtraction that creates artificial peaks + background = background * 0.8 + + # Ensure background doesn't exceed spectrum + background = np.minimum(background, spec * 0.9) + + subtracted_mean_spectrum = np.maximum(spec - background, 0) + + # TEMP- PORT OF SPECTRUM PLOTTING CODE FROM SHOW_MEAN_SPECTRUM - subtracted_mean_spectrum = np.maximum(spec - background, 0) - - #TEMP- PORT OF SPECTRUM PLOTTING CODE FROM SHOW_MEAN_SPECTRUM - # PLOT MEAN BACKGROUND-SUBTRACTED SPECTRUM --------------------------------------------------------------------------- - - # Create subplot layout: image on left, spectrum on right - fig, (ax_spec) = plt.subplots(1, 1, figsize=(12, 4)) - - - # RIGHT PLOT: Show spectrum - ax_spec.plot(E, subtracted_mean_spectrum, linewidth=1.5) - ax_spec.set_xlabel("Energy (keV)") - ax_spec.set_ylabel("Intensity") - ax_spec.set_title(f"Spectrum from ROI [{y}:{y+dy}, {x}:{x+dx}]") - ax_spec.grid(True, alpha=0.1) - - fig.tight_layout() - plt.show() + # Create subplot layout: image on left, spectrum on right + fig, (ax_spec) = plt.subplots(1, 1, figsize=(12, 4)) + + # RIGHT PLOT: Show spectrum + ax_spec.plot(E, subtracted_mean_spectrum, linewidth=1.5) + ax_spec.set_xlabel("Energy (keV)") + ax_spec.set_ylabel("Intensity") + ax_spec.set_title(f"Spectrum from ROI [{y}:{y + dy}, {x}:{x + dx}]") + ax_spec.grid(True, alpha=0.1) + + fig.tight_layout() + plt.show() - return background \ No newline at end of file + return background From ddb2e2f08dbe7b58ae889c3a94cfed8cf1f0c141 Mon Sep 17 00:00:00 2001 From: smribet Date: Tue, 20 Jan 2026 18:16:54 -1000 Subject: [PATCH 021/136] models --- .../spectroscopy/spectroscopy_models.py | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/quantem/spectroscopy/spectroscopy_models.py diff --git a/src/quantem/spectroscopy/spectroscopy_models.py b/src/quantem/spectroscopy/spectroscopy_models.py new file mode 100644 index 00000000..02eab850 --- /dev/null +++ b/src/quantem/spectroscopy/spectroscopy_models.py @@ -0,0 +1,149 @@ +import json +from pathlib import Path + +import numpy as np +import torch +import torch.nn as nn + + +class EDSModel(nn.Module): + """Complete EDS forward model with optional fit range""" + + def __init__(self, peak_model, background_model=None, fit_range=None, energy_axis=None): + super().__init__() + self.peak_model = peak_model + self.background_model = background_model + + def forward(self): + spectrum = self.peak_model() + if self.background_model is not None: + spectrum = spectrum + self.background_model() + return spectrum + + +class GaussianPeaks(nn.Module): + """Generate Gaussian peaks from peak library""" + + def __init__(self, energy_axis, elements_to_fit=None): + super().__init__() + + current_dir = Path(__file__).parent + with open(current_dir / "xray_lines.json", "r") as f: + data = json.load(f) + + self.energy_axis = torch.tensor(energy_axis, dtype=torch.float32) + self.energy_min = self.energy_axis.min().item() + self.energy_max = self.energy_axis.max().item() + + # Calculate energy step for later use + self.energy_step = (self.energy_axis[1] - self.energy_axis[0]).item() + + # Parse and filter elements + all_element_data = {} + for elem, lines in data["elements"].items(): + if len(lines) > 0: + energies = [] + weights = [] + + for line_name, line_data in lines.items(): + energy = line_data["energy (keV)"] + if self.energy_min - 0.5 <= energy <= self.energy_max + 0.5: + energies.append(energy) + weights.append(line_data["weight"]) + + if len(energies) > 0: + all_element_data[elem] = {"energies": energies, "weights": weights} + + # Filter to specific elements + if elements_to_fit is not None: + self.element_data = {} + for elem in elements_to_fit: + if elem in all_element_data: + self.element_data[elem] = all_element_data[elem] + else: + self.element_data = all_element_data + + self.element_names = list(self.element_data.keys()) + n_elements = len(self.element_names) + + # Pre-compute all peak positions and weights as tensors + all_peak_energies = [] + all_peak_weights = [] + all_peak_element_indices = [] + + for elem_idx, elem in enumerate(self.element_names): + energies = self.element_data[elem]["energies"] + weights = self.element_data[elem]["weights"] + + all_peak_energies.extend(energies) + all_peak_weights.extend(weights) + all_peak_element_indices.extend([elem_idx] * len(energies)) + + # Store as tensors for fast computation + self.peak_energies = torch.tensor(all_peak_energies, dtype=torch.float32) + self.peak_weights = torch.tensor(all_peak_weights, dtype=torch.float32) + self.peak_element_indices = torch.tensor(all_peak_element_indices, dtype=torch.long) + self.n_peaks = len(all_peak_energies) + + print(f"Fitting {n_elements} elements with {self.n_peaks} total peaks") + + # Learnable parameters + self.concentrations = nn.Parameter((torch.ones(n_elements))) + self.peak_width = nn.Parameter(torch.tensor(0.13)) + + def forward(self): + """Vectorized forward pass""" + centers = self.peak_energies.unsqueeze(1) + energies = self.energy_axis.unsqueeze(0) + + sigma = self.peak_width / 2.355 + + all_peaks = torch.exp(-0.5 * ((energies - centers) / sigma) ** 2) + + all_peaks = all_peaks * self.energy_step / (torch.sqrt(torch.tensor(2 * np.pi)) * sigma) + + peak_concentrations = nn.functional.softplus( + self.concentrations[self.peak_element_indices] + ) + weighted_peaks = all_peaks * (peak_concentrations * self.peak_weights).unsqueeze(1) + + spectrum = weighted_peaks.sum(dim=0) + + return spectrum + + +class PolynomialBackground(nn.Module): + """Polynomial background model""" + + def __init__(self, energy_axis, degree=3): + super().__init__() + self.energy_axis = torch.tensor(energy_axis, dtype=torch.float32) + self.degree = degree + + # Normalize energy axis to [0, 1] for numerical stability + self.energy_norm = (self.energy_axis - self.energy_axis.min()) / ( + self.energy_axis.max() - self.energy_axis.min() + ) + + self.coeffs = nn.Parameter(torch.randn(degree + 1) * 0.1) + + def forward(self): + background = torch.zeros_like(self.energy_axis) + for i, coeff in enumerate(self.coeffs): + background += coeff * (self.energy_norm**i) + return background + + +class ExponentialBackground(nn.Module): + """Exponential background for bremsstrahlung""" + + def __init__(self, energy_axis): + super().__init__() + self.energy_axis = torch.tensor(energy_axis, dtype=torch.float32) + + self.amplitude = nn.Parameter(torch.tensor(1.0)) + self.decay = nn.Parameter(torch.tensor(0.5)) + self.offset = nn.Parameter(torch.tensor(0.1)) + + def forward(self): + return self.amplitude * torch.exp(-self.decay * self.energy_axis) + self.offset From 96f975f441931e24790dc129ee919940a31b3170 Mon Sep 17 00:00:00 2001 From: smribet Date: Tue, 20 Jan 2026 18:19:59 -1000 Subject: [PATCH 022/136] better plotting --- src/quantem/spectroscopy/dataset3deds.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index b11a774f..b87d5898 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -136,6 +136,7 @@ def fit_spectrum_pytorch( ax[0].set_title("loss") ax[0].set_xlabel("iterations") ax[0].set_ylabel("loss") + ax[0].set_yscale("log") ax[1].plot(energy_axis, spectrum_raw.numpy(), "k-", label="Data", linewidth=1) ax[1].plot(energy_axis, final_pred, "r-", label="Fit", linewidth=2) From c7aa242db0a898c297c90aab7481ab0acffe53ba Mon Sep 17 00:00:00 2001 From: smribet Date: Wed, 21 Jan 2026 08:11:37 -0800 Subject: [PATCH 023/136] more flexible FWHM. let's see if this helps. --- src/quantem/spectroscopy/dataset3deds.py | 15 ++++++++-- .../spectroscopy/spectroscopy_models.py | 28 +++++++++++++++---- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index b87d5898..882afbc8 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -64,6 +64,7 @@ def fit_spectrum_pytorch( self, energy_range=None, elements_to_fit=None, + peak_width=0.1, num_iters=1000, lr=0.01, polynomial_background_degree=3, @@ -90,12 +91,11 @@ def fit_spectrum_pytorch( # initialize background = PolynomialBackground(energy_axis, degree=polynomial_background_degree) - peaks = GaussianPeaks(energy_axis, elements_to_fit=elements_to_fit) + peaks = GaussianPeaks(energy_axis, peak_width=peak_width, elements_to_fit=elements_to_fit) model = EDSModel(peaks, background, energy_axis=energy_axis) with torch.no_grad(): model.peak_model.concentrations.fill_((1)) - model.peak_model.peak_width.fill_(0.1) optimizer = torch.optim.Adam(model.parameters(), lr=lr) loss_fn = nn.MSELoss() @@ -119,7 +119,16 @@ def fit_spectrum_pytorch( final_pred = model().detach().numpy() * spectrum_max.numpy() + spectrum_min.numpy() concs = nn.functional.softplus(model.peak_model.concentrations).detach().numpy() - print(f"\nFinal: width={model.peak_model.peak_width.item():.3f} keV") + final_fwhm = ( + torch.nn.functional.softplus(model.peak_model.peak_width_by_peak) + .detach() + .cpu() + .numpy() + ) + print( + f"\nFinal: width median={np.median(final_fwhm):.3f} keV, " + f"min={final_fwhm.min():.3f}, max={final_fwhm.max():.3f}" + ) # Sort and show top N top_n = np.max((10, len(elements_to_fit))) diff --git a/src/quantem/spectroscopy/spectroscopy_models.py b/src/quantem/spectroscopy/spectroscopy_models.py index 02eab850..a6f31ed9 100644 --- a/src/quantem/spectroscopy/spectroscopy_models.py +++ b/src/quantem/spectroscopy/spectroscopy_models.py @@ -24,14 +24,18 @@ def forward(self): class GaussianPeaks(nn.Module): """Generate Gaussian peaks from peak library""" - def __init__(self, energy_axis, elements_to_fit=None): + def __init__(self, energy_axis, peak_width, elements_to_fit=None): super().__init__() current_dir = Path(__file__).parent with open(current_dir / "xray_lines.json", "r") as f: data = json.load(f) - self.energy_axis = torch.tensor(energy_axis, dtype=torch.float32) + self.energy_axis = ( + energy_axis.float() + if torch.is_tensor(energy_axis) + else torch.tensor(energy_axis, dtype=torch.float32) + ) self.energy_min = self.energy_axis.min().item() self.energy_max = self.energy_axis.max().item() @@ -84,19 +88,23 @@ def __init__(self, energy_axis, elements_to_fit=None): self.peak_weights = torch.tensor(all_peak_weights, dtype=torch.float32) self.peak_element_indices = torch.tensor(all_peak_element_indices, dtype=torch.long) self.n_peaks = len(all_peak_energies) + init_fwhm = torch.tensor(peak_width, dtype=torch.float32) + self.peak_width_by_peak = nn.Parameter( + torch.log(torch.expm1(init_fwhm)) * torch.ones(self.n_peaks) + ) print(f"Fitting {n_elements} elements with {self.n_peaks} total peaks") # Learnable parameters self.concentrations = nn.Parameter((torch.ones(n_elements))) - self.peak_width = nn.Parameter(torch.tensor(0.13)) def forward(self): """Vectorized forward pass""" centers = self.peak_energies.unsqueeze(1) energies = self.energy_axis.unsqueeze(0) - sigma = self.peak_width / 2.355 + fwhm = nn.functional.softplus(self.peak_width_by_peak) # (n_peaks,) + sigma = (fwhm / 2.355).unsqueeze(1) all_peaks = torch.exp(-0.5 * ((energies - centers) / sigma) ** 2) @@ -117,7 +125,11 @@ class PolynomialBackground(nn.Module): def __init__(self, energy_axis, degree=3): super().__init__() - self.energy_axis = torch.tensor(energy_axis, dtype=torch.float32) + self.energy_axis = ( + energy_axis.float() + if torch.is_tensor(energy_axis) + else torch.tensor(energy_axis, dtype=torch.float32) + ) self.degree = degree # Normalize energy axis to [0, 1] for numerical stability @@ -139,7 +151,11 @@ class ExponentialBackground(nn.Module): def __init__(self, energy_axis): super().__init__() - self.energy_axis = torch.tensor(energy_axis, dtype=torch.float32) + self.energy_axis = ( + energy_axis.float() + if torch.is_tensor(energy_axis) + else torch.tensor(energy_axis, dtype=torch.float32) + ) self.amplitude = nn.Parameter(torch.tensor(1.0)) self.decay = nn.Parameter(torch.tensor(0.5)) From e3b135aa82d6bda0942aab47f03faeeb41b74436 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Wed, 21 Jan 2026 18:15:15 -0800 Subject: [PATCH 024/136] add energy_range and cleanup background subtraction functions --- src/quantem/spectroscopy/dataset3deds.py | 166 +-- src/quantem/spectroscopy/dataset3deels.py | 29 +- .../spectroscopy/dataset3dspectroscopy.py | 1156 +++++++++++------ 3 files changed, 741 insertions(+), 610 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 882afbc8..c36d6fd2 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -167,140 +167,11 @@ def fit_spectrum_pytorch( plt.tight_layout() plt.show() - # Separate function for background subtraction to return a subtracted EDS spectra - - def calculate_background_powerlaw( - self, roi=None, energy_range=None, ignore_range=None, mask=None - ): - import matplotlib.pyplot as plt + def calculate_background_powerlaw(self, spectrum): import numpy as np - # TEMP- COPY OF ROI SELECTION CODE FROM SHOW_MEAN_SPECTRUM--------------------------- - - # ADJUST ROI BASED ON GIVEN FLAGS ----------------------------------------------- - # Parse ROI parameter - if roi is None: - # Full image - y, x, dy, dx = 0, 0, int(self.shape[1]), int(self.shape[2]) - elif len(roi) == 2: - # Single pixel [y, x] - y, x, dy, dx = int(roi[0]), int(roi[1]), 1, 1 - elif len(roi) == 4: - # Full ROI [y, x, dy, dx] with None support for defaults - y_val, x_val, dy_val, dx_val = roi - - # Handle None values with defaults - y = 0 if y_val is None else int(y_val) - x = 0 if x_val is None else int(x_val) - dy = int(self.shape[1]) - y if dy_val is None else int(dy_val) - dx = int(self.shape[2]) - x if dx_val is None else int(dx_val) - else: - raise ValueError( - "roi must be None, [y, x], or [y, x, dy, dx] (with None for defaults)" - ) - - # VALIDATE ROI BOUNDS --------------------------------------------------------------------------- - errs = [] - Ymax = int(self.shape[1]) - Xmax = int(self.shape[2]) - - # type/NaN checks (optional if you already cast to int above) - for name, val in (("y", y), ("x", x), ("dy", dy), ("dx", dx)): - if val is None: - errs.append(f"{name} is None (missing after normalization).") - - # if any None, bail early to avoid arithmetic errors - if errs: - raise ValueError("Invalid ROI:\n - " + "\n - ".join(errs)) - - # basic constraints - if y < 0: - errs.append(f"y={y} < 0") - if x < 0: - errs.append(f"x={x} < 0") - if dy < 1: - errs.append(f"dy={dy} < 1") - if dx < 1: - errs.append(f"dx={dx} < 1") - - # starts within image - if y >= Ymax: - errs.append(f"y start {y} out of bounds [0, {Ymax - 1}]") - if x >= Xmax: - errs.append(f"x start {x} out of bounds [0, {Xmax - 1}]") - - # ends within image - end_y = y + dy - end_x = x + dx - if end_y > Ymax: - errs.append(f"y+dy = {end_y} exceeds height {Ymax}") - if end_x > Xmax: - errs.append(f"x+dx = {end_x} exceeds width {Xmax}") - - if errs: - raise ValueError("Invalid ROI:\n - " + "\n - ".join(errs)) - - # TEMP- COPY OF SPECTRUM CALCULATION FROM SHOW_MEAN_SPECTRUM--------------------------- - - # SPECTRUM CALCULATION -------------------------------------------------------------- - - dE = float(self.sampling[0]) - E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 - E = E0 + dE * np.arange(self.shape[0]) - - # MASK HANDLING --------------------------------------------------------------------- - if mask is not None: - # Convert to ndarray and validate - mask = np.asarray(mask) - - # Check that it's a proper ndarray - if not isinstance(mask, np.ndarray): - raise TypeError(f"Mask must be a numpy ndarray, got {type(mask)}") - - # Check dimensions - must be 1D - if mask.ndim != 1: - raise ValueError( - f"Mask must be 1-dimensional, got {mask.ndim}D array with shape {mask.shape}" - ) - - # Convert to bool dtype and validate - if mask.dtype != bool: - try: - mask = mask.astype(bool) - except (ValueError, TypeError): - raise TypeError(f"Mask cannot be converted to boolean dtype from {mask.dtype}") - - # Check shape matches energy axis - arr = np.asarray(self.array, dtype=float) - if mask.shape != (arr.shape[0],): - raise ValueError( - f"Mask shape {mask.shape} does not match energy axis shape ({arr.shape[0]},)" - ) - - arr = arr[mask, :, :] # select only masked energy channels - spec = arr.sum(axis=(1, 2)) if arr.shape[0] > 0 else np.zeros(0) - E = E[mask] # Mask the energy axis as well - else: - spec = np.empty(self.shape[0], dtype=float) - for k in range(self.shape[0]): - img = np.asarray(self.array[k], dtype=float) - roi = img[y : y + dy, x : x + dx] - if roi.size == 0: - raise ValueError("ROI is empty; check y/x/dy/dx.") - spec[k] = roi.mean() - - # Store ignore_range for later use in element line filtering - if ignore_range is None: - ignore_range = [0, 0.25] # Default: ignore 0-0.25 keV for element lines only - - # ----------------------------------------------------------------------------------- - - # POWER LAW BACKGROUND SUBTRACTION - - # TEMP- PORT OF SUBTRACT_BACKGROUND_EDS BODY - """ - Subtract power-law background typical for EDS Bremsstrahlung. + From input spectrum, calculate power-law background typical for EDS Bremsstrahlung. Uses a conservative approach with heavy smoothing to avoid creating artifacts. Parameters @@ -313,21 +184,21 @@ def calculate_background_powerlaw( Returns ------- ndarray - Background-subtracted spectrum + 1D array representing the calculated background """ from scipy.ndimage import gaussian_filter # Use a larger window for more conservative background estimation window_size = 15 # Larger window = smoother, less aggressive - background = np.zeros_like(spec) + background = np.zeros_like(spectrum) half_window = window_size // 2 # Estimate background from sliding minimum - for i in range(len(spec)): + for i in range(len(spectrum)): start = max(0, i - half_window) - end = min(len(spec), i + half_window + 1) + end = min(len(spectrum), i + half_window + 1) # Use percentile instead of minimum for more robustness - background[i] = np.percentile(spec[start:end], 10) + background[i] = np.percentile(spectrum[start:end], 10) # Apply heavy smoothing to avoid creating artificial features background = gaussian_filter(background, sigma=5.0) @@ -337,25 +208,6 @@ def calculate_background_powerlaw( background = background * 0.8 # Ensure background doesn't exceed spectrum - background = np.minimum(background, spec * 0.9) - - subtracted_mean_spectrum = np.maximum(spec - background, 0) - - # TEMP- PORT OF SPECTRUM PLOTTING CODE FROM SHOW_MEAN_SPECTRUM - - # PLOT MEAN BACKGROUND-SUBTRACTED SPECTRUM --------------------------------------------------------------------------- - - # Create subplot layout: image on left, spectrum on right - fig, (ax_spec) = plt.subplots(1, 1, figsize=(12, 4)) - - # RIGHT PLOT: Show spectrum - ax_spec.plot(E, subtracted_mean_spectrum, linewidth=1.5) - ax_spec.set_xlabel("Energy (keV)") - ax_spec.set_ylabel("Intensity") - ax_spec.set_title(f"Spectrum from ROI [{y}:{y + dy}, {x}:{x + dx}]") - ax_spec.grid(True, alpha=0.1) - - fig.tight_layout() - plt.show() + background = np.minimum(background, spectrum * 0.9) - return background + return background \ No newline at end of file diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index 5288e1f1..825dff6f 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -55,49 +55,38 @@ def __init__( ) self._virtual_images = {} - - def calculate_background_iterative(self, roi=None, energy_range=None, ignore_range=None, mask=None): + def calculate_background_iterative(self, spectrum): """ Subtract background typical for EELS using iterative Gaussian fitting. This method isolates the continuum background from the low-loss region. - + WARNING: Only use with EELS data! Will remove peaks if used with EDS. - + Parameters ---------- spectrum : ndarray 1D EELS spectrum energy_axis : ndarray Energy axis corresponding to spectrum - + Returns ------- ndarray Background-subtracted spectrum """ - import numpy as np - import matplotlib as mpl - import matplotlib.pyplot as plt - from matplotlib.patches import Rectangle - from sklearn.decomposition import PCA - - from quantem.core.datastructures.dataset3d import Dataset3d - from quantem.core.utils.validators import ensure_valid_array - - - from scipy.stats import norm from scipy.ndimage import gaussian_filter - + from scipy.stats import norm + # Smooth for better fitting spec_smooth = gaussian_filter(spectrum, sigma=1.0) pixel_vals = spec_smooth.copy() - + # Iteratively fit Gaussian to low-intensity values (the continuum) # Remove outliers (edge peaks) iteratively num_iterations = 10 cutoff = 3 # +/- 3 sigma - + for _ in range(num_iterations): mu, std = norm.fit(pixel_vals) if std == 0: @@ -106,7 +95,7 @@ def calculate_background_iterative(self, roi=None, energy_range=None, ignore_ran lower = mu - cutoff * std upper = mu + cutoff * std pixel_vals = pixel_vals[(pixel_vals >= lower) & (pixel_vals <= upper)] - + # Subtract the estimated background level background_fit = mu diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index e3d4690c..0f0c86e6 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -1,42 +1,53 @@ -from typing import Any, Self, Optional, Tuple - -import os -import json import csv -from scipy.signal import find_peaks +import json +import os +from typing import Optional -import numpy as np -from numpy.typing import NDArray -import matplotlib as mpl import matplotlib.pyplot as plt +import numpy as np from matplotlib.patches import Rectangle +from numpy.typing import NDArray +from scipy.signal import find_peaks from sklearn.decomposition import PCA from quantem.core.datastructures.dataset3d import Dataset3d -from quantem.core.utils.validators import ensure_valid_array - class Dataset3dspectroscopy(Dataset3d): - # stores the element line info so you don't need to reload each time element_info = None # loads the xray lines dataset @classmethod - def load_element_info(cls, path='xray_lines.json'): + def load_element_info(cls, path="xray_lines.json"): if cls.element_info is not None: # don't reload if already loaded return base_dir = os.path.dirname(os.path.abspath(__file__)) full_path = os.path.join(base_dir, path) - with open(full_path, 'r') as f: - cls.element_info = json.load(f)['elements'] + with open(full_path, "r") as f: + cls.element_info = json.load(f)["elements"] - def __init__(self, array, name=None, origin=None, sampling=None, units=None, signal_units="arb. units", _token=None): + def __init__( + self, + array, + name=None, + origin=None, + sampling=None, + units=None, + signal_units="arb. units", + _token=None, + ): if ( - name is None and origin is None and sampling is None and units is None - and hasattr(array, "array") and hasattr(array, "name") and hasattr(array, "origin") and hasattr(array, "sampling") and hasattr(array, "units") + name is None + and origin is None + and sampling is None + and units is None + and hasattr(array, "array") + and hasattr(array, "name") + and hasattr(array, "origin") + and hasattr(array, "sampling") + and hasattr(array, "units") ): super().__init__( array=array.array, @@ -57,29 +68,28 @@ def __init__(self, array, name=None, origin=None, sampling=None, units=None, sig signal_units=signal_units, _token=type(self)._token if _token is None else _token, ) - + # Initialize model elements storage self.model_elements = None - def add_elements_to_model(self, elements): """ Add elements to the model for persistent use in show_mean_spectrum. - + Parameters ---------- elements : list or str - Element symbol(s) to add to the model. Can be a single string (e.g., 'Al') + Element symbol(s) to add to the model. Can be a single string (e.g., 'Al') or list of symbols (e.g., ['Au', 'Cu', 'Si']). """ # Load element info if not already loaded if type(self).element_info is None: type(self).load_element_info() - + # Convert to list if single string provided if isinstance(elements, str): elements = [elements] - + # Convert list of element symbols to dict using class element_info if isinstance(elements, list): all_info = type(self).element_info @@ -87,25 +97,25 @@ def add_elements_to_model(self, elements): # Initialize model_elements as dict if it doesn't exist if self.model_elements is None: self.model_elements = {} - + # Add new elements to existing model for el in elements: if el in all_info: self.model_elements[el] = all_info[el] - - def clear_model_elements(self): - """Clear all elements from the model.""" - self.model_elements = None + + def clear_model_elements(self): + """Clear all elements from the model.""" + self.model_elements = None ## PCA ANALYSIS METHODS - + def perform_pca( self, n_components: int = 10, standardize: bool = True, mask: Optional[NDArray] = None, plot_results: bool = True, - random_state: Optional[int] = 42 + random_state: Optional[int] = 42, ) -> dict: """ Perform Principal Component Analysis (PCA) on the spectroscopy dataset. @@ -173,17 +183,21 @@ def perform_pca( if plot_results: self._plot_pca_results( - components, loadings_spatial, pca.explained_variance_ratio_, - n_show=min(4, n_components) + components, + loadings_spatial, + pca.explained_variance_ratio_, + n_show=min(4, n_components), ) return { - 'pca': pca, - 'components': components, - 'loadings': loadings_spatial, - 'explained_variance_ratio': pca.explained_variance_ratio_, - 'explained_variance': pca.explained_variance_, - 'reconstructed': reconstructed.T.reshape(n_energy, ny, nx) if mask is None else reconstructed + "pca": pca, + "components": components, + "loadings": loadings_spatial, + "explained_variance_ratio": pca.explained_variance_ratio_, + "explained_variance": pca.explained_variance_, + "reconstructed": reconstructed.T.reshape(n_energy, ny, nx) + if mask is None + else reconstructed, } def _plot_pca_results( @@ -191,7 +205,7 @@ def _plot_pca_results( components: NDArray, loadings: NDArray, explained_variance_ratio: NDArray, - n_show: int = 4 + n_show: int = 4, ): """ Plot PCA results including scree plot, components, and loadings. @@ -214,13 +228,21 @@ def _plot_pca_results( ax_scree = fig.add_subplot(gs[0, 0]) cumsum_var = np.cumsum(explained_variance_ratio) - ax_scree.bar(range(1, len(explained_variance_ratio) + 1), - explained_variance_ratio * 100, alpha=0.6, label='Individual') - ax_scree.plot(range(1, len(explained_variance_ratio) + 1), - cumsum_var * 100, 'ro-', label='Cumulative') - ax_scree.set_xlabel('Component Number') - ax_scree.set_ylabel('Explained Variance (%)') - ax_scree.set_title('Scree Plot') + ax_scree.bar( + range(1, len(explained_variance_ratio) + 1), + explained_variance_ratio * 100, + alpha=0.6, + label="Individual", + ) + ax_scree.plot( + range(1, len(explained_variance_ratio) + 1), + cumsum_var * 100, + "ro-", + label="Cumulative", + ) + ax_scree.set_xlabel("Component Number") + ax_scree.set_ylabel("Explained Variance (%)") + ax_scree.set_title("Scree Plot") ax_scree.legend() ax_scree.grid(True, alpha=0.3) @@ -233,47 +255,55 @@ def _plot_pca_results( for i in range(n_show): ax_comp = fig.add_subplot(gs[1, i + 1]) ax_comp.plot(energy_axis, components[i]) - ax_comp.set_title(f'PC{i+1} ({explained_variance_ratio[i]*100:.1f}%)') - ax_comp.set_xlabel('Energy') + ax_comp.set_title(f"PC{i + 1} ({explained_variance_ratio[i] * 100:.1f}%)") + ax_comp.set_xlabel("Energy") if i == 0: - ax_comp.set_ylabel('Component') + ax_comp.set_ylabel("Component") ax_comp.grid(True, alpha=0.3) ax_load = fig.add_subplot(gs[2, i + 1]) - im = ax_load.imshow(loadings[i], cmap='RdBu_r', origin='lower') - ax_load.set_title(f'Loading {i+1}') - ax_load.axis('off') + im = ax_load.imshow(loadings[i], cmap="RdBu_r", origin="lower") + ax_load.set_title(f"Loading {i + 1}") + ax_load.axis("off") plt.colorbar(im, ax=ax_load, fraction=0.046, pad=0.04) ax_stats = fig.add_subplot(gs[1:, 0]) - ax_stats.axis('off') + ax_stats.axis("off") - stats_text = f"PCA Summary\n" + "="*20 + "\n\n" + stats_text = "PCA Summary\n" + "=" * 20 + "\n\n" stats_text += f"Total components: {len(explained_variance_ratio)}\n" stats_text += f"Components for 95% var: {np.argmax(cumsum_var >= 0.95) + 1}\n" stats_text += f"Components for 99% var: {np.argmax(cumsum_var >= 0.99) + 1}\n\n" for i in range(min(5, len(explained_variance_ratio))): - stats_text += f"PC{i+1}: {explained_variance_ratio[i]*100:.2f}%\n" - - ax_stats.text(0.1, 0.9, stats_text, transform=ax_stats.transAxes, - fontsize=10, verticalalignment='top', - fontfamily='monospace', - bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5)) + stats_text += f"PC{i + 1}: {explained_variance_ratio[i] * 100:.2f}%\n" + + ax_stats.text( + 0.1, + 0.9, + stats_text, + transform=ax_stats.transAxes, + fontsize=10, + verticalalignment="top", + fontfamily="monospace", + bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.5), + ) - plt.suptitle('PCA Analysis Results', fontsize=14, fontweight='bold') + plt.suptitle("PCA Analysis Results", fontsize=14, fontweight="bold") plt.tight_layout() plt.show() -# QUANTIFICATION ----------------------------------------------- + # QUANTIFICATION ----------------------------------------------- - def quantify_composition(self, roi=None, elements=None, k_factors=None, method='cliff_lorimer', mask=None): + def quantify_composition( + self, roi=None, elements=None, k_factors=None, method="cliff_lorimer", mask=None + ): """ Quantify elemental composition from EDS spectrum using Cliff-Lorimer approach. - + The Cliff-Lorimer equation relates atomic fractions to X-ray intensities: CA/CB = kAB * (IA/IB) - + Parameters ---------- roi : list or tuple, optional @@ -288,64 +318,66 @@ def quantify_composition(self, roi=None, elements=None, k_factors=None, method=' Quantification method. Currently supports 'cliff_lorimer'. mask : array, optional Boolean mask for energy channel selection. - + Returns ------- dict : Composition results containing: - 'atomic_percent': dict of element -> atomic % - - 'weight_percent': dict of element -> weight % + - 'weight_percent': dict of element -> weight % - 'intensities': dict of element -> integrated intensity - 'k_factors': dict of k-factors used - + Examples -------- # Basic quantification with theoretical k-factors comp = dataset.quantify_composition(elements=['Pt', 'Co']) - + # With experimental k-factors k_factors = {'Pt': 1.0, 'Co': 1.23} comp = dataset.quantify_composition(elements=['Pt', 'Co'], k_factors=k_factors) - + # Access results print(f"Pt: {comp['atomic_percent']['Pt']:.1f} at%") print(f"Co: {comp['atomic_percent']['Co']:.1f} at%") """ - + # Input validation if elements is None or len(elements) < 2: raise ValueError("At least 2 elements required for quantification") - + # Load element info if not available if type(self).element_info is None: type(self).load_element_info() - + # Extract spectrum from ROI spectrum_data = self._extract_spectrum_for_quantification(roi, mask) - spec = spectrum_data['spectrum'] - E = spectrum_data['energy'] - + spec = spectrum_data["spectrum"] + E = spectrum_data["energy"] + # Get X-ray line intensities for each element intensities = {} for element in elements: intensity = self._integrate_element_intensity(element, spec, E) intensities[element] = intensity - + # Handle k-factors - if k_factors is None: # if they arent provided, calculate from kfacs_Titan_300_keV.csv + if k_factors is None: # if they arent provided, calculate from kfacs_Titan_300_keV.csv k_factors = self._calculate_theoretical_k_factors(elements) else: # Validate k-factors if not all(elem in k_factors for elem in elements): raise ValueError("k_factors must include all elements") - + # Apply Cliff-Lorimer quantification - if method == 'cliff_lorimer': - results = self._cliff_lorimer_quantification(elements, intensities, k_factors, method, roi) + if method == "cliff_lorimer": + results = self._cliff_lorimer_quantification( + elements, intensities, k_factors, method, roi + ) else: raise ValueError(f"Unknown quantification method: {method}") - + return results - + def _extract_spectrum_for_quantification(self, roi, mask): """Extract spectrum data for quantification (similar to show_mean_spectrum).""" # Parse ROI (reuse logic from show_mean_spectrum) @@ -361,56 +393,62 @@ def _extract_spectrum_for_quantification(self, roi, mask): dx = int(self.shape[2]) - x if dx_val is None else int(dx_val) else: raise ValueError("roi must be None, [y, x], or [y, x, dy, dx]") - + # Energy axis dE = float(self.sampling[0]) E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 E = E0 + dE * np.arange(self.shape[0]) - + # Extract spectrum with mask handling if mask is not None: mask = np.asarray(mask, dtype=bool) if mask.shape != (self.shape[0],): - raise ValueError(f"Mask shape {mask.shape} doesn't match energy axis ({self.shape[0]},)") + raise ValueError( + f"Mask shape {mask.shape} doesn't match energy axis ({self.shape[0]},)" + ) arr = np.asarray(self.array, dtype=float)[mask, :, :] - spec = arr.sum(axis=(1,2)) if arr.shape[0] > 0 else np.zeros(0) + spec = arr.sum(axis=(1, 2)) if arr.shape[0] > 0 else np.zeros(0) E = E[mask] else: spec = np.empty(self.shape[0], dtype=float) for k in range(self.shape[0]): img = np.asarray(self.array[k], dtype=float) - roi_data = img[y:y+dy, x:x+dx] + roi_data = img[y : y + dy, x : x + dx] if roi_data.size == 0: raise ValueError("ROI is empty") spec[k] = roi_data.mean() - - return {'spectrum': spec, 'energy': E} - + + return {"spectrum": spec, "energy": E} + def _integrate_element_intensity(self, element, spectrum, energy): """Integrate X-ray intensity for a specific element using its characteristic lines.""" all_info = type(self).element_info if element not in all_info: raise ValueError(f"Element {element} not found in database") - + total_intensity = 0.0 element_lines = all_info[element] - + # Get the most intense lines (K-alpha, L-alpha, etc.) - weighted_lines = [(info['weight'], info['energy (keV)'], line_name) - for line_name, info in element_lines.items() - if info['energy (keV)'] <= 12.0] # Ignore high energy lines + weighted_lines = [ + (info["weight"], info["energy (keV)"], line_name) + for line_name, info in element_lines.items() + if info["energy (keV)"] <= 12.0 + ] # Ignore high energy lines weighted_lines.sort(reverse=True) # Sort by weight (highest first) - + # Use top 3 most intense lines for integration for weight, line_energy, line_name in weighted_lines[:3]: if weight > 0.1: # Only significant lines # Find integration window around the line # Use +/- 0.1 keV window or adaptive based on energy resolution window_width = max(0.1, line_energy * 0.01) # 1% of energy or 0.1 keV minimum - + # Find energy indices for integration - energy_mask = (energy >= line_energy - window_width) & (energy <= line_energy + window_width) - + energy_mask = (energy >= line_energy - window_width) & ( + energy <= line_energy + window_width + ) + if np.any(energy_mask): # Simple background subtraction: use linear interpolation at edges line_spectrum = spectrum[energy_mask] @@ -420,110 +458,110 @@ def _integrate_element_intensity(self, element, spectrum, energy): # Integrate above background, weighted by line intensity net_intensity = np.sum(line_spectrum - bg_level) * weight total_intensity += max(0, net_intensity) # No negative intensities - + return total_intensity - + def _calculate_theoretical_k_factors(self, elements): """Load k-factors from Titan 300 keV CSV file.""" # Get the path to the CSV file (same directory as this Python file) current_dir = os.path.dirname(os.path.abspath(__file__)) - csv_path = os.path.join(current_dir, 'kfacs_Titan_300_keV.csv') - + csv_path = os.path.join(current_dir, "kfacs_Titan_300_keV.csv") + # Load k-factors from CSV k_factor_data = {} try: - with open(csv_path, 'r') as f: + with open(csv_path, "r") as f: reader = csv.DictReader(f) for row in reader: - element = row['Element'] + element = row["Element"] k_factor_data[element] = { - 'K': float(row['K']), - 'L': float(row['L']), - 'M': float(row['M']) + "K": float(row["K"]), + "L": float(row["L"]), + "M": float(row["M"]), } except FileNotFoundError: print(f"Warning: K-factor CSV file not found at {csv_path}") print("Using simplified k-factors (all set to 1.0)") return {elem: 1.0 for elem in elements} - + # Get element info database to determine which X-ray line to use all_info = type(self).element_info - + k_factors = {} for element in elements: if element not in k_factor_data: print(f"Warning: Element {element} not found in k-factor database, using 1.0") k_factors[element] = 1.0 continue - + # Determine which X-ray line (K, L, or M) to use based on the element's primary lines if element in all_info: element_lines = all_info[element] - + # Check which X-ray series is most prominent for this element - has_k_lines = any('Ka' in line or 'Kb' in line for line in element_lines.keys()) - has_l_lines = any('La' in line or 'Lb' in line for line in element_lines.keys()) - has_m_lines = any('Ma' in line or 'Mb' in line for line in element_lines.keys()) - + has_k_lines = any("Ka" in line or "Kb" in line for line in element_lines.keys()) + has_l_lines = any("La" in line or "Lb" in line for line in element_lines.keys()) + has_m_lines = any("Ma" in line or "Mb" in line for line in element_lines.keys()) + # Prioritize K-lines, then L-lines, then M-lines - if has_k_lines and k_factor_data[element]['K'] > 0: - k_factors[element] = k_factor_data[element]['K'] - line_type = 'K' - elif has_l_lines and k_factor_data[element]['L'] > 0: - k_factors[element] = k_factor_data[element]['L'] - line_type = 'L' - elif has_m_lines and k_factor_data[element]['M'] > 0: - k_factors[element] = k_factor_data[element]['M'] - line_type = 'M' + if has_k_lines and k_factor_data[element]["K"] > 0: + k_factors[element] = k_factor_data[element]["K"] + line_type = "K" + elif has_l_lines and k_factor_data[element]["L"] > 0: + k_factors[element] = k_factor_data[element]["L"] + line_type = "L" + elif has_m_lines and k_factor_data[element]["M"] > 0: + k_factors[element] = k_factor_data[element]["M"] + line_type = "M" else: # Default to K-line k-factor if available - if k_factor_data[element]['K'] > 0: - k_factors[element] = k_factor_data[element]['K'] - line_type = 'K' - elif k_factor_data[element]['L'] > 0: - k_factors[element] = k_factor_data[element]['L'] - line_type = 'L' - elif k_factor_data[element]['M'] > 0: - k_factors[element] = k_factor_data[element]['M'] - line_type = 'M' + if k_factor_data[element]["K"] > 0: + k_factors[element] = k_factor_data[element]["K"] + line_type = "K" + elif k_factor_data[element]["L"] > 0: + k_factors[element] = k_factor_data[element]["L"] + line_type = "L" + elif k_factor_data[element]["M"] > 0: + k_factors[element] = k_factor_data[element]["M"] + line_type = "M" else: k_factors[element] = 1.0 - line_type = 'default' + line_type = "default" else: # Element not in database, use K-line if available - if k_factor_data[element]['K'] > 0: - k_factors[element] = k_factor_data[element]['K'] - line_type = 'K' - elif k_factor_data[element]['L'] > 0: - k_factors[element] = k_factor_data[element]['L'] - line_type = 'L' - elif k_factor_data[element]['M'] > 0: - k_factors[element] = k_factor_data[element]['M'] - line_type = 'M' + if k_factor_data[element]["K"] > 0: + k_factors[element] = k_factor_data[element]["K"] + line_type = "K" + elif k_factor_data[element]["L"] > 0: + k_factors[element] = k_factor_data[element]["L"] + line_type = "L" + elif k_factor_data[element]["M"] > 0: + k_factors[element] = k_factor_data[element]["M"] + line_type = "M" else: k_factors[element] = 1.0 - line_type = 'default' - + line_type = "default" + print(f"Using k-factors from Titan 300 keV database: {csv_path}") for elem in elements: print(f" {elem}: {k_factors[elem]:.3f}") - + return k_factors - + def _cliff_lorimer_quantification(self, elements, intensities, k_factors, method, roi): """Apply Cliff-Lorimer quantification method.""" # Cliff-Lorimer equation: CA/CB = kAB * (IA/IB) # For multiple elements: CA = kA * IA / SUM(ki * Ii) - + # Calculate weighted intensities weighted_sum = 0.0 weighted_intensities = {} - + for element in elements: weighted_intensity = k_factors[element] * intensities[element] weighted_intensities[element] = weighted_intensity weighted_sum += weighted_intensity - + # Calculate atomic percentages atomic_percent = {} for element in elements: @@ -531,138 +569,184 @@ def _cliff_lorimer_quantification(self, elements, intensities, k_factors, method atomic_percent[element] = (weighted_intensities[element] / weighted_sum) * 100.0 else: atomic_percent[element] = 0.0 - + # Calculate weight percentages (requires atomic weights) atomic_weights = { - 'C': 12.01, 'N': 14.01, 'O': 16.00, 'F': 19.00, 'Na': 22.99, 'Mg': 24.31, - 'Al': 26.98, 'Si': 28.09, 'P': 30.97, 'S': 32.07, 'Cl': 35.45, 'K': 39.10, - 'Ca': 40.08, 'Ti': 47.87, 'Cr': 52.00, 'Mn': 54.94, 'Fe': 55.85, 'Co': 58.93, - 'Ni': 58.69, 'Cu': 63.55, 'Zn': 65.38, 'Ag': 107.87, 'Pt': 195.08, 'Au': 196.97 + "C": 12.01, + "N": 14.01, + "O": 16.00, + "F": 19.00, + "Na": 22.99, + "Mg": 24.31, + "Al": 26.98, + "Si": 28.09, + "P": 30.97, + "S": 32.07, + "Cl": 35.45, + "K": 39.10, + "Ca": 40.08, + "Ti": 47.87, + "Cr": 52.00, + "Mn": 54.94, + "Fe": 55.85, + "Co": 58.93, + "Ni": 58.69, + "Cu": 63.55, + "Zn": 65.38, + "Ag": 107.87, + "Pt": 195.08, + "Au": 196.97, } - + # Convert atomic % to weight % weight_sum = 0.0 for element in elements: atomic_wt = atomic_weights.get(element, 55.85) # Default to Fe weight_sum += (atomic_percent[element] / 100.0) * atomic_wt - + weight_percent = {} for element in elements: if weight_sum > 0: atomic_wt = atomic_weights.get(element, 55.85) - weight_percent[element] = ((atomic_percent[element] / 100.0) * atomic_wt / weight_sum) * 100.0 + weight_percent[element] = ( + (atomic_percent[element] / 100.0) * atomic_wt / weight_sum + ) * 100.0 else: weight_percent[element] = 0.0 - + # Print summary in Cliff-Lorimer format - print(f"\n=== Quantification (Cliff-Lorimer) ===") + print("\n=== Quantification (Cliff-Lorimer) ===") print(f"ROI: {'Full image' if roi is None else roi}") print(f"Elements: {', '.join(elements)}") - - print(f"\nRaw Intensities:") + + print("\nRaw Intensities:") for elem in elements: print(f" {elem}: {intensities[elem]:.2f}") - - print(f"\nk-factors:") + + print("\nk-factors:") for elem in elements: print(f" {elem}: {k_factors[elem]:.2f}") - - print(f"\nAtomic %:") + + print("\nAtomic %:") for elem in elements: print(f" {elem}: {atomic_percent[elem]:.1f} at%") - - print(f"\nWeight %:") + + print("\nWeight %:") for elem in elements: print(f" {elem}: {weight_percent[elem]:.1f} wt%") - + return { - 'atomic_percent': atomic_percent, - 'weight_percent': weight_percent, - 'intensities': intensities, - 'k_factors': k_factors, - 'method': 'cliff_lorimer' + "atomic_percent": atomic_percent, + "weight_percent": weight_percent, + "intensities": intensities, + "k_factors": k_factors, + "method": "cliff_lorimer", } def _find_best_element_combinations(self, peak_energies, peak_intensities, tolerance=0.15): """ Find the best combination of elements that explains the detected peaks using a cost function. - + Parameters: peak_energies : array-like Detected peak positions in keV - peak_intensities : array-like + peak_intensities : array-like Detected peak intensities tolerance : float, default 0.15 Energy tolerance for peak matching in keV - + Returns: set : Set of element symbols that best explain the detected peaks """ from itertools import combinations - + # Get element database all_info = type(self).element_info if all_info is None: return set() - + # Consider combinations of 1-4 elements (reasonable for most samples) best_elements = set() - best_score = float('inf') - + best_score = float("inf") + # Get commonly analyzed elements (general EDS candidates) - general_elements = ['Fe', 'Pt', 'Cu', 'C', 'O', 'Ni', 'Co', 'Al', 'Si', 'Ti', 'Cr', 'Mn', 'Au', 'Ag', 'Zn', 'Ca', 'K', 'Na', 'Mg'] + general_elements = [ + "Fe", + "Pt", + "Cu", + "C", + "O", + "Ni", + "Co", + "Al", + "Si", + "Ti", + "Cr", + "Mn", + "Au", + "Ag", + "Zn", + "Ca", + "K", + "Na", + "Mg", + ] available_elements = [el for el in general_elements if el in all_info] - + # Test combinations of different sizes top_combinations = [] # Store combinations for analysis - for num_elements in range(1, min(5, len(available_elements)+1)): + for num_elements in range(1, min(5, len(available_elements) + 1)): for element_combo in combinations(available_elements, num_elements): score = self._calculate_element_combo_score( element_combo, peak_energies, peak_intensities, all_info, tolerance ) - + top_combinations.append((score, element_combo)) - + if score < best_score: best_score = score best_elements = set(element_combo) return best_elements - - def _calculate_element_combo_score(self, element_combo, peak_energies, peak_intensities, all_info, tolerance): + + def _calculate_element_combo_score( + self, element_combo, peak_energies, peak_intensities, all_info, tolerance + ): """ Calculate a cost function score for a given combination of elements. Lower scores are better. - + Strategy: Prioritize explaining ALL major peaks with the FEWEST elements. Only accept combinations that explain most peaks with high-weight lines. """ score = 0.0 explained_peaks = {} # peak_idx -> (matched_distance, line_weight, element) - + # For each detected peak, find the BEST match in the element combination for i, (peak_energy, peak_intensity) in enumerate(zip(peak_energies, peak_intensities)): - best_match_distance = float('inf') + best_match_distance = float("inf") best_line_weight = 0.0 best_element = None found_match = False - + # Check all elements in the combination for element in element_combo: if element in all_info: for line_name, line_info in all_info[element].items(): - line_energy = line_info['energy (keV)'] - line_weight = line_info.get('weight', 0.5) + line_energy = line_info["energy (keV)"] + line_weight = line_info.get("weight", 0.5) distance = abs(peak_energy - line_energy) - + # Only consider lines with significant weight (major lines only) if line_weight > 0.2 and distance <= tolerance: # Update best match if this line is better - if distance < best_match_distance or (distance == best_match_distance and line_weight > best_line_weight): + if distance < best_match_distance or ( + distance == best_match_distance and line_weight > best_line_weight + ): best_match_distance = distance best_line_weight = line_weight best_element = element found_match = True - + if found_match: explained_peaks[i] = (best_match_distance, best_line_weight, best_element) # Penalty for distance (prefer closer matches) @@ -672,15 +756,15 @@ def _calculate_element_combo_score(self, element_combo, peak_energies, peak_inte else: # HEAVY penalty for unexplained peaks - this is the key constraint score += 50.0 - + # Primary objective: explain ALL detected peaks unexplained_peaks = len(peak_energies) - len(explained_peaks) if unexplained_peaks > 0: score += unexplained_peaks * 100.0 # Very high penalty for unexplained peaks - + # Secondary objective: prefer simpler explanations (fewer elements) score += len(element_combo) * 5.0 - + # Tertiary objective: prefer explanations with multiple peaks per element # This avoids one-off false matches and encourages coherent solutions peaks_per_element = {} @@ -688,17 +772,160 @@ def _calculate_element_combo_score(self, element_combo, peak_energies, peak_inte if elem not in peaks_per_element: peaks_per_element[elem] = [] peaks_per_element[elem].append((dist, weight)) - + # Bonus if each element explains multiple peaks (coherence - more likely correct) for elem, matches in peaks_per_element.items(): if len(matches) > 1: # Elements with 2+ peak matches are much more likely correct score -= len(matches) * 2.0 - + return score - - def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_range=None, threshold=5.0, tolerance=0.15, mask=None, show_lines=True, show_text=True, snr_min=None, snr_threshold=None, distance_threshold_for_sample=0.05, contamination_elements=None, grid_peaks=None, background_subtraction='none', data_type='eds',peaks=15): + def calculate_mean_spectrum( + self, + roi=None, + energy_range=None, + ignore_range=None, + mask=None, + ): + # ADJUST ROI BASED ON GIVEN FLAGS ----------------------------------------------- + # Parse ROI parameter + if roi is None: + # Full image + y, x, dy, dx = 0, 0, int(self.shape[1]), int(self.shape[2]) + elif len(roi) == 2: + # Single pixel [y, x] + y, x, dy, dx = int(roi[0]), int(roi[1]), 1, 1 + elif len(roi) == 4: + # Full ROI [y, x, dy, dx] with None support for defaults + y_val, x_val, dy_val, dx_val = roi + + # Handle None values with defaults + y = 0 if y_val is None else int(y_val) + x = 0 if x_val is None else int(x_val) + dy = int(self.shape[1]) - y if dy_val is None else int(dy_val) + dx = int(self.shape[2]) - x if dx_val is None else int(dx_val) + else: + raise ValueError( + "roi must be None, [y, x], or [y, x, dy, dx] (with None for defaults)" + ) + + # VALIDATE ROI BOUNDS --------------------------------------------------------------------------- + errs = [] + Ymax = int(self.shape[1]) + Xmax = int(self.shape[2]) + + # type/NaN checks (optional if you already cast to int above) + for name, val in (("y", y), ("x", x), ("dy", dy), ("dx", dx)): + if val is None: + errs.append(f"{name} is None (missing after normalization).") + + # if any None, bail early to avoid arithmetic errors + if errs: + raise ValueError("Invalid ROI:\n - " + "\n - ".join(errs)) + + # basic constraints + if y < 0: + errs.append(f"y={y} < 0") + if x < 0: + errs.append(f"x={x} < 0") + if dy < 1: + errs.append(f"dy={dy} < 1") + if dx < 1: + errs.append(f"dx={dx} < 1") + + # starts within image + if y >= Ymax: + errs.append(f"y start {y} out of bounds [0, {Ymax - 1}]") + if x >= Xmax: + errs.append(f"x start {x} out of bounds [0, {Xmax - 1}]") + + # ends within image + end_y = y + dy + end_x = x + dx + if end_y > Ymax: + errs.append(f"y+dy = {end_y} exceeds height {Ymax}") + if end_x > Xmax: + errs.append(f"x+dx = {end_x} exceeds width {Xmax}") + + if errs: + raise ValueError("Invalid ROI:\n - " + "\n - ".join(errs)) + + # SPECTRUM CALCULATION -------------------------------------------------------------- + + dE = float(self.sampling[0]) + E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 + E = E0 + dE * np.arange(self.shape[0]) + + # MASK HANDLING --------------------------------------------------------------------- + if mask is not None: + # Convert to ndarray and validate + mask = np.asarray(mask) + + # Check that it's a proper ndarray + if not isinstance(mask, np.ndarray): + raise TypeError(f"Mask must be a numpy ndarray, got {type(mask)}") + + # Check dimensions - must be 1D + if mask.ndim != 1: + raise ValueError( + f"Mask must be 1-dimensional, got {mask.ndim}D array with shape {mask.shape}" + ) + + # Convert to bool dtype and validate + if mask.dtype != bool: + try: + mask = mask.astype(bool) + except (ValueError, TypeError): + raise TypeError(f"Mask cannot be converted to boolean dtype from {mask.dtype}") + + # Check shape matches energy axis + arr = np.asarray(self.array, dtype=float) + if mask.shape != (arr.shape[0],): + raise ValueError( + f"Mask shape {mask.shape} does not match energy axis shape ({arr.shape[0]},)" + ) + + arr = arr[mask, :, :] # select only masked energy channels + spec = arr.sum(axis=(1, 2)) if arr.shape[0] > 0 else np.zeros(0) + E = E[mask] # Mask the energy axis as well + else: + spec = np.empty(self.shape[0], dtype=float) + for k in range(self.shape[0]): + img = np.asarray(self.array[k], dtype=float) + roi = img[y : y + dy, x : x + dx] + if roi.size == 0: + raise ValueError("ROI is empty; check y/x/dy/dx.") + spec[k] = roi.mean() + + # APPLY ENERGY RANGE --------------------------------------------------------------- + + if energy_range is not None: + indices = np.where((E >= energy_range[0]) & (E <= energy_range[1]))[0] + spec = spec[indices] + E = E[indices] + + return spec + + def show_mean_spectrum( + self, + roi=None, + energy_range=None, + elements=None, + ignore_range=None, + threshold=5.0, + tolerance=0.15, + mask=None, + show_lines=True, + show_text=True, + snr_min=None, + snr_threshold=None, + distance_threshold_for_sample=0.05, + contamination_elements=None, + grid_peaks=None, + data_type="eds", + peaks=15, + ): """ Make and show a spectrum plot from a spatial ROI in a 3D EDS cube (E, Y, X). @@ -710,7 +937,7 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ - dy, dx: height and width of ROI Use None for default values: - [y, None, dy, None] = row y with height dy, full width - - [None, x, None, dx] = column x with width dx, full height + - [None, x, None, dx] = column x with width dx, full height - [y, x, None, None] = from (y,x) to bottom-right corner If roi=None, uses full image. Can also be [y, x] for single pixel. energy_range : list or tuple, optional @@ -719,7 +946,7 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ Element symbols to plot as X-ray lines (e.g., ['Fe', 'Pt']). If None, automatically detects elements from statistically significant peaks. ignore_range : list or tuple, optional - Energy range to ignore during peak detection as [min_energy, max_energy] in keV. + Energy range to ignore during peak detection as [min_energy, max_energy] in keV. E.g., [0, 2.5] ignores 0-2.5 keV during auto-detection. threshold : float, optional Statistical significance threshold (multiple of background noise). Default: 5.0 @@ -728,7 +955,7 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ mask : array, optional Boolean mask for pixel selection. show_lines : bool, optional - Whether to show element lines and/or auto-detected peaks. + Whether to show element lines and/or auto-detected peaks. Auto-enabled if elements are specified or auto-detection is used. show_text : bool, optional Whether to show text labels for detected elements. Default: True. @@ -771,18 +998,32 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ (fig, ax) : tuple The Matplotlib Figure and Axes of the spectrum plot. """ - + # Set defaults for detection parameters if contamination_elements is None: - contamination_elements = {'C', 'N', 'O', 'Cu', 'Si', 'K', 'Kr', 'Po', 'Pb', 'Os', 'Ir', 'At', 'Do', 'Po'} + contamination_elements = { + "C", + "N", + "O", + "Cu", + "Si", + "K", + "Kr", + "Po", + "Pb", + "Os", + "Ir", + "At", + "Do", + "Po", + } else: contamination_elements = set(contamination_elements) - + if grid_peaks is None: - grid_peaks = {'C': 0.260, 'Cu': 8.020} - + grid_peaks = {"C": 0.260, "Cu": 8.020} - # ADJUST ROI BASED ON GIVEN FLAGS ----------------------------------------------- + # ADJUST ROI BASED ON GIVEN FLAGS ----------------------------------------------- # Parse ROI parameter if roi is None: # Full image @@ -793,121 +1034,38 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ elif len(roi) == 4: # Full ROI [y, x, dy, dx] with None support for defaults y_val, x_val, dy_val, dx_val = roi - + # Handle None values with defaults y = 0 if y_val is None else int(y_val) x = 0 if x_val is None else int(x_val) dy = int(self.shape[1]) - y if dy_val is None else int(dy_val) dx = int(self.shape[2]) - x if dx_val is None else int(dx_val) else: - raise ValueError("roi must be None, [y, x], or [y, x, dy, dx] (with None for defaults)") - - - # VALIDATE ROI BOUNDS --------------------------------------------------------------------------- - errs = [] - Ymax = int(self.shape[1]) - Xmax = int(self.shape[2]) - - # type/NaN checks (optional if you already cast to int above) - for name, val in (("y", y), ("x", x), ("dy", dy), ("dx", dx)): - if val is None: - errs.append(f"{name} is None (missing after normalization).") - - # if any None, bail early to avoid arithmetic errors - if errs: - raise ValueError("Invalid ROI:\n - " + "\n - ".join(errs)) - - # basic constraints - if y < 0: errs.append(f"y={y} < 0") - if x < 0: errs.append(f"x={x} < 0") - if dy < 1: errs.append(f"dy={dy} < 1") - if dx < 1: errs.append(f"dx={dx} < 1") - - # starts within image - if y >= Ymax: errs.append(f"y start {y} out of bounds [0, {Ymax-1}]") - if x >= Xmax: errs.append(f"x start {x} out of bounds [0, {Xmax-1}]") + raise ValueError( + "roi must be None, [y, x], or [y, x, dy, dx] (with None for defaults)" + ) - # ends within image - end_y = y + dy - end_x = x + dx - if end_y > Ymax: errs.append(f"y+dy = {end_y} exceeds height {Ymax}") - if end_x > Xmax: errs.append(f"x+dx = {end_x} exceeds width {Xmax}") + # CALCULATE MEAN SPECTRUM FOR GIVEN ROI AND ENERGY RANGE -------------------------- - if errs: - raise ValueError("Invalid ROI:\n - " + "\n - ".join(errs)) - - - # SPECTRUM CALCULATION -------------------------------------------------------------- + spec = self.calculate_mean_spectrum(roi, energy_range, ignore_range, mask) dE = float(self.sampling[0]) E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 - E = E0 + dE * np.arange(self.shape[0]) + E = E0 + dE * np.arange(self.shape[0]) - # MASK HANDLING --------------------------------------------------------------------- - if mask is not None: - # Convert to ndarray and validate - mask = np.asarray(mask) - - # Check that it's a proper ndarray - if not isinstance(mask, np.ndarray): - raise TypeError(f"Mask must be a numpy ndarray, got {type(mask)}") - - # Check dimensions - must be 1D - if mask.ndim != 1: - raise ValueError(f"Mask must be 1-dimensional, got {mask.ndim}D array with shape {mask.shape}") - - # Convert to bool dtype and validate - if mask.dtype != bool: - try: - mask = mask.astype(bool) - except (ValueError, TypeError): - raise TypeError(f"Mask cannot be converted to boolean dtype from {mask.dtype}") - - # Check shape matches energy axis - arr = np.asarray(self.array, dtype=float) - if mask.shape != (arr.shape[0],): - raise ValueError(f"Mask shape {mask.shape} does not match energy axis shape ({arr.shape[0]},)") - - arr = arr[mask, :, :] # select only masked energy channels - spec = arr.sum(axis=(1,2)) if arr.shape[0] > 0 else np.zeros(0) - E = E[mask] # Mask the energy axis as well - else: - spec = np.empty(self.shape[0], dtype=float) - for k in range(self.shape[0]): - img = np.asarray(self.array[k], dtype=float) - roi = img[y:y+dy, x:x+dx] - if roi.size == 0: - raise ValueError("ROI is empty; check y/x/dy/dx.") - spec[k] = roi.mean() + if energy_range is not None: + indices = np.where((E >= energy_range[0]) & (E <= energy_range[1]))[0] + E = E[indices] # Store ignore_range for later use in element line filtering if ignore_range is None: ignore_range = [0, 0.25] # Default: ignore 0-0.25 keV for element lines only - - # BACKGROUND SUBTRACTION ------------------------------------------------------------------- - # Apply appropriate background subtraction method - if background_subtraction == 'auto': - # Automatically select best method for the data type - if data_type.lower() == 'eels': - background_subtraction = 'iterative' - else: # Default to EDS - background_subtraction = 'powerlaw' - - if background_subtraction == 'powerlaw': - # EDS: Power-law Bremsstrahlung background - spec = self._subtract_background_eds(spec, E) - elif background_subtraction == 'iterative': - # EELS: Iterative Gaussian fitting for continuum - spec = self._subtract_background_eels(spec, E) - # else: 'none' - no subtraction - - - # PLOTTING --------------------------------------------------------------------------- - + + # PLOTTING --------------------------------------------------------------------------- + # Create subplot layout: image on left, spectrum on right fig, (ax_img, ax_spec) = plt.subplots(1, 2, figsize=(12, 4)) - # LEFT PLOT: Show sum image with ROI highlighted # Create sum image across all energy channels (or masked channels) if mask is not None: @@ -916,36 +1074,44 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ else: sum_img = np.asarray(self.array, dtype=float).sum(axis=0) title_suffix = "" - - im = ax_img.imshow(sum_img, cmap='viridis', origin='lower') + + im = ax_img.imshow(sum_img, cmap="viridis", origin="lower") ax_img.set_title(f"EDS Sum Image{title_suffix}") ax_img.set_xlabel("X (pixels)") ax_img.set_ylabel("Y (pixels)") - + # Highlight the ROI with a rectangle - rect = Rectangle((x-0.5, y-0.5), dx, dy, linewidth=2, edgecolor='red', facecolor='none', alpha=0.8) + rect = Rectangle( + (x - 0.5, y - 0.5), dx, dy, linewidth=2, edgecolor="red", facecolor="none", alpha=0.8 + ) ax_img.add_patch(rect) - + # Add colorbar for the image - plt.colorbar(im, ax=ax_img, label='Intensity') - + plt.colorbar(im, ax=ax_img, label="Intensity") + # RIGHT PLOT: Show spectrum ax_spec.plot(E, spec, linewidth=1.5) ax_spec.set_xlabel("Energy (keV)") ax_spec.set_ylabel("Intensity") - ax_spec.set_title(f"Spectrum from ROI [{y}:{y+dy}, {x}:{x+dx}]") + ax_spec.set_title(f"Spectrum from ROI [{y}:{y + dy}, {x}:{x + dx}]") ax_spec.grid(True, alpha=0.1) - + # Use ax_spec for all subsequent peak/line plotting ax = ax_spec # HANDLE SHOW_LINES FLAG AND MODEL ELEMENTS ------------------------------------ # Auto-enable show_lines if elements are specified or if auto-detection is needed if show_lines is None: - show_lines = (elements is not None) or (hasattr(self, 'model_elements') and self.model_elements is not None) - + show_lines = (elements is not None) or ( + hasattr(self, "model_elements") and self.model_elements is not None + ) + # Use model elements if no elements specified but model has elements - if elements is None and hasattr(self, 'model_elements') and self.model_elements is not None: + if ( + elements is None + and hasattr(self, "model_elements") + and self.model_elements is not None + ): elements = list(self.model_elements.keys()) # Skip all line plotting if show_lines is False @@ -960,20 +1126,20 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ try: # Step 1: Find all potential peaks peak_indices, peak_properties = find_peaks(spec, height=0, distance=5) - peak_heights = peak_properties['peak_heights'] - + peak_heights = peak_properties["peak_heights"] + # Step 2: Calculate background statistics # Use nanpercentile to handle any NaN values in the spectrum background_level = np.nanpercentile(spec, 25) background_std = np.nanstd(spec[spec <= np.nanpercentile(spec, 50)]) - + # Step 3: Determine dynamic SNR thresholds if not provided # Calculate initial SNR for all peaks to assess data characteristics initial_snrs = [] for peak_idx, height in zip(peak_indices, peak_heights): - snr = height / background_std if background_std > 0 else float('inf') + snr = height / background_std if background_std > 0 else float("inf") initial_snrs.append(snr) - + # Calculate statistics of SNR distribution if len(initial_snrs) > 0: snr_median = np.nanmedian(initial_snrs) @@ -985,7 +1151,7 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ snr_75th = 0 snr_95th = 0 num_high_snr_peaks = 0 - + # Set snr_min (detection threshold) if not provided if snr_min is None: # Use adaptive threshold based on SNR distribution @@ -996,7 +1162,7 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ min_snr = 20.0 # Sparse peaks -> standard threshold else: min_snr = snr_min - + # Set snr_threshold (sample element threshold) if not provided if snr_threshold is None: # Adaptive threshold based on peak density and SNR distribution @@ -1010,40 +1176,44 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ snr_threshold_for_sample = max(30.0, snr_75th * 0.8) else: # Default case snr_threshold_for_sample = 40.0 - - print(f"Auto-determined thresholds: snr_min={min_snr:.1f}, snr_threshold={snr_threshold_for_sample:.1f}") - print(f" (Based on: median_snr={snr_median:.1f}, 75th_percentile={snr_75th:.1f}, high_snr_peaks={num_high_snr_peaks})") + + print( + f"Auto-determined thresholds: snr_min={min_snr:.1f}, snr_threshold={snr_threshold_for_sample:.1f}" + ) + print( + f" (Based on: median_snr={snr_median:.1f}, 75th_percentile={snr_75th:.1f}, high_snr_peaks={num_high_snr_peaks})" + ) else: snr_threshold_for_sample = snr_threshold - + # Step 4: Filter peaks by SNR significant_peaks = [] for peak_idx, height in zip(peak_indices, peak_heights): peak_energy = E[peak_idx] - + # Skip peaks in ignore range if ignore_range is not None and len(ignore_range) == 2: min_ignore, max_ignore = ignore_range if min_ignore <= peak_energy <= max_ignore: continue - - snr = height / background_std if background_std > 0 else float('inf') - + + snr = height / background_std if background_std > 0 else float("inf") + # Keep peaks with good SNR if snr >= min_snr: significant_peaks.append((peak_idx, height, peak_energy, snr)) - + if len(significant_peaks) > 0: # Sort by SNR (signal-to-noise ratio) for most statistically significant peaks significant_peaks.sort(key=lambda x: x[3], reverse=True) - + # Limit to top N peaks for display display_peaks = significant_peaks[:peaks] - + # Match detected peaks to xray_lines.json all_info = type(self).element_info peak_matches = [] # List of (peak_idx, height, peak_energy, snr, element, match_string, distance) - + # If specific elements are requested, filter the database to only those if elements is not None and isinstance(elements, list): search_elements = set(elements) @@ -1051,53 +1221,77 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ else: search_elements = None search_mode = "for all elements" - - print(f"\nDetected {len(significant_peaks)} peaks (SNR >= {min_snr:.1f}) {search_mode}") + + print( + f"\nDetected {len(significant_peaks)} peaks (SNR >= {min_snr:.1f}) {search_mode}" + ) if len(significant_peaks) > peaks: print(f"Showing top {peaks} peaks by SNR (most statistically significant)") print(f"{'Energy (keV)':<12} {'Intensity':<12} {'SNR':<8} {'Best Match':<25}") - print("-"*60) - + print("-" * 60) + # For each detected peak, find the best match in the database for peak_idx, height, peak_energy, snr in display_peaks: best_match = None - best_distance = float('inf') + best_distance = float("inf") best_element = None - + # Search through elements in database if all_info: for elem, lines in all_info.items(): # If specific elements requested, only search those if search_elements is not None and elem not in search_elements: continue - + for line_name, line_info in lines.items(): - line_energy = line_info['energy (keV)'] - line_weight = line_info.get('weight', 0.5) + line_energy = line_info["energy (keV)"] + line_weight = line_info.get("weight", 0.5) distance = abs(peak_energy - line_energy) - + # Prioritize K and L lines over M lines for element identification # M-lines are very weak and prone to false positives at low energies - is_m_line = 'M' in line_name and not ('Ma' in line_name or 'Mb' in line_name) - + is_m_line = "M" in line_name and not ( + "Ma" in line_name or "Mb" in line_name + ) + # Match to characteristic lines within tolerance # Require weight > 0.3 (filters weakest M-lines) # Penalize M-line matches by requiring closer distance - effective_tolerance = tolerance * 0.5 if is_m_line else tolerance - - if line_weight > 0.3 and distance <= effective_tolerance and distance < best_distance: + effective_tolerance = ( + tolerance * 0.5 if is_m_line else tolerance + ) + + if ( + line_weight > 0.3 + and distance <= effective_tolerance + and distance < best_distance + ): best_distance = distance best_match = f"{elem} {line_name}" best_element = elem - + if best_match: - peak_matches.append((peak_idx, height, peak_energy, snr, best_element, best_match, best_distance)) - print(f"{peak_energy:<12.3f} {height:<12.1f} {snr:<8.1f} {best_match:<25}") + peak_matches.append( + ( + peak_idx, + height, + peak_energy, + snr, + best_element, + best_match, + best_distance, + ) + ) + print( + f"{peak_energy:<12.3f} {height:<12.1f} {snr:<8.1f} {best_match:<25}" + ) else: - print(f"{peak_energy:<12.3f} {height:<12.1f} {snr:<8.1f} {'Unknown':<25}") - - print("-"*60) - + print( + f"{peak_energy:<12.3f} {height:<12.1f} {snr:<8.1f} {'Unknown':<25}" + ) + + print("-" * 60) + # Detect elements: use only the strongest peaks that match VERY well # Strategy: keep only peaks that: # 1. Match a characteristic line within distance_threshold_for_sample (very tight tolerance) @@ -1105,45 +1299,64 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ # 3. Are from non-contamination elements (or requested elements if specified) detected_elements = set() detected_sample_peaks = {} # Map peak_energy -> is_sample_element for line styling - - for peak_idx, height, peak_energy, snr, element, match_str, distance in peak_matches: + + for ( + peak_idx, + height, + peak_energy, + snr, + element, + match_str, + distance, + ) in peak_matches: # Very strict criteria for element detection - if (snr > snr_threshold_for_sample and # Strong peak - distance < distance_threshold_for_sample): # Very close match to characteristic line - + if ( + snr > snr_threshold_for_sample # Strong peak + and distance < distance_threshold_for_sample + ): # Very close match to characteristic line # If specific elements requested, only keep those; otherwise exclude contamination if search_elements is not None: if element in search_elements: detected_elements.add(element) detected_sample_peaks[peak_energy] = True else: - if element not in contamination_elements: # Not a known contamination + if ( + element not in contamination_elements + ): # Not a known contamination detected_elements.add(element) detected_sample_peaks[peak_energy] = True - + # MULTI-PEAK COHERENCE CHECK: Filter out elements with only single weak matches # Count DISTINCT characteristic lines for each element (Ka vs Kb, La vs Lb, etc.) element_line_types = {} # element -> set of line types (e.g., 'Ka', 'Lb') element_total_snr = {} element_has_major_lines = {} # Track if element has K or L lines (not just M) - - for peak_idx, height, peak_energy, snr, element, match_str, distance in peak_matches: + + for ( + peak_idx, + height, + peak_energy, + snr, + element, + match_str, + distance, + ) in peak_matches: # Count ALL good matches for each element (not just sample-quality ones) if distance < tolerance * 2: # Within 2x tolerance for counting if element not in element_line_types: element_line_types[element] = set() element_total_snr[element] = 0 element_has_major_lines[element] = False - + # Extract line type from match_str (e.g., "Pt La" -> "La") line_type = match_str.split()[-1] if match_str else "" element_line_types[element].add(line_type) element_total_snr[element] += snr - + # Check if this is a major line (K or L series) - if any(x in line_type for x in ['Ka', 'Kb', 'La', 'Lb', 'Lg']): + if any(x in line_type for x in ["Ka", "Kb", "La", "Lb", "Lg"]): element_has_major_lines[element] = True - + # Filter detected_elements: keep only if multiple DISTINCT lines OR very high SNR # CRITICAL: Reject elements with only M-lines (no K or L confirmation) filtered_detected_elements = set() @@ -1152,96 +1365,134 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ total_snr = element_total_snr.get(element, 0) avg_snr = total_snr / distinct_line_count if distinct_line_count > 0 else 0 has_major_lines = element_has_major_lines.get(element, False) - + # Keep element if: # - Has K or L lines (not just M-lines) - required for heavy elements # - AND (has 2+ DISTINCT lines OR 1 line with very high SNR >70) if has_major_lines and (distinct_line_count >= 2 or avg_snr > 70): filtered_detected_elements.add(element) - + # Update detected_elements with filtered set detected_elements = filtered_detected_elements - + # Update detected_sample_peaks to only include filtered elements filtered_sample_peaks = {} for peak_energy, is_sample in detected_sample_peaks.items(): # Find which element this peak belongs to - for peak_idx, height, pe, snr, element, match_str, distance in peak_matches: + for ( + peak_idx, + height, + pe, + snr, + element, + match_str, + distance, + ) in peak_matches: if abs(pe - peak_energy) < 0.001 and element in detected_elements: filtered_sample_peaks[peak_energy] = is_sample break detected_sample_peaks = filtered_sample_peaks - + # Plot detected peaks with appropriate line style (limit to display_peaks) for peak_idx, height, peak_energy, snr in display_peaks: # Use solid line for sample elements, dotted for others is_sample = detected_sample_peaks.get(peak_energy, False) - linestyle = '-' if is_sample else ':' - - ax_spec.axvline(peak_energy, color='red', linestyle=linestyle, alpha=0.3, linewidth=1.5) - + linestyle = "-" if is_sample else ":" + + ax_spec.axvline( + peak_energy, color="red", linestyle=linestyle, alpha=0.3, linewidth=1.5 + ) + # Add labels for grid artifacts and sample elements (if show_text enabled) if show_text: y_pos = height * 0.7 # Position label at 70% of peak height - + # Check if this is a grid/contamination peak is_grid_peak = False for grid_elem, grid_energy in grid_peaks.items(): - if abs(peak_energy - grid_energy) < 0.1: # Within 0.1 keV of known grid peak - ax_spec.text(peak_energy, y_pos, f'{grid_elem}\n(grid)', - ha='center', va='bottom', fontsize=8, color='gray', style='italic') + if ( + abs(peak_energy - grid_energy) < 0.1 + ): # Within 0.1 keV of known grid peak + ax_spec.text( + peak_energy, + y_pos, + f"{grid_elem}\n(grid)", + ha="center", + va="bottom", + fontsize=8, + color="gray", + style="italic", + ) is_grid_peak = True break - + # If elements were detected, use them for element identification only (not for line plotting) if detected_elements: print(f"\nDetected elements: {', '.join(sorted(detected_elements))}") - + # Prepare labels with vertical orientation and offset handling # Group labels by energy proximity (within 0.3 keV) labels_to_plot = [] # List of (peak_energy, label_text, color, height) - colors_map = {'Fe': 'darkblue', 'Pt': 'darkred'} - - for peak_idx, height, peak_energy, snr, element, match_str, distance in peak_matches: + colors_map = {"Fe": "darkblue", "Pt": "darkred"} + + for ( + peak_idx, + height, + peak_energy, + snr, + element, + match_str, + distance, + ) in peak_matches: if element in detected_elements: # Extract line name from match_str (e.g., "Fe Ka" -> "Ka") line_name = match_str.split()[-1] if match_str else "" label_text = f"{element} {line_name}" if line_name else element - color = colors_map.get(element, 'black') + color = colors_map.get(element, "black") labels_to_plot.append((peak_energy, label_text, color, height)) - + # Sort by energy to group nearby peaks labels_to_plot.sort(key=lambda x: x[0]) - + # Offset overlapping labels vertically label_offset_map = {} # Map peak_energy -> vertical offset multiplier proximity_threshold = 1.5 # 1.5 keV - + for i, (energy, label, color, height) in enumerate(labels_to_plot): # Check if this label is close to previous labels offset_count = 0 for j in range(i): - prev_energy, prev_label, prev_color, prev_height = labels_to_plot[j] + prev_energy, prev_label, prev_color, prev_height = labels_to_plot[ + j + ] if abs(energy - prev_energy) < proximity_threshold: offset_count += 1 - + label_offset_map[energy] = offset_count - + # Plot labels with vertical text and offsets (if show_text enabled) if show_text: for peak_energy, label_text, color, height in labels_to_plot: # Position label above the peak y_pos = height * 1.2 - - ax_spec.text(peak_energy, y_pos, label_text, - ha='center', va='bottom', fontsize=10, color=color, - weight='bold', rotation=90) + + ax_spec.text( + peak_energy, + y_pos, + label_text, + ha="center", + va="bottom", + fontsize=10, + color=color, + weight="bold", + rotation=90, + ) else: print(f"\nNo peaks detected with SNR >= {min_snr:.1f}") - + except ImportError: print("scipy is required for peak detection. Please install scipy.") - + # Skip element lines plotting - only show detected peaks # (Element characteristic lines are not plotted when using auto-detection) @@ -1249,9 +1500,19 @@ def show_mean_spectrum(self, roi=None, energy_range=None, elements=None, ignore_ plt.show() return fig, (ax_img, ax_spec) -# BACKGROND SUBTRACTION + # BACKGROND SUBTRACTION + + def subtract_background( + self, roi=None, energy_range=None, ignore_range=None, mask=None, data_type="eds" + ): + """ + Perform appropriate background subtraction routine on mean spectrum from a 3D spectroscopy dataset. + + + returns: + A dataset3dspectroscopy object with background subtraction performed at all probe positions - def subtract_background(self, roi=None, energy_range=None, ignore_range=None, mask=None, data_type='eds'): + """ from quantem.spectroscopy import ( Dataset3deds as Dataset3deds, @@ -1260,38 +1521,67 @@ def subtract_background(self, roi=None, energy_range=None, ignore_range=None, ma Dataset3deels as Dataset3deels, ) - """ - Perform appropriate background subtraction routine on mean spectrum from a 3D spectroscopy dataset. - """ - - if data_type == 'eds': - background = self.calculate_background_powerlaw(roi, energy_range, ignore_range, mask) - elif data_type == 'eels': - background = self.calculate_background_iterative(roi, energy_range, ignore_range, mask) + spec = self.calculate_mean_spectrum(roi, energy_range, ignore_range, mask) + + if data_type == "eds": + background = self.calculate_background_powerlaw(spec) + elif data_type == "eels": + background = self.calculate_background_iterative(spec) + + subtracted_mean_spectrum = np.maximum(spec - background, 0) + + # PLOT MEAN BACKGROUND-SUBTRACTED SPECTRUM --------------------------------------------------------------------------- + + # TODO: store energy axis variable so it doesn't have to be reinitialized repeatedly. + # for now, this chunk is borrowed from calculate_mean_spectrum + ### + dE = float(self.sampling[0]) + E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 + E = E0 + dE * np.arange(self.shape[0]) + + if energy_range is not None: + indices = np.where((E >= energy_range[0]) & (E <= energy_range[1]))[0] + E = E[indices] + else: + indices = np.arange(self.shape[0]) + ### - spec3D_subtracted = np.empty(self.shape, dtype=float) + fig, (ax_specbacksub) = plt.subplots(1, 1, figsize=(12, 4)) + ax_specbacksub.plot(E, subtracted_mean_spectrum, linewidth=1.5) + ax_specbacksub.set_xlabel("Energy (keV)") + ax_specbacksub.set_ylabel("Intensity") + ax_specbacksub.set_title("Background-subtracted spectrum from ROI") + ax_specbacksub.grid(True, alpha=0.1) + + fig.tight_layout() + plt.show() + + # NOTE: currently, if an energy_range parameter is set, subtract_background considers ONLY + # the spectrum data within that energy range, and the output dataset3dspectroscopy object + # only includes data from that energy range embedded. Not sure that's the best way to implement this. + + spec3D_subtracted = np.empty([spec.shape[0], self.shape[1], self.shape[2]], dtype=float) for p in range(self.shape[1]): for q in range(self.shape[2]): - spec3D_subtracted[:,p,q] = np.maximum(self.array[:,p,q] - background, 0) + spec3D_subtracted[:, p, q] = np.maximum(self.array[indices, p, q] - background, 0) - - if data_type == 'eds': + if data_type == "eds": return Dataset3deds.from_array( - array = spec3D_subtracted, - sampling = self.sampling, - origin = self.origin, - units = self.units) + array=spec3D_subtracted, + sampling=self.sampling, + origin=self.origin, + units=self.units, + ) - elif data_type == 'eels': + elif data_type == "eels": return Dataset3deels.from_array( - array = spec3D_subtracted, - sampling = self.sampling, - origin = self.origin, - units = self.units) - - + array=spec3D_subtracted, + sampling=self.sampling, + origin=self.origin, + units=self.units, + ) Dataset3dspectroscopy.load_element_info() \ No newline at end of file From ca07d1dd6a95f64625d823aa53fa6e6d1a848efa Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Mon, 26 Jan 2026 12:22:15 -0800 Subject: [PATCH 025/136] fix transpose bug for digitalmicrograph EELS data in read_3d_spectroscopy --- src/quantem/core/io/file_readers.py | 56 +++++++++++++++++++---------- 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/src/quantem/core/io/file_readers.py b/src/quantem/core/io/file_readers.py index ee71a0b7..6c5c22d4 100644 --- a/src/quantem/core/io/file_readers.py +++ b/src/quantem/core/io/file_readers.py @@ -148,24 +148,44 @@ def read_3d_spectroscopy(file_path: str, file_type: str, data_type: str, dataset imported_axes = imported_data["axes"] # imported_data[0], if data_type == "EELS": - dataset = Dataset3deels.from_array( - array=imported_data["data"].transpose((2, 0, 1)), - sampling=[ - imported_data["axes"][2]["scale"], - imported_data["axes"][0]["scale"], - imported_data["axes"][1]["scale"], - ], - origin=[ - imported_data["axes"][2]["offset"], - imported_data["axes"][0]["offset"], - imported_data["axes"][1]["offset"], - ], - units=[ - imported_data["axes"][2]["units"], - imported_data["axes"][0]["units"], - imported_data["axes"][1]["units"], - ], - ) + if file_type == "digitalmicrograph": + dataset = Dataset3deels.from_array( + array=imported_data["data"], + sampling=[ + imported_data["axes"][0]["scale"], + imported_data["axes"][1]["scale"], + imported_data["axes"][2]["scale"], + ], + origin=[ + imported_data["axes"][0]["offset"], + imported_data["axes"][1]["offset"], + imported_data["axes"][2]["offset"], + ], + units=[ + imported_data["axes"][0]["units"], + imported_data["axes"][1]["units"], + imported_data["axes"][2]["units"], + ], + ) + else: + dataset = Dataset3deels.from_array( + array=imported_data["data"].transpose((2, 0, 1)), + sampling=[ + imported_data["axes"][2]["scale"], + imported_data["axes"][0]["scale"], + imported_data["axes"][1]["scale"], + ], + origin=[ + imported_data["axes"][2]["offset"], + imported_data["axes"][0]["offset"], + imported_data["axes"][1]["offset"], + ], + units=[ + imported_data["axes"][2]["units"], + imported_data["axes"][0]["units"], + imported_data["axes"][1]["units"], + ], + ) elif data_type == "EDS": dataset = Dataset3deds.from_array( array=imported_data["data"].transpose((2, 0, 1)), From 2c8b5ece064ec3a79fe2460da67078ae3da85a4a Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Mon, 26 Jan 2026 14:50:19 -0800 Subject: [PATCH 026/136] modify print message to inform where 3D datasets are located in case of multiple 3D datasets in a file --- src/quantem/core/io/file_readers.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/quantem/core/io/file_readers.py b/src/quantem/core/io/file_readers.py index 6c5c22d4..d2d1f14e 100644 --- a/src/quantem/core/io/file_readers.py +++ b/src/quantem/core/io/file_readers.py @@ -102,7 +102,7 @@ def read_4dstem( def read_3d_spectroscopy(file_path: str, file_type: str, data_type: str, dataset_index: int | None = None) -> Dataset3dspectroscopy: """ - File reader for 3D spectroscopy data data + File reader for 3D spectroscopy data Parameters ---------- @@ -139,10 +139,15 @@ def read_3d_spectroscopy(file_path: str, file_type: str, data_type: str, dataset raise ValueError("No 3D dataset found in file") dataset_index, imported_data = three_d_datasets[0] + + dataset_indices = [] + for entry in three_d_datasets: + dataset_indices.append(entry[0]) + if len(data_list) > 1: print( - f"File contains {len(data_list)} dataset(s). Using dataset {dataset_index} with shape {imported_data['data'].shape}" + f"File contains {len(data_list)} dataset(s) and {len(three_d_datasets)} 3D dataset(s) at indices {', '.join(map(str, dataset_indices))}. Using dataset {dataset_index} with shape {imported_data['data'].shape}" ) imported_axes = imported_data["axes"] From 510a3a7cd430e2e4085565f8eece23d94ac5ee89 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Tue, 27 Jan 2026 11:00:45 -0800 Subject: [PATCH 027/136] small edits for precommit compliance in dataset3dspectroscopy --- .../spectroscopy/dataset3dspectroscopy.py | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 0f0c86e6..8acf3cc0 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -506,41 +506,41 @@ def _calculate_theoretical_k_factors(self, elements): # Prioritize K-lines, then L-lines, then M-lines if has_k_lines and k_factor_data[element]["K"] > 0: k_factors[element] = k_factor_data[element]["K"] - line_type = "K" + # line_type = "K" elif has_l_lines and k_factor_data[element]["L"] > 0: k_factors[element] = k_factor_data[element]["L"] - line_type = "L" + # line_type = "L" elif has_m_lines and k_factor_data[element]["M"] > 0: k_factors[element] = k_factor_data[element]["M"] - line_type = "M" + # line_type = "M" else: # Default to K-line k-factor if available if k_factor_data[element]["K"] > 0: k_factors[element] = k_factor_data[element]["K"] - line_type = "K" + # line_type = "K" elif k_factor_data[element]["L"] > 0: k_factors[element] = k_factor_data[element]["L"] - line_type = "L" + # line_type = "L" elif k_factor_data[element]["M"] > 0: k_factors[element] = k_factor_data[element]["M"] - line_type = "M" + # line_type = "M" else: k_factors[element] = 1.0 - line_type = "default" + # line_type = "default" else: # Element not in database, use K-line if available if k_factor_data[element]["K"] > 0: k_factors[element] = k_factor_data[element]["K"] - line_type = "K" + # line_type = "K" elif k_factor_data[element]["L"] > 0: k_factors[element] = k_factor_data[element]["L"] - line_type = "L" + # line_type = "L" elif k_factor_data[element]["M"] > 0: k_factors[element] = k_factor_data[element]["M"] - line_type = "M" + # line_type = "M" else: k_factors[element] = 1.0 - line_type = "default" + # line_type = "default" print(f"Using k-factors from Titan 300 keV database: {csv_path}") for elem in elements: @@ -1096,9 +1096,6 @@ def show_mean_spectrum( ax_spec.set_title(f"Spectrum from ROI [{y}:{y + dy}, {x}:{x + dx}]") ax_spec.grid(True, alpha=0.1) - # Use ax_spec for all subsequent peak/line plotting - ax = ax_spec - # HANDLE SHOW_LINES FLAG AND MODEL ELEMENTS ------------------------------------ # Auto-enable show_lines if elements are specified or if auto-detection is needed if show_lines is None: @@ -1130,7 +1127,6 @@ def show_mean_spectrum( # Step 2: Calculate background statistics # Use nanpercentile to handle any NaN values in the spectrum - background_level = np.nanpercentile(spec, 25) background_std = np.nanstd(spec[spec <= np.nanpercentile(spec, 50)]) # Step 3: Determine dynamic SNR thresholds if not provided @@ -1144,12 +1140,10 @@ def show_mean_spectrum( if len(initial_snrs) > 0: snr_median = np.nanmedian(initial_snrs) snr_75th = np.nanpercentile(initial_snrs, 75) - snr_95th = np.nanpercentile(initial_snrs, 95) num_high_snr_peaks = np.sum(np.array(initial_snrs) > 50) else: snr_median = 0 snr_75th = 0 - snr_95th = 0 num_high_snr_peaks = 0 # Set snr_min (detection threshold) if not provided @@ -1425,6 +1419,10 @@ def show_mean_spectrum( ) is_grid_peak = True break + if is_grid_peak: + print( + f"Peak at {peak_energy} keV may come from the grid or contamination." + ) # If elements were detected, use them for element identification only (not for line plotting) if detected_elements: @@ -1584,4 +1582,4 @@ def subtract_background( ) -Dataset3dspectroscopy.load_element_info() \ No newline at end of file +Dataset3dspectroscopy.load_element_info() From 834792d808fc2d788fb54f41b029e0c556f20817 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Tue, 27 Jan 2026 14:12:34 -0800 Subject: [PATCH 028/136] add checks/error messages for out-of-bounds energy range inputs --- src/quantem/spectroscopy/dataset3dspectroscopy.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 8acf3cc0..3b6f0908 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -901,6 +901,18 @@ def calculate_mean_spectrum( # APPLY ENERGY RANGE --------------------------------------------------------------- if energy_range is not None: + # Check for errors in energy_range input + if energy_range[0] >= energy_range[1]: + raise ValueError("Invalid energy range parameter.") + + # If the entire energy range specified is outside the original energy range of the data, raise an error. + if energy_range[1] < E[0] or energy_range[0] > E[-1]: + raise ValueError("Energy range parameter is outside of data bounds.") + + # If either side of input energy_range is beyond the original energy range of the data, default to the limit of the data instead. + energy_range[0] = np.maximum(energy_range[0], E[0]) + energy_range[1] = np.minimum(energy_range[1], E[-1]) + indices = np.where((E >= energy_range[0]) & (E <= energy_range[1]))[0] spec = spec[indices] E = E[indices] @@ -1538,6 +1550,9 @@ def subtract_background( E = E0 + dE * np.arange(self.shape[0]) if energy_range is not None: + energy_range[0] = np.maximum(energy_range[0], E[0]) + energy_range[1] = np.minimum(energy_range[1], E[-1]) + indices = np.where((E >= energy_range[0]) & (E <= energy_range[1]))[0] E = E[indices] else: From 44658b6b64391b124c4bf8f14ed104753dead9f1 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Thu, 29 Jan 2026 17:05:57 -0800 Subject: [PATCH 029/136] flags for returning back-subtracted 3D datasets and storage for back-subtracted spectra --- .../spectroscopy/dataset3dspectroscopy.py | 76 +++++++++++++++---- 1 file changed, 61 insertions(+), 15 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 3b6f0908..840c17b0 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -71,6 +71,8 @@ def __init__( # Initialize model elements storage self.model_elements = None + # Initialize spectra storage + self.attached_spectra = None def add_elements_to_model(self, elements): """ @@ -917,6 +919,11 @@ def calculate_mean_spectrum( spec = spec[indices] E = E[indices] + mean_spectrum = np.vstack((spec, E)) + + self.attached_spectra = [] + self.attached_spectra.append(mean_spectrum) + return spec def show_mean_spectrum( @@ -1512,8 +1519,41 @@ def show_mean_spectrum( # BACKGROND SUBTRACTION + def add_spectrum_to_data(self, spectrum, energy_axis): + """ + Store processed spectra in the 3D spectroscopy dataset structure, in a 1D array of 2D arrays. By default, calculate_mean_spectrum will + """ + two_d_spectrum = np.vstack((spectrum, energy_axis)) + self.attached_spectra.append(two_d_spectrum) + + def clear_attached_spectra(self): + self.attached_spectra = [] + + def plot_attached_spectrum(self, spectrum_index=0): + fig, (ax_spec) = plt.subplots(1, 1, figsize=(12, 4)) + + ax_spec.plot( + self.attached_spectra[spectrum_index][1], + self.attached_spectra[spectrum_index][0], + linewidth=1.5, + ) + ax_spec.set_xlabel("Energy (keV)") + ax_spec.set_ylabel("Intensity") + ax_spec.set_title("Background-subtracted spectrum from ROI") + ax_spec.grid(True, alpha=0.1) + + fig.tight_layout() + plt.show() + def subtract_background( - self, roi=None, energy_range=None, ignore_range=None, mask=None, data_type="eds" + self, + roi=None, + energy_range=None, + ignore_range=None, + mask=None, + data_type="eds", + return_dataset=True, + attach_subtracted_spectrum=True, ): """ Perform appropriate background subtraction routine on mean spectrum from a 3D spectroscopy dataset. @@ -1580,21 +1620,27 @@ def subtract_background( for q in range(self.shape[2]): spec3D_subtracted[:, p, q] = np.maximum(self.array[indices, p, q] - background, 0) - if data_type == "eds": - return Dataset3deds.from_array( - array=spec3D_subtracted, - sampling=self.sampling, - origin=self.origin, - units=self.units, - ) + if return_dataset: + if data_type == "eds": + return Dataset3deds.from_array( + array=spec3D_subtracted, + sampling=self.sampling, + origin=self.origin, + units=self.units, + ) - elif data_type == "eels": - return Dataset3deels.from_array( - array=spec3D_subtracted, - sampling=self.sampling, - origin=self.origin, - units=self.units, - ) + elif data_type == "eels": + return Dataset3deels.from_array( + array=spec3D_subtracted, + sampling=self.sampling, + origin=self.origin, + units=self.units, + ) + else: + print("Notice: no 3D dataset was returned") + + if attach_subtracted_spectrum: + self.add_spectrum_to_data(subtracted_mean_spectrum, E) Dataset3dspectroscopy.load_element_info() From 7db326eac1b1007b095b8b160fe855e70b61eec2 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Thu, 29 Jan 2026 17:30:51 -0800 Subject: [PATCH 030/136] edits to spectrum data storage flags/structure --- .../spectroscopy/dataset3dspectroscopy.py | 80 ++++++++++--------- 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 840c17b0..283c1228 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -109,6 +109,39 @@ def clear_model_elements(self): """Clear all elements from the model.""" self.model_elements = None + # Storage of spectra alongside dataset + + def add_spectrum_to_data(self, spectrum, energy_axis): + """ + Store processed spectra in the 3D spectroscopy dataset structure, in a 1D array of 2D arrays. By default, calculate_mean_spectrum will + """ + two_d_spectrum = np.vstack((spectrum, energy_axis)) + + if self.attached_spectra is not None: + self.attached_spectra.append(two_d_spectrum) + else: + self.attached_spectra = [] + self.attached_spectra.append(two_d_spectrum) + + def clear_attached_spectra(self): + self.attached_spectra = None + + def plot_attached_spectrum(self, spectrum_index=0): + fig, (ax_spec) = plt.subplots(1, 1, figsize=(12, 4)) + + ax_spec.plot( + self.attached_spectra[spectrum_index][1], + self.attached_spectra[spectrum_index][0], + linewidth=1.5, + ) + ax_spec.set_xlabel("Energy (keV)") + ax_spec.set_ylabel("Intensity") + ax_spec.set_title(f"Spectrum in index {spectrum_index}") + ax_spec.grid(True, alpha=0.1) + + fig.tight_layout() + plt.show() + ## PCA ANALYSIS METHODS def perform_pca( @@ -784,11 +817,7 @@ def _calculate_element_combo_score( return score def calculate_mean_spectrum( - self, - roi=None, - energy_range=None, - ignore_range=None, - mask=None, + self, roi=None, energy_range=None, ignore_range=None, mask=None, attach_mean_spectrum=True ): # ADJUST ROI BASED ON GIVEN FLAGS ----------------------------------------------- # Parse ROI parameter @@ -919,10 +948,8 @@ def calculate_mean_spectrum( spec = spec[indices] E = E[indices] - mean_spectrum = np.vstack((spec, E)) - - self.attached_spectra = [] - self.attached_spectra.append(mean_spectrum) + if attach_mean_spectrum: + self.add_spectrum_to_data(spec, E) return spec @@ -1519,32 +1546,6 @@ def show_mean_spectrum( # BACKGROND SUBTRACTION - def add_spectrum_to_data(self, spectrum, energy_axis): - """ - Store processed spectra in the 3D spectroscopy dataset structure, in a 1D array of 2D arrays. By default, calculate_mean_spectrum will - """ - two_d_spectrum = np.vstack((spectrum, energy_axis)) - self.attached_spectra.append(two_d_spectrum) - - def clear_attached_spectra(self): - self.attached_spectra = [] - - def plot_attached_spectrum(self, spectrum_index=0): - fig, (ax_spec) = plt.subplots(1, 1, figsize=(12, 4)) - - ax_spec.plot( - self.attached_spectra[spectrum_index][1], - self.attached_spectra[spectrum_index][0], - linewidth=1.5, - ) - ax_spec.set_xlabel("Energy (keV)") - ax_spec.set_ylabel("Intensity") - ax_spec.set_title("Background-subtracted spectrum from ROI") - ax_spec.grid(True, alpha=0.1) - - fig.tight_layout() - plt.show() - def subtract_background( self, roi=None, @@ -1553,7 +1554,7 @@ def subtract_background( mask=None, data_type="eds", return_dataset=True, - attach_subtracted_spectrum=True, + attach_spectrum=True, ): """ Perform appropriate background subtraction routine on mean spectrum from a 3D spectroscopy dataset. @@ -1639,8 +1640,13 @@ def subtract_background( else: print("Notice: no 3D dataset was returned") - if attach_subtracted_spectrum: + if attach_spectrum: + print( + f"Spectrum recorded to index {len(self.attached_spectra) - 1} of attached_spectra in {self}" + ) self.add_spectrum_to_data(subtracted_mean_spectrum, E) + else: + print(f"Notice: no spectrum recorded to attached_spectra in {self}") Dataset3dspectroscopy.load_element_info() From 1f6bbb3a99f563d56d2059247803fb6b768bbe14 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Fri, 6 Feb 2026 10:10:57 -0800 Subject: [PATCH 031/136] axis label fixes for EELS --- src/quantem/spectroscopy/dataset3dspectroscopy.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 283c1228..b6f839ae 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -1122,7 +1122,10 @@ def show_mean_spectrum( title_suffix = "" im = ax_img.imshow(sum_img, cmap="viridis", origin="lower") - ax_img.set_title(f"EDS Sum Image{title_suffix}") + if data_type == "eds": + ax_img.set_title(f"EDS Sum Image{title_suffix}") + else: + ax_img.set_title(f"EELS Sum Image{title_suffix}") ax_img.set_xlabel("X (pixels)") ax_img.set_ylabel("Y (pixels)") @@ -1137,7 +1140,10 @@ def show_mean_spectrum( # RIGHT PLOT: Show spectrum ax_spec.plot(E, spec, linewidth=1.5) - ax_spec.set_xlabel("Energy (keV)") + if data_type == "eds": + ax_spec.set_xlabel("Energy (keV)") + else: + ax_spec.set_xlabel("Energy (eV)") ax_spec.set_ylabel("Intensity") ax_spec.set_title(f"Spectrum from ROI [{y}:{y + dy}, {x}:{x + dx}]") ax_spec.grid(True, alpha=0.1) @@ -1603,7 +1609,10 @@ def subtract_background( fig, (ax_specbacksub) = plt.subplots(1, 1, figsize=(12, 4)) ax_specbacksub.plot(E, subtracted_mean_spectrum, linewidth=1.5) - ax_specbacksub.set_xlabel("Energy (keV)") + if data_type == "eds": + ax_specbacksub.set_xlabel("Energy (keV)") + else: + ax_specbacksub.set_xlabel("Energy (eV)") ax_specbacksub.set_ylabel("Intensity") ax_specbacksub.set_title("Background-subtracted spectrum from ROI") ax_specbacksub.grid(True, alpha=0.1) From f19cb55384b13c571050509ee60372e4f8cc85c5 Mon Sep 17 00:00:00 2001 From: smribet Date: Sat, 7 Feb 2026 14:52:30 -0800 Subject: [PATCH 032/136] improve optimizer --- src/quantem/spectroscopy/dataset3deds.py | 57 ++++++++++++++++++------ 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index c36d6fd2..9343ca23 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -66,8 +66,9 @@ def fit_spectrum_pytorch( elements_to_fit=None, peak_width=0.1, num_iters=1000, - lr=0.01, + lr=None, polynomial_background_degree=3, + optimizer="lbfgs", ): energy_axis = np.arange(self.shape[0]) * self.sampling[0] + self.origin[0] energy_axis = torch.tensor(energy_axis, dtype=torch.float32) @@ -97,21 +98,49 @@ def fit_spectrum_pytorch( with torch.no_grad(): model.peak_model.concentrations.fill_((1)) - optimizer = torch.optim.Adam(model.parameters(), lr=lr) + optimizer_name = optimizer.lower() + if optimizer_name == "adam": + if lr is None: + lr = 1e-3 + optimizer = torch.optim.Adam(model.parameters(), lr=lr) + elif optimizer_name == "lbfgs": + # Keep each outer-loop iteration comparable to Adam by limiting each LBFGS + # step to a single internal iteration. + if lr is None: + lr = 1 + optimizer = torch.optim.LBFGS( + model.parameters(), + lr=lr, + max_iter=1, + line_search_fn="strong_wolfe", + ) + else: + raise ValueError("optimizer must be 'lbfgs' or 'adam'") loss_fn = nn.MSELoss() loss_iter = [] - for iters in range(num_iters): - optimizer.zero_grad() - - predicted = model() - - loss = loss_fn(predicted, spectrum) - - loss.backward() - optimizer.step() - - loss_iter.append(loss.detach().numpy()) + for _ in range(num_iters): + if optimizer_name == "lbfgs": + + def closure(): + optimizer.zero_grad() + predicted = model() + loss = loss_fn(predicted, spectrum) + loss.backward() + return loss + + loss = optimizer.step(closure) + if not torch.is_tensor(loss): + with torch.no_grad(): + loss = loss_fn(model(), spectrum) + else: + optimizer.zero_grad() + predicted = model() + loss = loss_fn(predicted, spectrum) + loss.backward() + optimizer.step() + + loss_iter.append(float(loss.detach().cpu().item())) loss_iter = np.asarray(loss_iter) # plot_results @@ -210,4 +239,4 @@ def calculate_background_powerlaw(self, spectrum): # Ensure background doesn't exceed spectrum background = np.minimum(background, spectrum * 0.9) - return background \ No newline at end of file + return background From 48905a1094fc08d91ab04308fe713648e7cf3d4a Mon Sep 17 00:00:00 2001 From: smribet Date: Sat, 7 Feb 2026 15:28:28 -0800 Subject: [PATCH 033/136] move to 3D fitting --- src/quantem/spectroscopy/dataset3deds.py | 489 +++++++++++++++--- .../spectroscopy/spectroscopy_models.py | 71 ++- 2 files changed, 484 insertions(+), 76 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 9343ca23..0a915918 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -6,8 +6,18 @@ import torch.nn as nn from numpy.typing import NDArray +from quantem.core.visualization import show_2d from quantem.spectroscopy import Dataset3dspectroscopy -from quantem.spectroscopy.spectroscopy_models import EDSModel, GaussianPeaks, PolynomialBackground +from quantem.spectroscopy.spectroscopy_models import ( + EDSModel, + GaussianPeaks, + PolynomialBackground, + abundance_smoothness_l2, + build_element_basis, + eds_data_loss, + inverse_softplus, + polynomial_energy_basis, +) class Dataset3deds(Dataset3dspectroscopy): @@ -60,55 +70,47 @@ def __init__( ) self._virtual_images = {} - def fit_spectrum_pytorch( + def _fit_mean_model_pytorch( self, - energy_range=None, - elements_to_fit=None, - peak_width=0.1, - num_iters=1000, - lr=None, - polynomial_background_degree=3, - optimizer="lbfgs", + energy_axis, + spectrum_raw, + elements_to_fit, + peak_width, + polynomial_background_degree, + num_iters, + optimizer, + lr, + loss_name, + normalize_target, + default_lr_adam, + default_lr_lbfgs, ): - energy_axis = np.arange(self.shape[0]) * self.sampling[0] + self.origin[0] - energy_axis = torch.tensor(energy_axis, dtype=torch.float32) - - # TODO: make_more_flexible - spectrum_raw = torch.tensor(self.array.sum((-1, -2)), dtype=torch.float32) - - if energy_range is not None: - ind = (energy_axis >= energy_range[0]) & (energy_axis <= energy_range[1]) - spectrum_raw = spectrum_raw[ind] - energy_axis = energy_axis[ind] - else: - energy_range = [energy_axis.min().numpy(), energy_axis.max().numpy()] + target = spectrum_raw + spectrum_offset = torch.tensor(0.0, dtype=spectrum_raw.dtype, device=spectrum_raw.device) + spectrum_scale = torch.tensor(1.0, dtype=spectrum_raw.dtype, device=spectrum_raw.device) + if normalize_target: + spectrum_offset = spectrum_raw.min() + spectrum_scale = torch.clamp(spectrum_raw.max() - spectrum_offset, min=1e-8) + target = (spectrum_raw - spectrum_offset) / spectrum_scale - # rescale 0 to 1 - spectrum_min = spectrum_raw.min() - spectrum_max = spectrum_raw.max() - spectrum = spectrum_raw - spectrum_min - spectrum = spectrum / (spectrum_max - spectrum_min) - - # initialize background = PolynomialBackground(energy_axis, degree=polynomial_background_degree) - peaks = GaussianPeaks(energy_axis, peak_width=peak_width, elements_to_fit=elements_to_fit) model = EDSModel(peaks, background, energy_axis=energy_axis) + if len(model.peak_model.element_names) == 0: + raise ValueError("No elements found in the selected energy range/elements_to_fit.") with torch.no_grad(): - model.peak_model.concentrations.fill_((1)) + model.peak_model.concentrations.fill_(1.0) optimizer_name = optimizer.lower() if optimizer_name == "adam": if lr is None: - lr = 1e-3 - optimizer = torch.optim.Adam(model.parameters(), lr=lr) + lr = default_lr_adam + optimizer_obj = torch.optim.Adam(model.parameters(), lr=lr) elif optimizer_name == "lbfgs": - # Keep each outer-loop iteration comparable to Adam by limiting each LBFGS - # step to a single internal iteration. if lr is None: - lr = 1 - optimizer = torch.optim.LBFGS( + lr = default_lr_lbfgs + optimizer_obj = torch.optim.LBFGS( model.parameters(), lr=lr, max_iter=1, @@ -116,86 +118,423 @@ def fit_spectrum_pytorch( ) else: raise ValueError("optimizer must be 'lbfgs' or 'adam'") - loss_fn = nn.MSELoss() loss_iter = [] for _ in range(num_iters): if optimizer_name == "lbfgs": def closure(): - optimizer.zero_grad() + optimizer_obj.zero_grad() predicted = model() - loss = loss_fn(predicted, spectrum) + loss = eds_data_loss(predicted, target, loss=loss_name) loss.backward() return loss - loss = optimizer.step(closure) + loss = optimizer_obj.step(closure) if not torch.is_tensor(loss): with torch.no_grad(): - loss = loss_fn(model(), spectrum) + loss = eds_data_loss(model(), target, loss=loss_name) else: - optimizer.zero_grad() + optimizer_obj.zero_grad() predicted = model() - loss = loss_fn(predicted, spectrum) + loss = eds_data_loss(predicted, target, loss=loss_name) loss.backward() - optimizer.step() + optimizer_obj.step() loss_iter.append(float(loss.detach().cpu().item())) - loss_iter = np.asarray(loss_iter) - # plot_results with torch.no_grad(): - final_pred = model().detach().numpy() * spectrum_max.numpy() + spectrum_min.numpy() - concs = nn.functional.softplus(model.peak_model.concentrations).detach().numpy() - - final_fwhm = ( - torch.nn.functional.softplus(model.peak_model.peak_width_by_peak) - .detach() - .cpu() - .numpy() + final_pred_target = model() + if normalize_target: + final_pred_raw = final_pred_target * spectrum_scale + spectrum_offset + else: + final_pred_raw = final_pred_target + + return { + "model": model, + "loss_history": np.asarray(loss_iter), + "final_pred_raw": final_pred_raw.detach(), + "spectrum_offset": spectrum_offset.detach(), + "spectrum_scale": spectrum_scale.detach(), + } + + def fit_spectrum_mean_pytorch( + self, + energy_range=None, + elements_to_fit=None, + peak_width=0.1, + num_iters=1000, + lr=None, + polynomial_background_degree=3, + optimizer="lbfgs", + ): + return self.fit_spectrum_pytorch( + energy_range=energy_range, + elements_to_fit=elements_to_fit, + peak_width=peak_width, + num_iters=num_iters, + lr=lr, + polynomial_background_degree=polynomial_background_degree, + optimizer=optimizer, + loss="mse", + fit_mean_only=True, + show_plot=True, + ) + + def fit_spectrum_pytorch( + self, + energy_range=None, + elements_to_fit=None, + peak_width=0.1, + num_iters=300, + num_iters_global=200, + lr=None, + polynomial_background_degree=3, + optimizer=None, + loss=None, + freeze_peak_width=True, + spatial_lambda=0.0, + min_total_counts=0.0, + verbose=True, + fit_mean_only=False, + show_plot=True, + ): + """Fit EDS spectra with one entrypoint for mean-only or full-cube fitting. + + Parameters + ---------- + energy_range : list[float] | tuple[float, float] | None + Energy range [emin, emax] to include in the fit. + elements_to_fit : list[str] | None + Element symbols to fit. If None, all available elements in range are used. + peak_width : float, optional + Initial peak FWHM in keV. + num_iters : int, optional + Number of optimization iterations for per-pixel fitting. + num_iters_global : int, optional + Number of mean-spectrum iterations used to initialize local fitting (3D mode). + lr : float, optional + Learning rate. If None, sensible mode-specific defaults are used. + polynomial_background_degree : int, optional + Degree of per-pixel polynomial background. + optimizer : str | None, optional + Optimizer, "adam" or "lbfgs". If None, defaults to "lbfgs" for mean-only + and "adam" for 3D fitting. + loss : str | None, optional + Data term, "poisson" or "mse". If None, defaults to "mse" for mean-only + and "poisson" for 3D fitting. + freeze_peak_width : bool, optional + If True, lock peak widths after global fit (3D mode). + spatial_lambda : float, optional + Weight for spatial smoothness on abundance maps (3D mode). + min_total_counts : float, optional + Ignore pixels with summed counts below this threshold in data loss (3D mode). + verbose : bool, optional + Print progress updates. + fit_mean_only : bool, optional + If True, fit only the summed spectrum over (x, y). + show_plot : bool, optional + Plot fit diagnostics in mean-only mode. + + Returns + ------- + dict + Mean-only mode keys include concentrations, fit, and diagnostics. + 3D mode keys include abundance maps and fit diagnostics. + """ + if optimizer is None: + optimizer = "lbfgs" + if loss is None: + loss = "mse" + + optimizer_name = optimizer.lower() + if optimizer_name not in {"adam", "lbfgs"}: + raise ValueError("optimizer must be 'adam' or 'lbfgs'") + + loss_name = loss.lower() + if loss_name not in {"poisson", "mse"}: + raise ValueError("loss must be 'poisson' or 'mse'") + + if spatial_lambda < 0: + raise ValueError("spatial_lambda must be >= 0") + + energy_axis_np = np.arange(self.shape[0]) * self.sampling[0] + self.origin[0] + energy_axis = torch.tensor(energy_axis_np, dtype=torch.float32) + spectra = torch.tensor(self.array, dtype=torch.float32) # (E, Y, X) + + if energy_range is not None: + ind = (energy_axis >= energy_range[0]) & (energy_axis <= energy_range[1]) + energy_axis = energy_axis[ind] + spectra = spectra[ind] + else: + energy_range = [float(energy_axis.min().numpy()), float(energy_axis.max().numpy())] + + if fit_mean_only: + spectrum_raw = spectra.sum((-1, -2)) + mean_fit = self._fit_mean_model_pytorch( + energy_axis=energy_axis, + spectrum_raw=spectrum_raw, + elements_to_fit=elements_to_fit, + peak_width=peak_width, + polynomial_background_degree=polynomial_background_degree, + num_iters=num_iters, + optimizer=optimizer_name, + lr=lr, + loss_name=loss_name, + normalize_target=True, + default_lr_adam=1e-3, + default_lr_lbfgs=1.0, ) + + model = mean_fit["model"] + loss_history = mean_fit["loss_history"] + spectrum_offset = mean_fit["spectrum_offset"] + spectrum_scale = mean_fit["spectrum_scale"] + with torch.no_grad(): + final_pred = mean_fit["final_pred_raw"].cpu().numpy() + concs = ( + nn.functional.softplus(model.peak_model.concentrations).detach().cpu().numpy() + ) + final_fwhm = ( + torch.nn.functional.softplus(model.peak_model.peak_width_by_peak) + .detach() + .cpu() + .numpy() + ) + background_fit = ( + (model.background_model().detach() * spectrum_scale + spectrum_offset) + .cpu() + .numpy() + ) + print( f"\nFinal: width median={np.median(final_fwhm):.3f} keV, " f"min={final_fwhm.min():.3f}, max={final_fwhm.max():.3f}" ) - # Sort and show top N - top_n = np.max((10, len(elements_to_fit))) + top_n = max(10, len(elements_to_fit) if elements_to_fit is not None else 0) sorted_indices = np.argsort(concs)[::-1] - print("\nTop elements:") for i, idx in enumerate(sorted_indices[:top_n], 1): elem = model.peak_model.element_names[idx] conc = concs[idx] print(f"{i:2d}. {elem:2s}: {conc:.3f}") - fig, ax = plt.subplots(2, 1, figsize=(10, 6)) - ax[0].plot(np.arange(loss_iter.shape[0]), loss_iter, color="k") - ax[0].set_title("loss") - ax[0].set_xlabel("iterations") - ax[0].set_ylabel("loss") - ax[0].set_yscale("log") + if show_plot: + fig, ax = plt.subplots(2, 1, figsize=(10, 6)) + ax[0].plot(np.arange(loss_history.shape[0]), loss_history, color="k") + ax[0].set_title("loss") + ax[0].set_xlabel("iterations") + ax[0].set_ylabel("loss") + ax[0].set_yscale("log") + + ax[1].plot(energy_axis, spectrum_raw.numpy(), "k-", label="Data", linewidth=1) + ax[1].plot(energy_axis, final_pred, "r-", label="Fit", linewidth=2) + ax[1].plot(energy_axis, background_fit, "b--", label="Background", linewidth=1.5) + ax[1].set_xlim(energy_range[0], energy_range[1]) + ax[1].legend() + ax[1].set_title("fit spectrum") + ax[1].set_xlabel("Energy (keV)") + ax[1].set_ylabel("Counts") + plt.tight_layout() + plt.show() + + return { + "loss_history": loss_history, + "fitted_spectrum": final_pred, + "input_spectrum": spectrum_raw.detach().cpu().numpy(), + "background_spectrum": background_fit, + "concentrations": concs, + "element_names": model.peak_model.element_names, + "peak_widths": final_fwhm, + "energy_axis": energy_axis.detach().cpu().numpy(), + "fit_range": energy_range, + } + + n_energy, n_y, n_x = spectra.shape + n_pixels = n_y * n_x + spectra_flat = spectra.permute(1, 2, 0).reshape(n_pixels, n_energy) + + total_counts = spectra_flat.sum(dim=1) + valid_pixel_mask = total_counts >= float(min_total_counts) + if not torch.any(valid_pixel_mask): + raise ValueError("No pixels satisfy min_total_counts. Lower threshold and retry.") + + mean_spectrum = spectra_flat[valid_pixel_mask].mean(dim=0) + + # Stage 1: global mean-spectrum fit to initialize per-pixel parameters. + global_fit = self._fit_mean_model_pytorch( + energy_axis=energy_axis, + spectrum_raw=mean_spectrum, + elements_to_fit=elements_to_fit, + peak_width=peak_width, + polynomial_background_degree=polynomial_background_degree, + num_iters=num_iters_global, + optimizer="lbfgs", + lr=1.0, + loss_name=loss_name, + normalize_target=False, + default_lr_adam=1e-3, + default_lr_lbfgs=1.0, + ) + global_model = global_fit["model"] + global_loss_history = global_fit["loss_history"] - ax[1].plot(energy_axis, spectrum_raw.numpy(), "k-", label="Data", linewidth=1) - ax[1].plot(energy_axis, final_pred, "r-", label="Fit", linewidth=2) + with torch.no_grad(): + global_conc = nn.functional.softplus(global_model.peak_model.concentrations).detach() + global_bg_coeffs = global_model.background_model.coeffs.detach() + global_peak_width_params = global_model.peak_model.peak_width_by_peak.detach().clone() + + # Stage 2: vectorized per-pixel fit with shared peak shapes. + n_elements = len(global_model.peak_model.element_names) + peak_energies = global_model.peak_model.peak_energies + peak_weights = global_model.peak_model.peak_weights + peak_element_indices = global_model.peak_model.peak_element_indices + energy_step = float(global_model.peak_model.energy_step) + + background_basis = polynomial_energy_basis( + energy_axis, degree=polynomial_background_degree + ) + + mean_total = torch.clamp(mean_spectrum.sum(), min=1e-8) + pixel_scales = (total_counts / mean_total).unsqueeze(1) + conc_init = torch.clamp(global_conc.unsqueeze(0) * pixel_scales, min=1e-6) + conc_logits = nn.Parameter(inverse_softplus(conc_init)) + bg_coeffs = nn.Parameter(global_bg_coeffs.unsqueeze(0).repeat(n_pixels, 1) * pixel_scales) - if model.background_model is not None: - background = ( - model.background_model().detach().numpy() * spectrum_max.numpy() - + spectrum_min.numpy() + if freeze_peak_width: + peak_width_params = global_peak_width_params + else: + peak_width_params = nn.Parameter(global_peak_width_params.clone()) + + if freeze_peak_width: + element_basis = build_element_basis( + energy_axis=energy_axis, + peak_energies=peak_energies, + peak_weights=peak_weights, + peak_element_indices=peak_element_indices, + peak_width_by_peak=peak_width_params, + n_elements=n_elements, + energy_step=energy_step, + ) + + trainable_params = [conc_logits, bg_coeffs] + if not freeze_peak_width: + trainable_params.append(peak_width_params) + + local_lr = lr + if local_lr is None: + local_lr = 0.05 if optimizer_name == "adam" else 1.0 + + if optimizer_name == "adam": + local_opt = torch.optim.Adam(trainable_params, lr=local_lr) + else: + local_opt = torch.optim.LBFGS( + trainable_params, + lr=local_lr, + max_iter=1, + line_search_fn="strong_wolfe", + history_size=10, + ) + + loss_history = [] + + def _forward_model(): + basis = ( + element_basis + if freeze_peak_width + else build_element_basis( + energy_axis=energy_axis, + peak_energies=peak_energies, + peak_weights=peak_weights, + peak_element_indices=peak_element_indices, + peak_width_by_peak=peak_width_params, + n_elements=n_elements, + energy_step=energy_step, ) - ax[1].plot(energy_axis, background, "b--", label="Background", linewidth=1.5) + ) + conc = nn.functional.softplus(conc_logits) # (P, n_elements) + peaks_pred = conc @ basis.t() # (P, E) + bg_raw = bg_coeffs @ background_basis # (P, E) + bg_pred = nn.functional.softplus(bg_raw) + predicted = torch.clamp(peaks_pred + bg_pred, min=1e-8) + return predicted, conc + + def _local_loss(pred_local, conc_local): + loss_data = eds_data_loss( + pred_local[valid_pixel_mask], + spectra_flat[valid_pixel_mask], + loss=loss_name, + ) + if spatial_lambda <= 0: + return loss_data - ax[1].set_xlim(energy_range[0], energy_range[1]) - ax[1].legend() + conc_maps = conc_local.view(n_y, n_x, n_elements).permute(2, 0, 1) + loss_smooth = abundance_smoothness_l2(conc_maps) + return loss_data + spatial_lambda * loss_smooth - ax[1].set_title("fit spectrum") - ax[1].set_xlabel("Energy (keV)") - ax[1].set_ylabel("Counts") + for i in range(num_iters): + if optimizer_name == "lbfgs": + + def _local_closure(): + local_opt.zero_grad() + pred_local, conc_local = _forward_model() + loss_total = _local_loss(pred_local, conc_local) + loss_total.backward() + return loss_total + + loss_value = local_opt.step(_local_closure) + if not torch.is_tensor(loss_value): + with torch.no_grad(): + pred_local, conc_local = _forward_model() + loss_value = _local_loss(pred_local, conc_local) + else: + local_opt.zero_grad() + pred_local, conc_local = _forward_model() + loss_value = _local_loss(pred_local, conc_local) + loss_value.backward() + local_opt.step() + loss_history.append(float(loss_value.detach().cpu().item())) + if verbose and ((i + 1) % max(1, num_iters // 10) == 0 or i == 0): + print(f"iter {i + 1:4d}/{num_iters}: loss={loss_history[-1]:.6g}") + + with torch.no_grad(): + _, conc_final = _forward_model() + abundance_maps = conc_final.view(n_y, n_x, n_elements).permute(2, 0, 1).cpu().numpy() + peak_widths = nn.functional.softplus(peak_width_params).detach().cpu().numpy() + loss_history_array = np.asarray(loss_history) + + if show_plot: + fig, ax = plt.subplots(1, 1, figsize=(8, 4)) + ax.plot( + np.arange(global_loss_history.shape[0]), global_loss_history, "b-", label="global" + ) + ax.plot( + np.arange(loss_history_array.shape[0]), loss_history_array, "k-", label="local" + ) + ax.set_title("loss") + ax.set_xlabel("iterations") + ax.set_ylabel("loss") + ax.set_yscale("log") + ax.legend() plt.tight_layout() plt.show() + map_titles = [f"{name}" for name in global_model.peak_model.element_names] + show_2d(list(abundance_maps), title=map_titles) + + return { + "abundance_maps": abundance_maps, + "element_names": global_model.peak_model.element_names, + "peak_widths": peak_widths, + "loss_history": loss_history_array, + "global_loss_history": np.asarray(global_loss_history), + "valid_pixel_mask": valid_pixel_mask.view(n_y, n_x).cpu().numpy(), + "energy_axis": energy_axis.cpu().numpy(), + "fit_range": energy_range, + } + def calculate_background_powerlaw(self, spectrum): import numpy as np diff --git a/src/quantem/spectroscopy/spectroscopy_models.py b/src/quantem/spectroscopy/spectroscopy_models.py index a6f31ed9..3b0b2928 100644 --- a/src/quantem/spectroscopy/spectroscopy_models.py +++ b/src/quantem/spectroscopy/spectroscopy_models.py @@ -6,6 +6,75 @@ import torch.nn as nn +def inverse_softplus(x: torch.Tensor, min_value: float = 1e-8) -> torch.Tensor: + """Numerically stable inverse of softplus for positive initialization values.""" + x = torch.clamp(x, min=min_value) + return torch.log(torch.expm1(x)) + + +def eds_data_loss( + predicted: torch.Tensor, target: torch.Tensor, loss: str = "poisson", min_value: float = 1e-8 +) -> torch.Tensor: + """Compute EDS fit loss with clamped positive predictions.""" + pred_safe = torch.clamp(predicted, min=min_value) + if loss == "poisson": + return torch.mean(pred_safe - target * torch.log(pred_safe)) + if loss == "mse": + return nn.functional.mse_loss(pred_safe, target) + raise ValueError("loss must be 'poisson' or 'mse'") + + +def polynomial_energy_basis(energy_axis: torch.Tensor, degree: int) -> torch.Tensor: + """Return polynomial basis in normalized energy coordinates.""" + energy_norm = (energy_axis - energy_axis.min()) / ( + energy_axis.max() - energy_axis.min() + 1e-12 + ) + return torch.stack([energy_norm**i for i in range(degree + 1)], dim=0) + + +def build_element_basis( + energy_axis: torch.Tensor, + peak_energies: torch.Tensor, + peak_weights: torch.Tensor, + peak_element_indices: torch.Tensor, + peak_width_by_peak: torch.Tensor, + n_elements: int, + energy_step: float, +) -> torch.Tensor: + """Build matrix mapping per-element concentrations to spectral intensity.""" + fwhm = nn.functional.softplus(peak_width_by_peak) + sigma = (fwhm / 2.355).unsqueeze(1) + centers = peak_energies.unsqueeze(1) + energies = energy_axis.unsqueeze(0) + all_peaks = torch.exp(-0.5 * ((energies - centers) / sigma) ** 2) + sqrt_2pi = torch.sqrt(torch.tensor(2 * np.pi, dtype=all_peaks.dtype, device=all_peaks.device)) + all_peaks = all_peaks * energy_step / (sqrt_2pi * sigma) + weighted_peaks = all_peaks * peak_weights.unsqueeze(1) + + basis = torch.zeros( + (n_elements, energy_axis.shape[0]), + dtype=weighted_peaks.dtype, + device=weighted_peaks.device, + ) + basis.index_add_(0, peak_element_indices.to(weighted_peaks.device), weighted_peaks) + return basis.t() + + +def abundance_smoothness_l2(abundance_maps: torch.Tensor) -> torch.Tensor: + """Spatial L2 smoothness for abundance maps shaped (n_elements, y, x).""" + if abundance_maps.ndim != 3: + raise ValueError("abundance_maps must have shape (n_elements, y, x)") + + loss = abundance_maps.new_tensor(0.0) + if abundance_maps.shape[2] > 1: + dx = abundance_maps[:, :, 1:] - abundance_maps[:, :, :-1] + loss = loss + dx.pow(2).mean() + if abundance_maps.shape[1] > 1: + dy = abundance_maps[:, 1:, :] - abundance_maps[:, :-1, :] + loss = loss + dy.pow(2).mean() + return loss + + class EDSModel(nn.Module): """Complete EDS forward model with optional fit range""" @@ -90,7 +159,7 @@ def __init__(self, energy_axis, peak_width, elements_to_fit=None): self.n_peaks = len(all_peak_energies) init_fwhm = torch.tensor(peak_width, dtype=torch.float32) self.peak_width_by_peak = nn.Parameter( - torch.log(torch.expm1(init_fwhm)) * torch.ones(self.n_peaks) + inverse_softplus(init_fwhm) * torch.ones(self.n_peaks) ) print(f"Fitting {n_elements} elements with {self.n_peaks} total peaks") From b222257922b9bc9b0bb71b4b41562977e314d3e8 Mon Sep 17 00:00:00 2001 From: smribet Date: Sat, 7 Feb 2026 15:30:26 -0800 Subject: [PATCH 034/136] make plotting better --- src/quantem/spectroscopy/dataset3deds.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 0a915918..d0510ea1 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -507,11 +507,26 @@ def _local_closure(): if show_plot: fig, ax = plt.subplots(1, 1, figsize=(8, 4)) + global_x = np.arange(global_loss_history.shape[0]) + local_x = np.arange(loss_history_array.shape[0]) + global_loss_history.shape[0] ax.plot( - np.arange(global_loss_history.shape[0]), global_loss_history, "b-", label="global" + global_x, + global_loss_history, + "b-", + label="global", ) ax.plot( - np.arange(loss_history_array.shape[0]), loss_history_array, "k-", label="local" + local_x, + loss_history_array, + "r-", + label="local", + ) + ax.axvline( + x=global_loss_history.shape[0] - 0.5, + color="gray", + linestyle="--", + linewidth=1.0, + label="switch", ) ax.set_title("loss") ax.set_xlabel("iterations") From 5f7d8dcb3e6fe06e455f62c0d67db6b8340c442e Mon Sep 17 00:00:00 2001 From: smribet Date: Sat, 7 Feb 2026 15:33:17 -0800 Subject: [PATCH 035/136] change loss --- src/quantem/spectroscopy/dataset3deds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index d0510ea1..4e295c6e 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -245,7 +245,7 @@ def fit_spectrum_pytorch( if optimizer is None: optimizer = "lbfgs" if loss is None: - loss = "mse" + loss = "mse" if fit_mean_only else "poisson" optimizer_name = optimizer.lower() if optimizer_name not in {"adam", "lbfgs"}: From 692b0dbb2c7a5e8013a5ebc21ccd128c4454013d Mon Sep 17 00:00:00 2001 From: smribet Date: Sun, 8 Feb 2026 06:17:48 -0800 Subject: [PATCH 036/136] reformatting, adding EELS --- src/quantem/spectroscopy/dataset3deds.py | 5 + src/quantem/spectroscopy/dataset3deels.py | 95 +- .../spectroscopy/dataset3dspectroscopy.py | 238 +- .../spectroscopy/eels_binding_energies.json | 3223 +++++++++++++++++ 4 files changed, 3471 insertions(+), 90 deletions(-) create mode 100644 src/quantem/spectroscopy/eels_binding_energies.json diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 4e295c6e..4fa7c7a9 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -30,6 +30,10 @@ class Dataset3deds(Dataset3dspectroscopy): """ + element_info = None + element_info_path = "xray_lines.json" + dataset_type = "EDS" + def __init__( self, array: NDArray | Any, @@ -69,6 +73,7 @@ def __init__( _token=_token, ) self._virtual_images = {} + self.dataset_type = "EDS" def _fit_mean_model_pytorch( self, diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index 825dff6f..07c06f81 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -15,6 +15,10 @@ class Dataset3deels(Dataset3dspectroscopy): """ + element_info = None + element_info_path = "eels_binding_energies.json" + dataset_type = "EELS" + def __init__( self, array: NDArray | Any, @@ -54,49 +58,50 @@ def __init__( _token=_token, ) self._virtual_images = {} + self.dataset_type = "EELS" + + def calculate_background_iterative(self, spectrum): + """ + Subtract background typical for EELS using iterative Gaussian fitting. + This method isolates the continuum background from the low-loss region. + + WARNING: Only use with EELS data! Will remove peaks if used with EDS. + + Parameters + ---------- + spectrum : ndarray + 1D EELS spectrum + energy_axis : ndarray + Energy axis corresponding to spectrum + + Returns + ------- + ndarray + Background-subtracted spectrum + """ + + from scipy.ndimage import gaussian_filter + from scipy.stats import norm + + # Smooth for better fitting + spec_smooth = gaussian_filter(spectrum, sigma=1.0) + pixel_vals = spec_smooth.copy() + + # Iteratively fit Gaussian to low-intensity values (the continuum) + # Remove outliers (edge peaks) iteratively + num_iterations = 10 + cutoff = 3 # +/- 3 sigma + + for _ in range(num_iterations): + mu, std = norm.fit(pixel_vals) + if std == 0: + break + # Keep only values within +/- 3 sigma (removes edge contributions) + lower = mu - cutoff * std + upper = mu + cutoff * std + pixel_vals = pixel_vals[(pixel_vals >= lower) & (pixel_vals <= upper)] + + # Subtract the estimated background level + background_fit = mu - def calculate_background_iterative(self, spectrum): - """ - Subtract background typical for EELS using iterative Gaussian fitting. - This method isolates the continuum background from the low-loss region. - - WARNING: Only use with EELS data! Will remove peaks if used with EDS. - - Parameters - ---------- - spectrum : ndarray - 1D EELS spectrum - energy_axis : ndarray - Energy axis corresponding to spectrum - - Returns - ------- - ndarray - Background-subtracted spectrum - """ - - from scipy.ndimage import gaussian_filter - from scipy.stats import norm - - # Smooth for better fitting - spec_smooth = gaussian_filter(spectrum, sigma=1.0) - pixel_vals = spec_smooth.copy() - - # Iteratively fit Gaussian to low-intensity values (the continuum) - # Remove outliers (edge peaks) iteratively - num_iterations = 10 - cutoff = 3 # +/- 3 sigma - - for _ in range(num_iterations): - mu, std = norm.fit(pixel_vals) - if std == 0: - break - # Keep only values within +/- 3 sigma (removes edge contributions) - lower = mu - cutoff * std - upper = mu + cutoff * std - pixel_vals = pixel_vals[(pixel_vals >= lower) & (pixel_vals <= upper)] - - # Subtract the estimated background level - background_fit = mu - - return background_fit \ No newline at end of file + return background_fit diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index b6f839ae..f1089629 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -1,7 +1,7 @@ import csv import json import os -from typing import Optional +from typing import Any, Optional import matplotlib.pyplot as plt import numpy as np @@ -16,10 +16,48 @@ class Dataset3dspectroscopy(Dataset3d): # stores the element line info so you don't need to reload each time element_info = None + element_info_path = "xray_lines.json" + dataset_type = "EDS" - # loads the xray lines dataset + def __init__( + self, + array: NDArray | Any, + name: str, + origin: NDArray | tuple | list | float | int, + sampling: NDArray | tuple | list | float | int, + units: list[str] | tuple | list, + signal_units: str = "arb. units", + _token: object | None = None, + ): + super().__init__( + array=array, + name=name, + origin=origin, + sampling=sampling, + units=units, + signal_units=signal_units, + _token=type(self)._token if _token is None else _token, + ) + + # Initialize model elements storage + self.model_elements = None + # Initialize spectra storage + self.attached_spectra = None + + # loads elemental information @classmethod - def load_element_info(cls, path="xray_lines.json"): + def load_element_info( + cls, + ): + """Load element database for EDS (X-ray lines) or EELS (binding energies).""" + class_type = str(getattr(cls, "dataset_type", "")).strip().lower() + if class_type == "eels": + path = "eels_binding_energies.json" + elif class_type == "eds": + path = "xray_lines.json" + else: + path = getattr(cls, "element_info_path", "xray_lines.json") + if cls.element_info is not None: # don't reload if already loaded return @@ -28,51 +66,161 @@ def load_element_info(cls, path="xray_lines.json"): with open(full_path, "r") as f: cls.element_info = json.load(f)["elements"] - def __init__( + def format_spectral_features_table( self, - array, - name=None, - origin=None, - sampling=None, - units=None, - signal_units="arb. units", - _token=None, - ): - if ( - name is None - and origin is None - and sampling is None - and units is None - and hasattr(array, "array") - and hasattr(array, "name") - and hasattr(array, "origin") - and hasattr(array, "sampling") - and hasattr(array, "units") - ): - super().__init__( - array=array.array, - name=array.name, - origin=getattr(array, "origin", np.zeros(3)), - sampling=array.sampling, - units=array.units, - signal_units=getattr(array, "signal_units", signal_units), - _token=type(self)._token if _token is None else _token, - ) + element: str, + source: str = "auto", + sort_by: str = "energy", + ascending: bool = True, + precision: int = 4, + ) -> str: + """Format X-ray lines or EELS edges for one element as a simple text table.""" + if type(self).element_info is None: + type(self).load_element_info() + + all_info = type(self).element_info or {} + if element not in all_info: + available = sorted(all_info.keys()) + msg = f"Element '{element}' not found." + if available: + msg += f" Available examples: {', '.join(available[:10])}" + raise ValueError(msg) + + source_norm = source.strip().lower() + if source_norm not in {"auto", "xray", "eels"}: + raise ValueError("source must be one of: 'auto', 'xray', 'eels'") + + if source_norm == "auto": + class_type = str(getattr(type(self), "dataset_type", "")).strip().lower() + source_norm = "eels" if class_type == "eels" else "xray" + + energy_keys = ( + ("energy (eV)", "onset (eV)", "edge (eV)", "energy") + if source_norm == "eels" + else ("energy (keV)", "energy_keV", "energy") + ) + rows = [] + for feature_name, info in all_info[element].items(): + if isinstance(info, dict): + energy_raw = next( + (info.get(k) for k in energy_keys if info.get(k) is not None), None + ) + weight_raw = info.get("weight", info.get("strength")) + else: + energy_raw = info + weight_raw = None + + try: + energy = float(energy_raw) + except (TypeError, ValueError): + continue + + try: + weight = float(weight_raw) if weight_raw is not None else np.nan + except (TypeError, ValueError): + weight = np.nan + + rows.append((str(feature_name), energy, weight)) + + if not rows: + return f"{element}: no spectral features found." + + sort_index = { + "feature": 0, + "line": 0, + "edge": 0, + "energy": 1, + "weight": 2, + "strength": 2, + }.get(sort_by.strip().lower()) + if sort_index is None: + raise ValueError("sort_by must be one of: feature/line/edge, energy, weight/strength") + rows.sort(key=lambda r: r[sort_index], reverse=not ascending) + + unit = "eV" if source_norm == "eels" else "keV" + show_weight = any(np.isfinite(r[2]) for r in rows) + feature_w = max(len("Feature"), *(len(r[0]) for r in rows)) + energy_vals = [f"{r[1]:.{precision}f}" for r in rows] + energy_w = max(len(f"Energy ({unit})"), *(len(v) for v in energy_vals)) + + if show_weight: + weight_vals = [f"{r[2]:.{precision}f}" if np.isfinite(r[2]) else "" for r in rows] + weight_w = max(len("Weight"), *(len(v) for v in weight_vals)) + header = f"{'Feature':<{feature_w}} {'Energy (' + unit + ')':>{energy_w}} {'Weight':>{weight_w}}" + lines = [ + f"{element} {'EELS edges' if source_norm == 'eels' else 'X-ray lines'}", + header, + "-" * len(header), + ] + for (feature, _, _), e, w in zip(rows, energy_vals, weight_vals): + lines.append(f"{feature:<{feature_w}} {e:>{energy_w}} {w:>{weight_w}}") else: - super().__init__( - array=array, - name=name, - origin=origin, - sampling=sampling, - units=units, - signal_units=signal_units, - _token=type(self)._token if _token is None else _token, - ) + header = f"{'Feature':<{feature_w}} {'Energy (' + unit + ')':>{energy_w}}" + lines = [ + f"{element} {'EELS edges' if source_norm == 'eels' else 'X-ray lines'}", + header, + "-" * len(header), + ] + for (feature, _, _), e in zip(rows, energy_vals): + lines.append(f"{feature:<{feature_w}} {e:>{energy_w}}") + + return "\n".join(lines) + + def format_xray_lines_table( + self, + element: str, + sort_by: str = "energy", + ascending: bool = True, + precision: int = 4, + ) -> str: + """Backward-compatible wrapper for X-ray lines.""" + return self.format_spectral_features_table( + element=element, + source="xray", + sort_by=sort_by, + ascending=ascending, + precision=precision, + ) - # Initialize model elements storage - self.model_elements = None - # Initialize spectra storage - self.attached_spectra = None + def format_eels_edges_table( + self, + element: str, + sort_by: str = "energy", + ascending: bool = True, + precision: int = 4, + ) -> str: + """Format EELS edge entries for one element.""" + return self.format_spectral_features_table( + element=element, + source="eels", + sort_by=sort_by, + ascending=ascending, + precision=precision, + ) + + def print_xray_lines( + self, + element: str, + sort_by: str = "energy", + ascending: bool = True, + precision: int = 4, + ) -> str: + """Print and return a formatted table of X-ray lines for one element.""" + table = self.format_xray_lines_table(element, sort_by, ascending, precision) + print(table) + return table + + def print_eels_edges( + self, + element: str, + sort_by: str = "energy", + ascending: bool = True, + precision: int = 4, + ) -> str: + """Print and return a formatted table of EELS edges for one element.""" + table = self.format_eels_edges_table(element, sort_by, ascending, precision) + print(table) + return table def add_elements_to_model(self, elements): """ diff --git a/src/quantem/spectroscopy/eels_binding_energies.json b/src/quantem/spectroscopy/eels_binding_energies.json new file mode 100644 index 00000000..05f21b36 --- /dev/null +++ b/src/quantem/spectroscopy/eels_binding_energies.json @@ -0,0 +1,3223 @@ +{ + "elements": { + "Ac": { + "L1": { + "edge": "", + "onset_energy (eV)": 19840.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "", + "onset_energy (eV)": 19083.0, + "relevance": "Minor", + "threshold": "" + }, + "L3": { + "edge": "", + "onset_energy (eV)": 15871.0, + "relevance": "Major", + "threshold": "" + }, + "M1": { + "edge": "", + "onset_energy (eV)": 5002.0, + "relevance": "Minor", + "threshold": "" + }, + "M2": { + "edge": "", + "onset_energy (eV)": 4656.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 3909.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "", + "onset_energy (eV)": 3370.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "", + "onset_energy (eV)": 3219.0, + "relevance": "Major", + "threshold": "" + }, + "N1": { + "edge": "", + "onset_energy (eV)": 1269.0, + "relevance": "Minor", + "threshold": "" + }, + "N2": { + "edge": "", + "onset_energy (eV)": 1080.0, + "relevance": "Minor", + "threshold": "" + }, + "N3": { + "edge": "", + "onset_energy (eV)": 890.0, + "relevance": "Minor", + "threshold": "" + }, + "N4": { + "edge": "", + "onset_energy (eV)": 675.0, + "relevance": "Minor", + "threshold": "" + } + }, + "Ag": { + "M2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 602.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 571.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 373.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 367.0, + "relevance": "Major", + "threshold": "" + } + }, + "Al": { + "K": { + "edge": "Abrupt onset", + "onset_energy (eV)": 1560.0, + "relevance": "Major", + "threshold": "" + }, + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 118.0, + "relevance": "Minor", + "threshold": "" + }, + "L2,3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 73.0, + "relevance": "Major", + "threshold": "Sharp peak" + } + }, + "Am": { + "L1": { + "edge": "", + "onset_energy (eV)": 23773.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "", + "onset_energy (eV)": 22944.0, + "relevance": "Minor", + "threshold": "" + }, + "L3": { + "edge": "", + "onset_energy (eV)": 18504.0, + "relevance": "Major", + "threshold": "" + }, + "M1": { + "edge": "", + "onset_energy (eV)": 6121.0, + "relevance": "Minor", + "threshold": "" + }, + "M2": { + "edge": "", + "onset_energy (eV)": 5710.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 4667.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "", + "onset_energy (eV)": 4092.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "", + "onset_energy (eV)": 3887.0, + "relevance": "Major", + "threshold": "" + }, + "N1": { + "edge": "", + "onset_energy (eV)": 1617.0, + "relevance": "Minor", + "threshold": "" + }, + "N2": { + "edge": "", + "onset_energy (eV)": 1412.0, + "relevance": "Minor", + "threshold": "" + }, + "N3": { + "edge": "", + "onset_energy (eV)": 1136.0, + "relevance": "Minor", + "threshold": "" + }, + "N4": { + "edge": "", + "onset_energy (eV)": 879.0, + "relevance": "Minor", + "threshold": "" + }, + "N5": { + "edge": "", + "onset_energy (eV)": 828.0, + "relevance": "Minor", + "threshold": "" + }, + "O4": { + "edge": "", + "onset_energy (eV)": 116.0, + "relevance": "Major", + "threshold": "" + }, + "O5": { + "edge": "", + "onset_energy (eV)": 103.0, + "relevance": "Major", + "threshold": "" + } + }, + "Ar": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 320.0, + "relevance": "Minor", + "threshold": "" + }, + "L2,3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 245.0, + "relevance": "Major", + "threshold": "" + } + }, + "As": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 1526.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1359.0, + "relevance": "Major", + "threshold": "" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1323.0, + "relevance": "Major", + "threshold": "" + } + }, + "At": { + "L1": { + "edge": "", + "onset_energy (eV)": 17493.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "", + "onset_energy (eV)": 16785.0, + "relevance": "Minor", + "threshold": "" + }, + "L3": { + "edge": "", + "onset_energy (eV)": 14214.0, + "relevance": "Major", + "threshold": "" + }, + "M1": { + "edge": "", + "onset_energy (eV)": 4317.0, + "relevance": "Minor", + "threshold": "" + }, + "M2": { + "edge": "", + "onset_energy (eV)": 4008.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 3426.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "", + "onset_energy (eV)": 2908.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "", + "onset_energy (eV)": 2787.0, + "relevance": "Major", + "threshold": "" + }, + "N1": { + "edge": "", + "onset_energy (eV)": 1042.0, + "relevance": "Minor", + "threshold": "" + }, + "N2": { + "edge": "", + "onset_energy (eV)": 886.0, + "relevance": "Minor", + "threshold": "" + }, + "N3": { + "edge": "", + "onset_energy (eV)": 740.0, + "relevance": "Minor", + "threshold": "" + }, + "N4": { + "edge": "", + "onset_energy (eV)": 879.0, + "relevance": "Minor", + "threshold": "" + }, + "N5": { + "edge": "", + "onset_energy (eV)": 533.0, + "relevance": "Minor", + "threshold": "" + } + }, + "Au": { + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2291.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2206.0, + "relevance": "Major", + "threshold": "" + } + }, + "B": { + "K": { + "edge": "Abrupt onset", + "onset_energy (eV)": 188.0, + "relevance": "Major", + "threshold": "" + } + }, + "Ba": { + "M2": { + "edge": "", + "onset_energy (eV)": 1137.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 1062.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 796.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 781.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "N4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 90.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "N5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 90.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Be": { + "K": { + "edge": "Abrupt onset", + "onset_energy (eV)": 111.0, + "relevance": "Major", + "threshold": "" + } + }, + "Bi": { + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2688.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2580.0, + "relevance": "Major", + "threshold": "" + } + }, + "Br": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 1782.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1596.0, + "relevance": "Major", + "threshold": "" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1550.0, + "relevance": "Major", + "threshold": "" + } + }, + "C": { + "K": { + "edge": "Abrupt onset", + "onset_energy (eV)": 284.0, + "relevance": "Major", + "threshold": "" + } + }, + "Ca": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 438.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 350.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 346.0, + "relevance": "Major", + "threshold": "Sharp peak" + } + }, + "Cd": { + "M2": { + "edge": "", + "onset_energy (eV)": 651.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 616.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 411.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 404.0, + "relevance": "Major", + "threshold": "" + } + }, + "Ce": { + "M2": { + "edge": "", + "onset_energy (eV)": 1273.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 1185.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 901.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 883.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "N4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 110.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "N5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 110.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Cl": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 270.0, + "relevance": "Minor", + "threshold": "" + }, + "L2,3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 200.0, + "relevance": "Major", + "threshold": "" + } + }, + "Co": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 926.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 794.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 779.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 59.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "M3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 59.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Cr": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 695.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 584.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 575.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 43.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "M3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 43.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Cs": { + "M2": { + "edge": "", + "onset_energy (eV)": 1065.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 998.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 740.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 726.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "N4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 78.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "N5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 78.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Cu": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 1096.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 951.0, + "relevance": "Major", + "threshold": "" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 931.0, + "relevance": "Major", + "threshold": "" + }, + "M2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 74.0, + "relevance": "Major", + "threshold": "" + }, + "M3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 74.0, + "relevance": "Major", + "threshold": "" + } + }, + "Dy": { + "M2": { + "edge": "", + "onset_energy (eV)": 1842.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 1676.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1332.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1295.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "N4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 154.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "N5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 154.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Er": { + "M2": { + "edge": "", + "onset_energy (eV)": 2006.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 1812.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1453.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1409.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "N4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 168.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "N5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 168.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Eu": { + "M2": { + "edge": "", + "onset_energy (eV)": 1614.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 1481.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1161.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1131.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "N4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 134.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "N5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 134.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "F": { + "K": { + "edge": "Abrupt onset", + "onset_energy (eV)": 685.0, + "relevance": "Major", + "threshold": "" + } + }, + "Fe": { + "K": { + "edge": "", + "onset_energy (eV)": 7113.0, + "relevance": "Minor", + "threshold": "" + }, + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 846.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 721.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 708.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 57.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "M3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 57.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Fr": { + "L1": { + "edge": "", + "onset_energy (eV)": 18639.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "", + "onset_energy (eV)": 17907.0, + "relevance": "Minor", + "threshold": "" + }, + "L3": { + "edge": "", + "onset_energy (eV)": 15031.0, + "relevance": "Major", + "threshold": "" + }, + "M1": { + "edge": "", + "onset_energy (eV)": 4652.0, + "relevance": "Minor", + "threshold": "" + }, + "M2": { + "edge": "", + "onset_energy (eV)": 4327.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 3663.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "", + "onset_energy (eV)": 3136.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "", + "onset_energy (eV)": 3000.0, + "relevance": "Major", + "threshold": "" + }, + "N1": { + "edge": "", + "onset_energy (eV)": 1153.0, + "relevance": "Minor", + "threshold": "" + }, + "N2": { + "edge": "", + "onset_energy (eV)": 980.0, + "relevance": "Minor", + "threshold": "" + }, + "N3": { + "edge": "", + "onset_energy (eV)": 810.0, + "relevance": "Minor", + "threshold": "" + }, + "N4": { + "edge": "", + "onset_energy (eV)": 603.0, + "relevance": "Minor", + "threshold": "" + }, + "N5": { + "edge": "", + "onset_energy (eV)": 577.0, + "relevance": "Minor", + "threshold": "" + } + }, + "Ga": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 1298.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1142.0, + "relevance": "Major", + "threshold": "" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1115.0, + "relevance": "Major", + "threshold": "" + } + }, + "Gd": { + "M2": { + "edge": "", + "onset_energy (eV)": 1688.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 1544.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1217.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1185.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "N4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 141.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "N5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 141.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Ge": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 1414.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1248.0, + "relevance": "Major", + "threshold": "" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1217.0, + "relevance": "Major", + "threshold": "" + } + }, + "H": { + "K": { + "edge": "Abrupt onset", + "onset_energy (eV)": 13.598, + "relevance": "Major", + "threshold": "" + } + }, + "He": { + "K": { + "edge": "Abrupt onset", + "onset_energy (eV)": 24.587, + "relevance": "Major", + "threshold": "" + } + }, + "Hf": { + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1716.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1662.0, + "relevance": "Major", + "threshold": "" + } + }, + "Hg": { + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2385.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2295.0, + "relevance": "Major", + "threshold": "" + } + }, + "Ho": { + "M2": { + "edge": "", + "onset_energy (eV)": 1923.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 1741.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1391.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1351.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "N4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 161.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "N5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 161.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "I": { + "M2": { + "edge": "", + "onset_energy (eV)": 930.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 875.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 631.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 620.0, + "relevance": "Major", + "threshold": "" + } + }, + "In": { + "M2": { + "edge": "", + "onset_energy (eV)": 702.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 664.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 451.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 443.0, + "relevance": "Major", + "threshold": "" + } + }, + "Ir": { + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2116.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2040.0, + "relevance": "Major", + "threshold": "" + } + }, + "K": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 377.0, + "relevance": "Minor", + "threshold": "" + }, + "L1a": { + "edge": "Abrupt onset", + "onset_energy (eV)": 377.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 296.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 294.0, + "relevance": "Major", + "threshold": "Sharp peak" + } + }, + "Kr": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 1921.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1727.0, + "relevance": "Major", + "threshold": "" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1675.0, + "relevance": "Major", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 89.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 89.0, + "relevance": "Major", + "threshold": "Sharp peak" + } + }, + "La": { + "M2": { + "edge": "", + "onset_energy (eV)": 1204.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 1123.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 849.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 832.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "N4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 99.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "N5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 99.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Li": { + "K": { + "edge": "Abrupt onset", + "onset_energy (eV)": 55.0, + "relevance": "Major", + "threshold": "" + } + }, + "Lu": { + "M2": { + "edge": "", + "onset_energy (eV)": 2263.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 2024.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1639.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1588.0, + "relevance": "Major", + "threshold": "" + }, + "N4": { + "edge": "Very delayed", + "onset_energy (eV)": 195.0, + "relevance": "Major", + "threshold": "" + }, + "N5": { + "edge": "Very delayed", + "onset_energy (eV)": 195.0, + "relevance": "Major", + "threshold": "" + } + }, + "Mg": { + "K": { + "edge": "Abrupt onset", + "onset_energy (eV)": 1305.0, + "relevance": "Major", + "threshold": "" + }, + "L1": { + "edge": "", + "onset_energy (eV)": 89.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 51.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 51.0, + "relevance": "Major", + "threshold": "Sharp peak" + } + }, + "Mn": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 769.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 651.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 640.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 51.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "M3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 51.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Mo": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 2866.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2625.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2520.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M2": { + "edge": "", + "onset_energy (eV)": 410.0, + "relevance": "Minor", + "threshold": "Sharp peak" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 392.0, + "relevance": "Minor", + "threshold": "Sharp peak" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 228.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 227.0, + "relevance": "Major", + "threshold": "" + } + }, + "N": { + "K": { + "edge": "Abrupt onset", + "onset_energy (eV)": 401.0, + "relevance": "Major", + "threshold": "" + } + }, + "Na": { + "K": { + "edge": "Abrupt onset", + "onset_energy (eV)": 1072.0, + "relevance": "Major", + "threshold": "" + }, + "L1": { + "edge": "", + "onset_energy (eV)": 63.0, + "relevance": "Minor", + "threshold": "" + }, + "L2,3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 31.0, + "relevance": "Major", + "threshold": "Sharp peak" + } + }, + "Nb": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 2698.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2465.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2371.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M2": { + "edge": "", + "onset_energy (eV)": 378.0, + "relevance": "Minor", + "threshold": "Sharp peak" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 363.0, + "relevance": "Minor", + "threshold": "Sharp peak" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 205.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 202.3, + "relevance": "Major", + "threshold": "" + } + }, + "Nd": { + "M2": { + "edge": "", + "onset_energy (eV)": 1403.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 1297.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1000.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 978.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "N4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 118.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "N5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 118.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Ne": { + "K": { + "edge": "Abrupt onset", + "onset_energy (eV)": 867.0, + "relevance": "Major", + "threshold": "" + } + }, + "Ni": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 1008.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 872.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 855.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 68.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "M3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 68.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Np": { + "L1": { + "edge": "", + "onset_energy (eV)": 22427.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "", + "onset_energy (eV)": 21601.0, + "relevance": "Minor", + "threshold": "" + }, + "L3": { + "edge": "", + "onset_energy (eV)": 17610.0, + "relevance": "Major", + "threshold": "" + }, + "M1": { + "edge": "", + "onset_energy (eV)": 5723.0, + "relevance": "Minor", + "threshold": "" + }, + "M2": { + "edge": "", + "onset_energy (eV)": 5366.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 4435.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "", + "onset_energy (eV)": 3850.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "", + "onset_energy (eV)": 3666.0, + "relevance": "Major", + "threshold": "" + }, + "N1": { + "edge": "", + "onset_energy (eV)": 1501.0, + "relevance": "Minor", + "threshold": "" + }, + "N2": { + "edge": "", + "onset_energy (eV)": 1328.0, + "relevance": "Minor", + "threshold": "" + }, + "N3": { + "edge": "", + "onset_energy (eV)": 1087.0, + "relevance": "Minor", + "threshold": "" + }, + "N4": { + "edge": "", + "onset_energy (eV)": 816.0, + "relevance": "Minor", + "threshold": "" + }, + "N5": { + "edge": "", + "onset_energy (eV)": 770.0, + "relevance": "Minor", + "threshold": "" + }, + "N6": { + "edge": "", + "onset_energy (eV)": 415.0, + "relevance": "Major", + "threshold": "" + }, + "N7": { + "edge": "", + "onset_energy (eV)": 404.0, + "relevance": "Major", + "threshold": "" + }, + "O1": { + "edge": "", + "onset_energy (eV)": 206.0, + "relevance": "Minor", + "threshold": "" + }, + "O2": { + "edge": "", + "onset_energy (eV)": 283.0, + "relevance": "Minor", + "threshold": "" + }, + "O4": { + "edge": "", + "onset_energy (eV)": 109.0, + "relevance": "Major", + "threshold": "" + }, + "O5": { + "edge": "", + "onset_energy (eV)": 101.0, + "relevance": "Major", + "threshold": "" + } + }, + "O": { + "K": { + "edge": "Abrupt onset", + "onset_energy (eV)": 532.0, + "relevance": "Major", + "threshold": "" + } + }, + "Os": { + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2031.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1960.0, + "relevance": "Major", + "threshold": "" + } + }, + "P": { + "K": { + "edge": "Abrupt onset", + "onset_energy (eV)": 2146.0, + "relevance": "Major", + "threshold": "" + }, + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 189.0, + "relevance": "Minor", + "threshold": "" + }, + "L2,3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 132.0, + "relevance": "Major", + "threshold": "Sharp peak" + } + }, + "Pa": { + "L1": { + "edge": "", + "onset_energy (eV)": 21105.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "", + "onset_energy (eV)": 20314.0, + "relevance": "Minor", + "threshold": "" + }, + "L3": { + "edge": "", + "onset_energy (eV)": 16733.0, + "relevance": "Major", + "threshold": "" + }, + "M1": { + "edge": "", + "onset_energy (eV)": 5367.0, + "relevance": "Minor", + "threshold": "" + }, + "M2": { + "edge": "", + "onset_energy (eV)": 5001.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 4174.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "", + "onset_energy (eV)": 3611.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "", + "onset_energy (eV)": 3442.0, + "relevance": "Major", + "threshold": "" + }, + "N1": { + "edge": "", + "onset_energy (eV)": 1387.0, + "relevance": "Minor", + "threshold": "" + }, + "N2": { + "edge": "", + "onset_energy (eV)": 1224.0, + "relevance": "Minor", + "threshold": "" + }, + "N3": { + "edge": "", + "onset_energy (eV)": 1007.0, + "relevance": "Minor", + "threshold": "" + }, + "N4": { + "edge": "", + "onset_energy (eV)": 743.0, + "relevance": "Minor", + "threshold": "" + }, + "N5": { + "edge": "", + "onset_energy (eV)": 708.0, + "relevance": "Minor", + "threshold": "" + }, + "N6": { + "edge": "", + "onset_energy (eV)": 371.0, + "relevance": "Major", + "threshold": "" + }, + "N7": { + "edge": "", + "onset_energy (eV)": 360.0, + "relevance": "Major", + "threshold": "" + }, + "O1": { + "edge": "", + "onset_energy (eV)": 223.0, + "relevance": "Minor", + "threshold": "" + }, + "O2": { + "edge": "", + "onset_energy (eV)": 310.0, + "relevance": "Minor", + "threshold": "" + }, + "O4": { + "edge": "", + "onset_energy (eV)": 94.0, + "relevance": "Major", + "threshold": "" + }, + "O5": { + "edge": "", + "onset_energy (eV)": 94.0, + "relevance": "Major", + "threshold": "" + } + }, + "Pb": { + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2586.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2484.0, + "relevance": "Major", + "threshold": "" + } + }, + "Pd": { + "M2": { + "edge": "", + "onset_energy (eV)": 559.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 531.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 340.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 335.0, + "relevance": "Major", + "threshold": "" + } + }, + "Pm": { + "L1": { + "edge": "", + "onset_energy (eV)": 7428.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "", + "onset_energy (eV)": 7013.0, + "relevance": "Minor", + "threshold": "" + }, + "L3": { + "edge": "", + "onset_energy (eV)": 6459.0, + "relevance": "Major", + "threshold": "" + }, + "M1": { + "edge": "Abrupt Onset", + "onset_energy (eV)": 1646.0, + "relevance": "Minor", + "threshold": "" + }, + "M2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1471.0, + "relevance": "Minor", + "threshold": "Sharp Peak" + }, + "M3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1357.0, + "relevance": "Minor", + "threshold": "Sharp Peak" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1052.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1027.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "N1": { + "edge": "Abrupt Onset", + "onset_energy (eV)": 330.0, + "relevance": "Minor", + "threshold": "" + }, + "N2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 242.0, + "relevance": "Minor", + "threshold": "Sharp Peak" + }, + "N4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 120.0, + "relevance": "Minor", + "threshold": "Broad peak" + }, + "N5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 121.0, + "relevance": "Minor", + "threshold": "Broad peak" + }, + "O2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 24.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "O3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 24.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Po": { + "L1": { + "edge": "", + "onset_energy (eV)": 16939.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "", + "onset_energy (eV)": 16244.0, + "relevance": "Minor", + "threshold": "" + }, + "L3": { + "edge": "", + "onset_energy (eV)": 13814.0, + "relevance": "Major", + "threshold": "" + }, + "M1": { + "edge": "", + "onset_energy (eV)": 4149.0, + "relevance": "Minor", + "threshold": "" + }, + "M2": { + "edge": "", + "onset_energy (eV)": 3854.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 3302.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "", + "onset_energy (eV)": 2798.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "", + "onset_energy (eV)": 2683.0, + "relevance": "Major", + "threshold": "" + }, + "N1": { + "edge": "", + "onset_energy (eV)": 995.0, + "relevance": "Minor", + "threshold": "" + }, + "N2": { + "edge": "", + "onset_energy (eV)": 851.0, + "relevance": "Minor", + "threshold": "" + }, + "O4": { + "edge": "", + "onset_energy (eV)": 31.0, + "relevance": "Major", + "threshold": "" + }, + "O5": { + "edge": "", + "onset_energy (eV)": 31.0, + "relevance": "Major", + "threshold": "" + } + }, + "Pr": { + "M2": { + "edge": "", + "onset_energy (eV)": 1337.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 1242.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 951.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 931.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "N4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 114.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "N5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 114.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Pt": { + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2202.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2122.0, + "relevance": "Major", + "threshold": "" + } + }, + "Pu": { + "L1": { + "edge": "", + "onset_energy (eV)": 23097.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "", + "onset_energy (eV)": 22266.0, + "relevance": "Minor", + "threshold": "" + }, + "L3": { + "edge": "", + "onset_energy (eV)": 18057.0, + "relevance": "Major", + "threshold": "" + }, + "M1": { + "edge": "", + "onset_energy (eV)": 5933.0, + "relevance": "Minor", + "threshold": "" + }, + "M2": { + "edge": "", + "onset_energy (eV)": 5541.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 4557.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "", + "onset_energy (eV)": 3973.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "", + "onset_energy (eV)": 3778.0, + "relevance": "Major", + "threshold": "" + }, + "N1": { + "edge": "", + "onset_energy (eV)": 1559.0, + "relevance": "Minor", + "threshold": "" + }, + "N2": { + "edge": "", + "onset_energy (eV)": 1372.0, + "relevance": "Minor", + "threshold": "" + }, + "N3": { + "edge": "", + "onset_energy (eV)": 1115.0, + "relevance": "Minor", + "threshold": "" + }, + "N4": { + "edge": "", + "onset_energy (eV)": 849.0, + "relevance": "Minor", + "threshold": "" + }, + "N5": { + "edge": "", + "onset_energy (eV)": 801.0, + "relevance": "Minor", + "threshold": "" + }, + "N6": { + "edge": "", + "onset_energy (eV)": 446.0, + "relevance": "Major", + "threshold": "" + }, + "N7": { + "edge": "", + "onset_energy (eV)": 432.0, + "relevance": "Major", + "threshold": "" + }, + "O1": { + "edge": "", + "onset_energy (eV)": 352.0, + "relevance": "Minor", + "threshold": "" + }, + "O2": { + "edge": "", + "onset_energy (eV)": 274.0, + "relevance": "Minor", + "threshold": "" + }, + "O3": { + "edge": "", + "onset_energy (eV)": 207.0, + "relevance": "Minor", + "threshold": "" + }, + "O4": { + "edge": "", + "onset_energy (eV)": 116.0, + "relevance": "Major", + "threshold": "" + }, + "O5": { + "edge": "", + "onset_energy (eV)": 105.0, + "relevance": "Major", + "threshold": "" + } + }, + "Ra": { + "L1": { + "edge": "", + "onset_energy (eV)": 19237.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "", + "onset_energy (eV)": 18484.0, + "relevance": "Minor", + "threshold": "" + }, + "L3": { + "edge": "", + "onset_energy (eV)": 15444.0, + "relevance": "Major", + "threshold": "" + }, + "M1": { + "edge": "", + "onset_energy (eV)": 4822.0, + "relevance": "Minor", + "threshold": "" + }, + "M2": { + "edge": "", + "onset_energy (eV)": 4490.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 3792.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "", + "onset_energy (eV)": 3248.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "", + "onset_energy (eV)": 3105.0, + "relevance": "Major", + "threshold": "" + }, + "N1": { + "edge": "", + "onset_energy (eV)": 1208.0, + "relevance": "Minor", + "threshold": "" + }, + "N2": { + "edge": "", + "onset_energy (eV)": 1058.0, + "relevance": "Minor", + "threshold": "" + }, + "N3": { + "edge": "", + "onset_energy (eV)": 879.0, + "relevance": "Minor", + "threshold": "" + }, + "N4": { + "edge": "", + "onset_energy (eV)": 636.0, + "relevance": "Minor", + "threshold": "" + }, + "N5": { + "edge": "", + "onset_energy (eV)": 603.0, + "relevance": "Minor", + "threshold": "" + }, + "N6": { + "edge": "", + "onset_energy (eV)": 299.0, + "relevance": "Major", + "threshold": "" + }, + "N7": { + "edge": "", + "onset_energy (eV)": 299.0, + "relevance": "Major", + "threshold": "" + }, + "O1": { + "edge": "", + "onset_energy (eV)": 254.0, + "relevance": "Minor", + "threshold": "" + }, + "O2": { + "edge": "", + "onset_energy (eV)": 254.0, + "relevance": "Minor", + "threshold": "" + }, + "O3": { + "edge": "", + "onset_energy (eV)": 153.0, + "relevance": "Minor", + "threshold": "" + }, + "O4": { + "edge": "", + "onset_energy (eV)": 67.0, + "relevance": "Major", + "threshold": "" + }, + "O5": { + "edge": "", + "onset_energy (eV)": 67.0, + "relevance": "Major", + "threshold": "" + } + }, + "Rb": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 2065.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1864.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1804.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 247.0, + "relevance": "Minor", + "threshold": "Sharp peak" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 238.0, + "relevance": "Minor", + "threshold": "Sharp peak" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 110.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 110.0, + "relevance": "Major", + "threshold": "" + } + }, + "Re": { + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1949.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1883.0, + "relevance": "Major", + "threshold": "" + } + }, + "Rh": { + "M2": { + "edge": "", + "onset_energy (eV)": 521.0, + "relevance": "Minor", + "threshold": "Sharp peak" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 496.0, + "relevance": "Minor", + "threshold": "Sharp peak" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 312.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 308.0, + "relevance": "Major", + "threshold": "" + } + }, + "Rn": { + "L1": { + "edge": "", + "onset_energy (eV)": 18049.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "", + "onset_energy (eV)": 17337.0, + "relevance": "Minor", + "threshold": "" + }, + "L3": { + "edge": "", + "onset_energy (eV)": 14619.0, + "relevance": "Major", + "threshold": "" + }, + "M1": { + "edge": "", + "onset_energy (eV)": 4482.0, + "relevance": "Minor", + "threshold": "" + }, + "M2": { + "edge": "", + "onset_energy (eV)": 4159.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 3538.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "", + "onset_energy (eV)": 3022.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "", + "onset_energy (eV)": 2892.0, + "relevance": "Major", + "threshold": "" + }, + "N1": { + "edge": "", + "onset_energy (eV)": 1097.0, + "relevance": "Minor", + "threshold": "" + }, + "N2": { + "edge": "", + "onset_energy (eV)": 929.0, + "relevance": "Minor", + "threshold": "" + }, + "N3": { + "edge": "", + "onset_energy (eV)": 768.0, + "relevance": "Minor", + "threshold": "" + }, + "N4": { + "edge": "", + "onset_energy (eV)": 567.0, + "relevance": "Minor", + "threshold": "" + } + }, + "Ru": { + "M2": { + "edge": "", + "onset_energy (eV)": 483.0, + "relevance": "Minor", + "threshold": "Sharp peak" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 461.0, + "relevance": "Minor", + "threshold": "Sharp peak" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 279.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 279.0, + "relevance": "Major", + "threshold": "" + } + }, + "S": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 229.0, + "relevance": "Minor", + "threshold": "" + }, + "L2,3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 165.0, + "relevance": "Major", + "threshold": "" + } + }, + "Sb": { + "M2": { + "edge": "", + "onset_energy (eV)": 812.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 766.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 537.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 528.0, + "relevance": "Major", + "threshold": "" + } + }, + "Sc": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 500.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 407.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 402.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 32.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "M3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 32.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Se": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 1654.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1476.0, + "relevance": "Major", + "threshold": "" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1436.0, + "relevance": "Major", + "threshold": "" + } + }, + "Si": { + "K": { + "edge": "Abrupt onset", + "onset_energy (eV)": 1839.0, + "relevance": "Major", + "threshold": "" + }, + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 149.7, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 99.8, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 99.2, + "relevance": "Major", + "threshold": "Sharp peak" + } + }, + "Sm": { + "M2": { + "edge": "", + "onset_energy (eV)": 1541.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 1420.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1106.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1080.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "N4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 130.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "N5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 130.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Sn": { + "M2": { + "edge": "", + "onset_energy (eV)": 756.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 714.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 494.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 485.0, + "relevance": "Major", + "threshold": "" + } + }, + "Sr": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 2216.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2007.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1940.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M2": { + "edge": "", + "onset_energy (eV)": 280.0, + "relevance": "Minor", + "threshold": "Sharp peak" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 269.0, + "relevance": "Minor", + "threshold": "Sharp peak" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 134.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 134.0, + "relevance": "Major", + "threshold": "" + } + }, + "Ta": { + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1793.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1735.0, + "relevance": "Major", + "threshold": "" + } + }, + "Tb": { + "M2": { + "edge": "", + "onset_energy (eV)": 1768.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 1611.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1275.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1241.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "N4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 148.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "N5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 148.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Tc": { + "K": { + "edge": "", + "onset_energy (eV)": 21044.0, + "relevance": "Major", + "threshold": "" + }, + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 3043.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2793.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2677.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 544.0, + "relevance": "Minor", + "threshold": "" + }, + "M2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 445.0, + "relevance": "Minor", + "threshold": "Sharp peak" + }, + "M3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 425.0, + "relevance": "Minor", + "threshold": "Sharp peak" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 256.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 253.0, + "relevance": "Major", + "threshold": "" + }, + "N1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 68.0, + "relevance": "Minor", + "threshold": "" + }, + "N2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 39.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "N3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 39.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Te": { + "M2": { + "edge": "", + "onset_energy (eV)": 870.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 819.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 582.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 572.0, + "relevance": "Major", + "threshold": "" + } + }, + "Th": { + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 3491.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 3332.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "O4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 83.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "O5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 83.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Ti": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 564.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 462.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 456.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 35.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "M3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 35.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Tl": { + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2485.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2389.0, + "relevance": "Major", + "threshold": "" + } + }, + "Tm": { + "M2": { + "edge": "", + "onset_energy (eV)": 2090.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 1884.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1515.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1468.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "N4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 180.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "N5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 180.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "U": { + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 3728.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 3552.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "O4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 96.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "O5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 96.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "V": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 628.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 521.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 513.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 38.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "M3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 38.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "W": { + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1872.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1809.0, + "relevance": "Major", + "threshold": "" + } + }, + "Xe": { + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 685.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 672.0, + "relevance": "Major", + "threshold": "" + } + }, + "Y": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 2372.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2155.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2080.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M2": { + "edge": "", + "onset_energy (eV)": 312.0, + "relevance": "Minor", + "threshold": "Sharp peak" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 300.0, + "relevance": "Minor", + "threshold": "Sharp peak" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 160.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 160.0, + "relevance": "Major", + "threshold": "" + } + }, + "Yb": { + "M2": { + "edge": "", + "onset_energy (eV)": 2173.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 1950.0, + "relevance": "Minor", + "threshold": "" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1576.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1528.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "N4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 184.0, + "relevance": "Major", + "threshold": "Broad peak" + }, + "N5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 184.0, + "relevance": "Major", + "threshold": "Broad peak" + } + }, + "Zn": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 1194.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1043.0, + "relevance": "Major", + "threshold": "" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 1020.0, + "relevance": "Major", + "threshold": "" + }, + "M2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 87.0, + "relevance": "Minor", + "threshold": "" + }, + "M3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 87.0, + "relevance": "Minor", + "threshold": "" + } + }, + "Zr": { + "L1": { + "edge": "Abrupt onset", + "onset_energy (eV)": 2532.0, + "relevance": "Minor", + "threshold": "" + }, + "L2": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2307.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "L3": { + "edge": "Delayed maximum", + "onset_energy (eV)": 2222.0, + "relevance": "Major", + "threshold": "Sharp peak" + }, + "M2": { + "edge": "", + "onset_energy (eV)": 344.0, + "relevance": "Minor", + "threshold": "Sharp peak" + }, + "M3": { + "edge": "", + "onset_energy (eV)": 330.0, + "relevance": "Minor", + "threshold": "Sharp peak" + }, + "M4": { + "edge": "Delayed maximum", + "onset_energy (eV)": 181.0, + "relevance": "Major", + "threshold": "" + }, + "M5": { + "edge": "Delayed maximum", + "onset_energy (eV)": 181.0, + "relevance": "Major", + "threshold": "" + } + } + }, + "metadata": { + "last_updated": "2025-06-04", + "units": "eV", + "version": "1.0" + } +} From 89b93ce730fe5a91a0d42225f81a761a086a3b2b Mon Sep 17 00:00:00 2001 From: smribet Date: Sun, 8 Feb 2026 06:50:17 -0800 Subject: [PATCH 037/136] lr flexibility --- src/quantem/spectroscopy/dataset3deds.py | 35 +++++++++++++++++++----- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 4fa7c7a9..26e1b81c 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -89,6 +89,7 @@ def _fit_mean_model_pytorch( normalize_target, default_lr_adam, default_lr_lbfgs, + verbose=False, ): target = spectrum_raw spectrum_offset = torch.tensor(0.0, dtype=spectrum_raw.dtype, device=spectrum_raw.device) @@ -125,7 +126,7 @@ def _fit_mean_model_pytorch( raise ValueError("optimizer must be 'lbfgs' or 'adam'") loss_iter = [] - for _ in range(num_iters): + for i in range(num_iters): if optimizer_name == "lbfgs": def closure(): @@ -147,6 +148,8 @@ def closure(): optimizer_obj.step() loss_iter.append(float(loss.detach().cpu().item())) + if verbose and ((i + 1) % max(1, num_iters // 10) == 0 or i == 0): + print(f"iter {i + 1:4d}/{num_iters}: loss={loss_iter[-1]:.6g}") with torch.no_grad(): final_pred_target = model() @@ -203,6 +206,8 @@ def fit_spectrum_pytorch( verbose=True, fit_mean_only=False, show_plot=True, + lr_global=None, + lr_local=None, ): """Fit EDS spectra with one entrypoint for mean-only or full-cube fitting. @@ -219,12 +224,17 @@ def fit_spectrum_pytorch( num_iters_global : int, optional Number of mean-spectrum iterations used to initialize local fitting (3D mode). lr : float, optional - Learning rate. If None, sensible mode-specific defaults are used. + Backward-compatible shared learning rate fallback. Used for global/local + fitting only when lr_global/lr_local are not provided. + lr_global : float, optional + Learning rate for the global mean-spectrum stage. In mean-only mode, this + is the learning rate used for that fit. + lr_local : float, optional + Learning rate for the position-by-position stage (3D mode). polynomial_background_degree : int, optional Degree of per-pixel polynomial background. optimizer : str | None, optional - Optimizer, "adam" or "lbfgs". If None, defaults to "lbfgs" for mean-only - and "adam" for 3D fitting. + Optimizer, "adam" or "lbfgs". If None, defaults to "lbfgs". loss : str | None, optional Data term, "poisson" or "mse". If None, defaults to "mse" for mean-only and "poisson" for 3D fitting. @@ -263,6 +273,9 @@ def fit_spectrum_pytorch( if spatial_lambda < 0: raise ValueError("spatial_lambda must be >= 0") + effective_lr_global = lr if lr_global is None else lr_global + effective_lr_local = lr if lr_local is None else lr_local + energy_axis_np = np.arange(self.shape[0]) * self.sampling[0] + self.origin[0] energy_axis = torch.tensor(energy_axis_np, dtype=torch.float32) spectra = torch.tensor(self.array, dtype=torch.float32) # (E, Y, X) @@ -275,6 +288,8 @@ def fit_spectrum_pytorch( energy_range = [float(energy_axis.min().numpy()), float(energy_axis.max().numpy())] if fit_mean_only: + if verbose: + print("fitting spectrum globally") spectrum_raw = spectra.sum((-1, -2)) mean_fit = self._fit_mean_model_pytorch( energy_axis=energy_axis, @@ -284,11 +299,12 @@ def fit_spectrum_pytorch( polynomial_background_degree=polynomial_background_degree, num_iters=num_iters, optimizer=optimizer_name, - lr=lr, + lr=effective_lr_global, loss_name=loss_name, normalize_target=True, default_lr_adam=1e-3, default_lr_lbfgs=1.0, + verbose=verbose, ) model = mean_fit["model"] @@ -368,6 +384,8 @@ def fit_spectrum_pytorch( mean_spectrum = spectra_flat[valid_pixel_mask].mean(dim=0) # Stage 1: global mean-spectrum fit to initialize per-pixel parameters. + if verbose: + print("fitting spectrum globally") global_fit = self._fit_mean_model_pytorch( energy_axis=energy_axis, spectrum_raw=mean_spectrum, @@ -376,11 +394,12 @@ def fit_spectrum_pytorch( polynomial_background_degree=polynomial_background_degree, num_iters=num_iters_global, optimizer="lbfgs", - lr=1.0, + lr=effective_lr_global, loss_name=loss_name, normalize_target=False, default_lr_adam=1e-3, default_lr_lbfgs=1.0, + verbose=verbose, ) global_model = global_fit["model"] global_loss_history = global_fit["loss_history"] @@ -427,7 +446,7 @@ def fit_spectrum_pytorch( if not freeze_peak_width: trainable_params.append(peak_width_params) - local_lr = lr + local_lr = effective_lr_local if local_lr is None: local_lr = 0.05 if optimizer_name == "adam" else 1.0 @@ -478,6 +497,8 @@ def _local_loss(pred_local, conc_local): loss_smooth = abundance_smoothness_l2(conc_maps) return loss_data + spatial_lambda * loss_smooth + if verbose: + print("fitting spectrum position-by-position") for i in range(num_iters): if optimizer_name == "lbfgs": From 69006b1307cf993bf4a7f1e05f3cb9bfc06ae9a0 Mon Sep 17 00:00:00 2001 From: smribet Date: Mon, 9 Feb 2026 05:34:53 -0800 Subject: [PATCH 038/136] normalization fixes --- src/quantem/spectroscopy/dataset3deds.py | 130 ++++++++++++++---- .../spectroscopy/spectroscopy_models.py | 25 +++- 2 files changed, 122 insertions(+), 33 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 26e1b81c..138e1752 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -200,6 +200,11 @@ def fit_spectrum_pytorch( polynomial_background_degree=3, optimizer=None, loss=None, + optimizer_global=None, + optimizer_local=None, + loss_global=None, + loss_local=None, + normalize_local_target=True, freeze_peak_width=True, spatial_lambda=0.0, min_total_counts=0.0, @@ -234,10 +239,24 @@ def fit_spectrum_pytorch( polynomial_background_degree : int, optional Degree of per-pixel polynomial background. optimizer : str | None, optional - Optimizer, "adam" or "lbfgs". If None, defaults to "lbfgs". + Backward-compatible optimizer selector. In mean-only mode it controls + the global stage. In 3D mode it controls the local stage. + optimizer_global : str | None, optional + Global/mean-stage optimizer, "adam" or "lbfgs". In 3D mode, defaults + to "lbfgs" unless explicitly set. + optimizer_local : str | None, optional + Local position-by-position optimizer, "adam" or "lbfgs". In 3D mode, + defaults to optimizer if provided, otherwise "lbfgs". loss : str | None, optional - Data term, "poisson" or "mse". If None, defaults to "mse" for mean-only - and "poisson" for 3D fitting. + Backward-compatible shared data term, "poisson" or "mse". If provided, + it applies to both stages unless stage-specific losses are set. + loss_global : str | None, optional + Global/mean-stage data term, "poisson" or "mse". + loss_local : str | None, optional + Local position-by-position data term, "poisson" or "mse". + normalize_local_target : bool, optional + If True, normalize each local pixel spectrum by its own target scale + (max channel value) before evaluating the local data loss. freeze_peak_width : bool, optional If True, lock peak widths after global fit (3D mode). spatial_lambda : float, optional @@ -257,21 +276,47 @@ def fit_spectrum_pytorch( Mean-only mode keys include concentrations, fit, and diagnostics. 3D mode keys include abundance maps and fit diagnostics. """ - if optimizer is None: - optimizer = "lbfgs" - if loss is None: - loss = "mse" if fit_mean_only else "poisson" - optimizer_name = optimizer.lower() - if optimizer_name not in {"adam", "lbfgs"}: - raise ValueError("optimizer must be 'adam' or 'lbfgs'") + def _normalize_optimizer(name, param_name): + if name is None: + return None + name_norm = name.lower() + if name_norm not in {"adam", "lbfgs"}: + raise ValueError(f"{param_name} must be 'adam' or 'lbfgs'") + return name_norm + + def _normalize_loss(name, param_name): + if name is None: + return None + name_norm = name.lower() + if name_norm not in {"poisson", "mse"}: + raise ValueError(f"{param_name} must be 'poisson' or 'mse'") + return name_norm + + optimizer_name = _normalize_optimizer(optimizer, "optimizer") + optimizer_global_name = _normalize_optimizer(optimizer_global, "optimizer_global") + optimizer_local_name = _normalize_optimizer(optimizer_local, "optimizer_local") + + loss_name = _normalize_loss(loss, "loss") + loss_global_name = _normalize_loss(loss_global, "loss_global") + loss_local_name = _normalize_loss(loss_local, "loss_local") - loss_name = loss.lower() - if loss_name not in {"poisson", "mse"}: - raise ValueError("loss must be 'poisson' or 'mse'") + if fit_mean_only: + effective_optimizer_global = optimizer_global_name or optimizer_name or "lbfgs" + effective_loss_global = loss_global_name or loss_name or "mse" + effective_optimizer_local = None + effective_loss_local = None + else: + # Preserve historical behavior: global stage defaults to LBFGS. + effective_optimizer_global = optimizer_global_name or "lbfgs" + effective_optimizer_local = optimizer_local_name or optimizer_name or "lbfgs" + effective_loss_global = loss_global_name or loss_name or "poisson" + effective_loss_local = loss_local_name or loss_name or "poisson" if spatial_lambda < 0: raise ValueError("spatial_lambda must be >= 0") + if not isinstance(normalize_local_target, bool): + raise ValueError("normalize_local_target must be a bool") effective_lr_global = lr if lr_global is None else lr_global effective_lr_local = lr if lr_local is None else lr_local @@ -298,9 +343,9 @@ def fit_spectrum_pytorch( peak_width=peak_width, polynomial_background_degree=polynomial_background_degree, num_iters=num_iters, - optimizer=optimizer_name, + optimizer=effective_optimizer_global, lr=effective_lr_global, - loss_name=loss_name, + loss_name=effective_loss_global, normalize_target=True, default_lr_adam=1e-3, default_lr_lbfgs=1.0, @@ -393,20 +438,30 @@ def fit_spectrum_pytorch( peak_width=peak_width, polynomial_background_degree=polynomial_background_degree, num_iters=num_iters_global, - optimizer="lbfgs", + optimizer=effective_optimizer_global, lr=effective_lr_global, - loss_name=loss_name, - normalize_target=False, + loss_name=effective_loss_global, + normalize_target=True, default_lr_adam=1e-3, default_lr_lbfgs=1.0, verbose=verbose, ) global_model = global_fit["model"] global_loss_history = global_fit["loss_history"] + global_scale = global_fit["spectrum_scale"].detach() + global_offset = global_fit["spectrum_offset"].detach() with torch.no_grad(): - global_conc = nn.functional.softplus(global_model.peak_model.concentrations).detach() - global_bg_coeffs = global_model.background_model.coeffs.detach() + # If the global stage fit a normalized target, convert amplitude-like + # parameters back to raw-count scale for local initialization. + global_conc = ( + nn.functional.softplus(global_model.peak_model.concentrations).detach() + * global_scale + ) + global_bg_coeffs = global_model.background_model.coeffs.detach() * global_scale + if global_bg_coeffs.numel() > 0: + global_bg_coeffs = global_bg_coeffs.clone() + global_bg_coeffs[0] = global_bg_coeffs[0] + global_offset global_peak_width_params = global_model.peak_model.peak_width_by_peak.detach().clone() # Stage 2: vectorized per-pixel fit with shared peak shapes. @@ -422,7 +477,13 @@ def fit_spectrum_pytorch( mean_total = torch.clamp(mean_spectrum.sum(), min=1e-8) pixel_scales = (total_counts / mean_total).unsqueeze(1) - conc_init = torch.clamp(global_conc.unsqueeze(0) * pixel_scales, min=1e-6) + if normalize_local_target: + # When local loss is normalized per pixel, amplitude scaling from total + # counts is no longer part of the objective and can destabilize LBFGS. + pixel_scales = torch.ones_like(pixel_scales) + # Avoid near-zero concentration initialization that can cause vanishing + # softplus gradients in local optimization (especially on normalized data). + conc_init = torch.clamp(global_conc.unsqueeze(0) * pixel_scales, min=1e-3, max=50.0) conc_logits = nn.Parameter(inverse_softplus(conc_init)) bg_coeffs = nn.Parameter(global_bg_coeffs.unsqueeze(0).repeat(n_pixels, 1) * pixel_scales) @@ -448,9 +509,9 @@ def fit_spectrum_pytorch( local_lr = effective_lr_local if local_lr is None: - local_lr = 0.05 if optimizer_name == "adam" else 1.0 + local_lr = 0.05 if effective_optimizer_local == "adam" else 1.0 - if optimizer_name == "adam": + if effective_optimizer_local == "adam": local_opt = torch.optim.Adam(trainable_params, lr=local_lr) else: local_opt = torch.optim.LBFGS( @@ -480,15 +541,26 @@ def _forward_model(): conc = nn.functional.softplus(conc_logits) # (P, n_elements) peaks_pred = conc @ basis.t() # (P, E) bg_raw = bg_coeffs @ background_basis # (P, E) - bg_pred = nn.functional.softplus(bg_raw) - predicted = torch.clamp(peaks_pred + bg_pred, min=1e-8) + # Keep local background parameterization consistent with global initialization. + bg_pred = bg_raw + predicted = torch.clamp(peaks_pred + bg_pred, min=1e-8, max=1e8) return predicted, conc def _local_loss(pred_local, conc_local): + pred_eval = pred_local[valid_pixel_mask] + target_eval = spectra_flat[valid_pixel_mask] + if normalize_local_target: + local_scale = torch.clamp( + target_eval.max(dim=1, keepdim=True).values, + min=1e-6, + ) + pred_eval = pred_eval / local_scale + target_eval = target_eval / local_scale + loss_data = eds_data_loss( - pred_local[valid_pixel_mask], - spectra_flat[valid_pixel_mask], - loss=loss_name, + pred_eval, + target_eval, + loss=effective_loss_local, ) if spatial_lambda <= 0: return loss_data @@ -500,7 +572,7 @@ def _local_loss(pred_local, conc_local): if verbose: print("fitting spectrum position-by-position") for i in range(num_iters): - if optimizer_name == "lbfgs": + if effective_optimizer_local == "lbfgs": def _local_closure(): local_opt.zero_grad() diff --git a/src/quantem/spectroscopy/spectroscopy_models.py b/src/quantem/spectroscopy/spectroscopy_models.py index 3b0b2928..0812e18b 100644 --- a/src/quantem/spectroscopy/spectroscopy_models.py +++ b/src/quantem/spectroscopy/spectroscopy_models.py @@ -9,18 +9,35 @@ def inverse_softplus(x: torch.Tensor, min_value: float = 1e-8) -> torch.Tensor: """Numerically stable inverse of softplus for positive initialization values.""" x = torch.clamp(x, min=min_value) - return torch.log(torch.expm1(x)) + # For large x, log(expm1(x)) can overflow in float32. Use a stable branch. + return torch.where( + x > 20.0, + x + torch.log1p(-torch.exp(-x)), + torch.log(torch.expm1(x)), + ) def eds_data_loss( predicted: torch.Tensor, target: torch.Tensor, loss: str = "poisson", min_value: float = 1e-8 ) -> torch.Tensor: """Compute EDS fit loss with clamped positive predictions.""" - pred_safe = torch.clamp(predicted, min=min_value) + pred_safe = torch.nan_to_num(predicted, nan=min_value, posinf=1e8, neginf=min_value) + pred_safe = torch.clamp(pred_safe, min=min_value, max=1e8) if loss == "poisson": - return torch.mean(pred_safe - target * torch.log(pred_safe)) + target_safe = torch.nan_to_num(target, nan=0.0, posinf=1e8, neginf=0.0) + target_safe = torch.clamp(target_safe, min=0.0, max=1e8) + if hasattr(torch, "xlogy"): + log_term = torch.xlogy(target_safe, pred_safe) + elif hasattr(torch.special, "xlogy"): + log_term = torch.special.xlogy(target_safe, pred_safe) + else: + log_term = target_safe * torch.log(pred_safe) + log_term = torch.nan_to_num(log_term, nan=0.0, posinf=1e8, neginf=-1e8) + loss_terms = pred_safe - log_term + return torch.mean(torch.nan_to_num(loss_terms, nan=1e8, posinf=1e8, neginf=-1e8)) if loss == "mse": - return nn.functional.mse_loss(pred_safe, target) + target_safe = torch.nan_to_num(target, nan=0.0, posinf=1e8, neginf=-1e8) + return nn.functional.mse_loss(pred_safe, target_safe) raise ValueError("loss must be 'poisson' or 'mse'") From b31ee7cbec19f5a4e81a0f3c6f9d4765f543e32b Mon Sep 17 00:00:00 2001 From: smribet Date: Mon, 9 Feb 2026 05:44:41 -0800 Subject: [PATCH 039/136] cleaning up --- src/quantem/spectroscopy/dataset3deds.py | 88 +++++++++++++++--------- 1 file changed, 56 insertions(+), 32 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 138e1752..6bbd48d0 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -277,41 +277,59 @@ def fit_spectrum_pytorch( 3D mode keys include abundance maps and fit diagnostics. """ - def _normalize_optimizer(name, param_name): + def _normalize_choice(name, param_name, allowed_values): if name is None: return None name_norm = name.lower() - if name_norm not in {"adam", "lbfgs"}: - raise ValueError(f"{param_name} must be 'adam' or 'lbfgs'") + if name_norm not in allowed_values: + allowed_display = "', '".join(sorted(allowed_values)) + raise ValueError(f"{param_name} must be '{allowed_display}'") return name_norm - def _normalize_loss(name, param_name): - if name is None: - return None - name_norm = name.lower() - if name_norm not in {"poisson", "mse"}: - raise ValueError(f"{param_name} must be 'poisson' or 'mse'") - return name_norm - - optimizer_name = _normalize_optimizer(optimizer, "optimizer") - optimizer_global_name = _normalize_optimizer(optimizer_global, "optimizer_global") - optimizer_local_name = _normalize_optimizer(optimizer_local, "optimizer_local") + def _resolve_stage_settings( + fit_mean_only_mode, + optimizer_default_global, + loss_default_global, + loss_default_local, + ): + if fit_mean_only_mode: + return ( + optimizer_global_name or optimizer_name or optimizer_default_global, + None, + loss_global_name or loss_name or loss_default_global, + None, + ) + # Preserve historical behavior: global stage defaults to LBFGS. + return ( + optimizer_global_name or optimizer_default_global, + optimizer_local_name or optimizer_name or "lbfgs", + loss_global_name or loss_name or loss_default_global, + loss_local_name or loss_name or loss_default_local, + ) - loss_name = _normalize_loss(loss, "loss") - loss_global_name = _normalize_loss(loss_global, "loss_global") - loss_local_name = _normalize_loss(loss_local, "loss_local") + optimizer_name = _normalize_choice(optimizer, "optimizer", {"adam", "lbfgs"}) + optimizer_global_name = _normalize_choice( + optimizer_global, "optimizer_global", {"adam", "lbfgs"} + ) + optimizer_local_name = _normalize_choice( + optimizer_local, "optimizer_local", {"adam", "lbfgs"} + ) - if fit_mean_only: - effective_optimizer_global = optimizer_global_name or optimizer_name or "lbfgs" - effective_loss_global = loss_global_name or loss_name or "mse" - effective_optimizer_local = None - effective_loss_local = None - else: - # Preserve historical behavior: global stage defaults to LBFGS. - effective_optimizer_global = optimizer_global_name or "lbfgs" - effective_optimizer_local = optimizer_local_name or optimizer_name or "lbfgs" - effective_loss_global = loss_global_name or loss_name or "poisson" - effective_loss_local = loss_local_name or loss_name or "poisson" + loss_name = _normalize_choice(loss, "loss", {"poisson", "mse"}) + loss_global_name = _normalize_choice(loss_global, "loss_global", {"poisson", "mse"}) + loss_local_name = _normalize_choice(loss_local, "loss_local", {"poisson", "mse"}) + + ( + effective_optimizer_global, + effective_optimizer_local, + effective_loss_global, + effective_loss_local, + ) = _resolve_stage_settings( + fit_mean_only_mode=fit_mean_only, + optimizer_default_global="lbfgs", + loss_default_global="mse" if fit_mean_only else "poisson", + loss_default_local="poisson", + ) if spatial_lambda < 0: raise ValueError("spatial_lambda must be >= 0") @@ -507,9 +525,11 @@ def _normalize_loss(name, param_name): if not freeze_peak_width: trainable_params.append(peak_width_params) - local_lr = effective_lr_local - if local_lr is None: - local_lr = 0.05 if effective_optimizer_local == "adam" else 1.0 + local_lr = ( + effective_lr_local + if effective_lr_local is not None + else (0.05 if effective_optimizer_local == "adam" else 1.0) + ) if effective_optimizer_local == "adam": local_opt = torch.optim.Adam(trainable_params, lr=local_lr) @@ -546,7 +566,7 @@ def _forward_model(): predicted = torch.clamp(peaks_pred + bg_pred, min=1e-8, max=1e8) return predicted, conc - def _local_loss(pred_local, conc_local): + def _prepare_local_loss_inputs(pred_local): pred_eval = pred_local[valid_pixel_mask] target_eval = spectra_flat[valid_pixel_mask] if normalize_local_target: @@ -556,6 +576,10 @@ def _local_loss(pred_local, conc_local): ) pred_eval = pred_eval / local_scale target_eval = target_eval / local_scale + return pred_eval, target_eval + + def _local_loss(pred_local, conc_local): + pred_eval, target_eval = _prepare_local_loss_inputs(pred_local) loss_data = eds_data_loss( pred_eval, From ff2cae09595f4d107041d78249ade1e905b4f295 Mon Sep 17 00:00:00 2001 From: smribet Date: Tue, 10 Feb 2026 05:08:44 -0800 Subject: [PATCH 040/136] plotting update --- src/quantem/spectroscopy/dataset3deds.py | 72 +++++++++++++++++------- 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 6bbd48d0..c1649b07 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -204,7 +204,6 @@ def fit_spectrum_pytorch( optimizer_local=None, loss_global=None, loss_local=None, - normalize_local_target=True, freeze_peak_width=True, spatial_lambda=0.0, min_total_counts=0.0, @@ -254,9 +253,6 @@ def fit_spectrum_pytorch( Global/mean-stage data term, "poisson" or "mse". loss_local : str | None, optional Local position-by-position data term, "poisson" or "mse". - normalize_local_target : bool, optional - If True, normalize each local pixel spectrum by its own target scale - (max channel value) before evaluating the local data loss. freeze_peak_width : bool, optional If True, lock peak widths after global fit (3D mode). spatial_lambda : float, optional @@ -333,8 +329,6 @@ def _resolve_stage_settings( if spatial_lambda < 0: raise ValueError("spatial_lambda must be >= 0") - if not isinstance(normalize_local_target, bool): - raise ValueError("normalize_local_target must be a bool") effective_lr_global = lr if lr_global is None else lr_global effective_lr_local = lr if lr_local is None else lr_local @@ -468,6 +462,7 @@ def _resolve_stage_settings( global_loss_history = global_fit["loss_history"] global_scale = global_fit["spectrum_scale"].detach() global_offset = global_fit["spectrum_offset"].detach() + global_fitted_spectrum = global_fit["final_pred_raw"].detach().cpu().numpy() with torch.no_grad(): # If the global stage fit a normalized target, convert amplitude-like @@ -495,13 +490,18 @@ def _resolve_stage_settings( mean_total = torch.clamp(mean_spectrum.sum(), min=1e-8) pixel_scales = (total_counts / mean_total).unsqueeze(1) - if normalize_local_target: - # When local loss is normalized per pixel, amplitude scaling from total - # counts is no longer part of the objective and can destabilize LBFGS. - pixel_scales = torch.ones_like(pixel_scales) # Avoid near-zero concentration initialization that can cause vanishing # softplus gradients in local optimization (especially on normalized data). - conc_init = torch.clamp(global_conc.unsqueeze(0) * pixel_scales, min=1e-3, max=50.0) + conc_init = torch.clamp( + global_conc.unsqueeze(0) * pixel_scales, + min=1e-3, + ) + # Small random perturbation helps break symmetry across pixels. + conc_init = torch.clamp( + conc_init * (1.0 + 0.02 * torch.randn_like(conc_init)), + min=1e-3, + ) + conc_logits = nn.Parameter(inverse_softplus(conc_init)) bg_coeffs = nn.Parameter(global_bg_coeffs.unsqueeze(0).repeat(n_pixels, 1) * pixel_scales) @@ -569,13 +569,9 @@ def _forward_model(): def _prepare_local_loss_inputs(pred_local): pred_eval = pred_local[valid_pixel_mask] target_eval = spectra_flat[valid_pixel_mask] - if normalize_local_target: - local_scale = torch.clamp( - target_eval.max(dim=1, keepdim=True).values, - min=1e-6, - ) - pred_eval = pred_eval / local_scale - target_eval = target_eval / local_scale + local_scale = torch.clamp(global_scale, min=1e-8) + pred_eval = pred_eval / local_scale + target_eval = target_eval / local_scale return pred_eval, target_eval def _local_loss(pred_local, conc_local): @@ -590,6 +586,7 @@ def _local_loss(pred_local, conc_local): return loss_data conc_maps = conc_local.view(n_y, n_x, n_elements).permute(2, 0, 1) + conc_maps = conc_maps / torch.clamp(global_scale, min=1e-8) loss_smooth = abundance_smoothness_l2(conc_maps) return loss_data + spatial_lambda * loss_smooth @@ -622,10 +619,17 @@ def _local_closure(): print(f"iter {i + 1:4d}/{num_iters}: loss={loss_history[-1]:.6g}") with torch.no_grad(): - _, conc_final = _forward_model() + pred_final, conc_final = _forward_model() + bg_final = bg_coeffs @ background_basis + + mean_input_spectrum = spectra_flat.mean(dim=0).cpu().numpy() + mean_fitted_spectrum = pred_final.mean(dim=0).cpu().numpy() + mean_background_spectrum = bg_final.mean(dim=0).cpu().numpy() + abundance_maps = conc_final.view(n_y, n_x, n_elements).permute(2, 0, 1).cpu().numpy() peak_widths = nn.functional.softplus(peak_width_params).detach().cpu().numpy() loss_history_array = np.asarray(loss_history) + energy_axis_np = energy_axis.cpu().numpy() if show_plot: fig, ax = plt.subplots(1, 1, figsize=(8, 4)) @@ -658,6 +662,31 @@ def _local_closure(): plt.tight_layout() plt.show() + fig, ax = plt.subplots(1, 1, figsize=(10, 4)) + ax.plot(energy_axis_np, mean_input_spectrum, "k-", label="Data", linewidth=1) + ax.plot( + energy_axis_np, + global_fitted_spectrum, + color="cyan", + label="Global fit", + linewidth=2.5, + ) + ax.plot(energy_axis_np, mean_fitted_spectrum, "r-", label="Fit", linewidth=2.5) + ax.plot( + energy_axis_np, + mean_background_spectrum, + "b--", + label="Background", + linewidth=2.5, + ) + ax.set_xlim(energy_range[0], energy_range[1]) + ax.legend() + ax.set_title("fit spectrum after local fitting (x/y-averaged)") + ax.set_xlabel("Energy (keV)") + ax.set_ylabel("Counts") + plt.tight_layout() + plt.show() + map_titles = [f"{name}" for name in global_model.peak_model.element_names] show_2d(list(abundance_maps), title=map_titles) @@ -668,7 +697,10 @@ def _local_closure(): "loss_history": loss_history_array, "global_loss_history": np.asarray(global_loss_history), "valid_pixel_mask": valid_pixel_mask.view(n_y, n_x).cpu().numpy(), - "energy_axis": energy_axis.cpu().numpy(), + "energy_axis": energy_axis_np, + "input_spectrum": mean_input_spectrum, + "fitted_spectrum": mean_fitted_spectrum, + "background_spectrum": mean_background_spectrum, "fit_range": energy_range, } From fffe363654c2f99d1b5a1ea51906e9fa7d66021d Mon Sep 17 00:00:00 2001 From: smribet Date: Tue, 10 Feb 2026 05:25:11 -0800 Subject: [PATCH 041/136] make gpu compatible --- src/quantem/spectroscopy/dataset3deds.py | 34 +++++-- .../spectroscopy/spectroscopy_models.py | 88 +++++++++++++++---- 2 files changed, 100 insertions(+), 22 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index c1649b07..fc83a49e 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -102,6 +102,7 @@ def _fit_mean_model_pytorch( background = PolynomialBackground(energy_axis, degree=polynomial_background_degree) peaks = GaussianPeaks(energy_axis, peak_width=peak_width, elements_to_fit=elements_to_fit) model = EDSModel(peaks, background, energy_axis=energy_axis) + model = model.to(device=energy_axis.device, dtype=energy_axis.dtype) if len(model.peak_model.element_names) == 0: raise ValueError("No elements found in the selected energy range/elements_to_fit.") @@ -175,6 +176,7 @@ def fit_spectrum_mean_pytorch( lr=None, polynomial_background_degree=3, optimizer="lbfgs", + device=None, ): return self.fit_spectrum_pytorch( energy_range=energy_range, @@ -187,6 +189,7 @@ def fit_spectrum_mean_pytorch( loss="mse", fit_mean_only=True, show_plot=True, + device=device, ) def fit_spectrum_pytorch( @@ -212,6 +215,7 @@ def fit_spectrum_pytorch( show_plot=True, lr_global=None, lr_local=None, + device=None, ): """Fit EDS spectra with one entrypoint for mean-only or full-cube fitting. @@ -265,6 +269,9 @@ def fit_spectrum_pytorch( If True, fit only the summed spectrum over (x, y). show_plot : bool, optional Plot fit diagnostics in mean-only mode. + device : str | torch.device | None, optional + Compute device to run fitting on. If None, uses CUDA when available, + otherwise CPU. Returns ------- @@ -330,19 +337,26 @@ def _resolve_stage_settings( if spatial_lambda < 0: raise ValueError("spatial_lambda must be >= 0") + if device is None: + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + else: + device = torch.device(device) + if device.type == "cuda" and not torch.cuda.is_available(): + raise ValueError("CUDA device requested but torch.cuda.is_available() is False.") + effective_lr_global = lr if lr_global is None else lr_global effective_lr_local = lr if lr_local is None else lr_local energy_axis_np = np.arange(self.shape[0]) * self.sampling[0] + self.origin[0] - energy_axis = torch.tensor(energy_axis_np, dtype=torch.float32) - spectra = torch.tensor(self.array, dtype=torch.float32) # (E, Y, X) + energy_axis = torch.tensor(energy_axis_np, dtype=torch.float32, device=device) + spectra = torch.tensor(self.array, dtype=torch.float32, device=device) # (E, Y, X) if energy_range is not None: ind = (energy_axis >= energy_range[0]) & (energy_axis <= energy_range[1]) energy_axis = energy_axis[ind] spectra = spectra[ind] else: - energy_range = [float(energy_axis.min().numpy()), float(energy_axis.max().numpy())] + energy_range = [float(energy_axis.min().item()), float(energy_axis.max().item())] if fit_mean_only: if verbose: @@ -399,6 +413,8 @@ def _resolve_stage_settings( print(f"{i:2d}. {elem:2s}: {conc:.3f}") if show_plot: + energy_axis_plot = energy_axis.detach().cpu().numpy() + spectrum_raw_plot = spectrum_raw.detach().cpu().numpy() fig, ax = plt.subplots(2, 1, figsize=(10, 6)) ax[0].plot(np.arange(loss_history.shape[0]), loss_history, color="k") ax[0].set_title("loss") @@ -406,9 +422,15 @@ def _resolve_stage_settings( ax[0].set_ylabel("loss") ax[0].set_yscale("log") - ax[1].plot(energy_axis, spectrum_raw.numpy(), "k-", label="Data", linewidth=1) - ax[1].plot(energy_axis, final_pred, "r-", label="Fit", linewidth=2) - ax[1].plot(energy_axis, background_fit, "b--", label="Background", linewidth=1.5) + ax[1].plot(energy_axis_plot, spectrum_raw_plot, "k-", label="Data", linewidth=1) + ax[1].plot(energy_axis_plot, final_pred, "r-", label="Fit", linewidth=2) + ax[1].plot( + energy_axis_plot, + background_fit, + "b--", + label="Background", + linewidth=1.5, + ) ax[1].set_xlim(energy_range[0], energy_range[1]) ax[1].legend() ax[1].set_title("fit spectrum") diff --git a/src/quantem/spectroscopy/spectroscopy_models.py b/src/quantem/spectroscopy/spectroscopy_models.py index 0812e18b..2b1cfb39 100644 --- a/src/quantem/spectroscopy/spectroscopy_models.py +++ b/src/quantem/spectroscopy/spectroscopy_models.py @@ -117,11 +117,12 @@ def __init__(self, energy_axis, peak_width, elements_to_fit=None): with open(current_dir / "xray_lines.json", "r") as f: data = json.load(f) - self.energy_axis = ( + energy_axis_tensor = ( energy_axis.float() if torch.is_tensor(energy_axis) else torch.tensor(energy_axis, dtype=torch.float32) ) + self.register_buffer("energy_axis", energy_axis_tensor) self.energy_min = self.energy_axis.min().item() self.energy_max = self.energy_axis.max().item() @@ -170,19 +171,55 @@ def __init__(self, energy_axis, peak_width, elements_to_fit=None): all_peak_element_indices.extend([elem_idx] * len(energies)) # Store as tensors for fast computation - self.peak_energies = torch.tensor(all_peak_energies, dtype=torch.float32) - self.peak_weights = torch.tensor(all_peak_weights, dtype=torch.float32) - self.peak_element_indices = torch.tensor(all_peak_element_indices, dtype=torch.long) + self.register_buffer( + "peak_energies", + torch.tensor( + all_peak_energies, + dtype=self.energy_axis.dtype, + device=self.energy_axis.device, + ), + ) + self.register_buffer( + "peak_weights", + torch.tensor( + all_peak_weights, + dtype=self.energy_axis.dtype, + device=self.energy_axis.device, + ), + ) + self.register_buffer( + "peak_element_indices", + torch.tensor( + all_peak_element_indices, + dtype=torch.long, + device=self.energy_axis.device, + ), + ) self.n_peaks = len(all_peak_energies) - init_fwhm = torch.tensor(peak_width, dtype=torch.float32) + init_fwhm = torch.tensor( + peak_width, + dtype=self.energy_axis.dtype, + device=self.energy_axis.device, + ) self.peak_width_by_peak = nn.Parameter( - inverse_softplus(init_fwhm) * torch.ones(self.n_peaks) + inverse_softplus(init_fwhm) + * torch.ones( + self.n_peaks, + dtype=self.energy_axis.dtype, + device=self.energy_axis.device, + ) ) print(f"Fitting {n_elements} elements with {self.n_peaks} total peaks") # Learnable parameters - self.concentrations = nn.Parameter((torch.ones(n_elements))) + self.concentrations = nn.Parameter( + torch.ones( + n_elements, + dtype=self.energy_axis.dtype, + device=self.energy_axis.device, + ) + ) def forward(self): """Vectorized forward pass""" @@ -194,7 +231,14 @@ def forward(self): all_peaks = torch.exp(-0.5 * ((energies - centers) / sigma) ** 2) - all_peaks = all_peaks * self.energy_step / (torch.sqrt(torch.tensor(2 * np.pi)) * sigma) + sqrt_2pi = torch.sqrt( + torch.tensor( + 2 * np.pi, + dtype=all_peaks.dtype, + device=all_peaks.device, + ) + ) + all_peaks = all_peaks * self.energy_step / (sqrt_2pi * sigma) peak_concentrations = nn.functional.softplus( self.concentrations[self.peak_element_indices] @@ -211,19 +255,28 @@ class PolynomialBackground(nn.Module): def __init__(self, energy_axis, degree=3): super().__init__() - self.energy_axis = ( + energy_axis_tensor = ( energy_axis.float() if torch.is_tensor(energy_axis) else torch.tensor(energy_axis, dtype=torch.float32) ) + self.register_buffer("energy_axis", energy_axis_tensor) self.degree = degree # Normalize energy axis to [0, 1] for numerical stability - self.energy_norm = (self.energy_axis - self.energy_axis.min()) / ( + energy_norm = (self.energy_axis - self.energy_axis.min()) / ( self.energy_axis.max() - self.energy_axis.min() ) - - self.coeffs = nn.Parameter(torch.randn(degree + 1) * 0.1) + self.register_buffer("energy_norm", energy_norm) + + self.coeffs = nn.Parameter( + torch.randn( + degree + 1, + dtype=self.energy_axis.dtype, + device=self.energy_axis.device, + ) + * 0.1 + ) def forward(self): background = torch.zeros_like(self.energy_axis) @@ -237,15 +290,18 @@ class ExponentialBackground(nn.Module): def __init__(self, energy_axis): super().__init__() - self.energy_axis = ( + energy_axis_tensor = ( energy_axis.float() if torch.is_tensor(energy_axis) else torch.tensor(energy_axis, dtype=torch.float32) ) + self.register_buffer("energy_axis", energy_axis_tensor) + dtype = self.energy_axis.dtype + device = self.energy_axis.device - self.amplitude = nn.Parameter(torch.tensor(1.0)) - self.decay = nn.Parameter(torch.tensor(0.5)) - self.offset = nn.Parameter(torch.tensor(0.1)) + self.amplitude = nn.Parameter(torch.tensor(1.0, dtype=dtype, device=device)) + self.decay = nn.Parameter(torch.tensor(0.5, dtype=dtype, device=device)) + self.offset = nn.Parameter(torch.tensor(0.1, dtype=dtype, device=device)) def forward(self): return self.amplitude * torch.exp(-self.decay * self.energy_axis) + self.offset From aec39c686abc11a1f96b6e36af285e0cb5fd5cb6 Mon Sep 17 00:00:00 2001 From: smribet Date: Tue, 10 Feb 2026 08:36:34 -0800 Subject: [PATCH 042/136] a few more changes --- src/quantem/spectroscopy/dataset3deds.py | 87 +++++++++++++++++++----- 1 file changed, 70 insertions(+), 17 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index fc83a49e..d868f012 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -216,6 +216,7 @@ def fit_spectrum_pytorch( lr_global=None, lr_local=None, device=None, + constrain_background=True, ): """Fit EDS spectra with one entrypoint for mean-only or full-cube fitting. @@ -272,6 +273,9 @@ def fit_spectrum_pytorch( device : str | torch.device | None, optional Compute device to run fitting on. If None, uses CUDA when available, otherwise CPU. + constrain_background : bool, optional + If True (3D mode), regularize local backgrounds using the global fit as + a prior and soft physical constraints with built-in weights. Returns ------- @@ -525,7 +529,8 @@ def _resolve_stage_settings( ) conc_logits = nn.Parameter(inverse_softplus(conc_init)) - bg_coeffs = nn.Parameter(global_bg_coeffs.unsqueeze(0).repeat(n_pixels, 1) * pixel_scales) + bg_coeffs_init = global_bg_coeffs.unsqueeze(0).repeat(n_pixels, 1) * pixel_scales + bg_coeffs = nn.Parameter(bg_coeffs_init.clone()) if freeze_peak_width: peak_width_params = global_peak_width_params @@ -586,7 +591,7 @@ def _forward_model(): # Keep local background parameterization consistent with global initialization. bg_pred = bg_raw predicted = torch.clamp(peaks_pred + bg_pred, min=1e-8, max=1e8) - return predicted, conc + return predicted, conc, bg_pred def _prepare_local_loss_inputs(pred_local): pred_eval = pred_local[valid_pixel_mask] @@ -596,7 +601,44 @@ def _prepare_local_loss_inputs(pred_local): target_eval = target_eval / local_scale return pred_eval, target_eval - def _local_loss(pred_local, conc_local): + def _background_regularization(bg_local): + if not constrain_background: + return bg_local.new_tensor(0.0) + + prior_lambda = 0.1 + nonneg_lambda = 0.5 + monotonic_lambda = 0.05 + smoothness_lambda = 0.01 + + reg_loss = bg_local.new_tensor(0.0) + local_scale = torch.clamp(global_scale, min=1e-8) + + if prior_lambda > 0: + coeff_init_eval = bg_coeffs_init[valid_pixel_mask] + coeff_eval = bg_coeffs[valid_pixel_mask] + coeff_scale = torch.clamp(coeff_init_eval.abs().mean(), min=1e-8) + reg_prior = ((coeff_eval - coeff_init_eval) / coeff_scale).pow(2).mean() + reg_loss = reg_loss + prior_lambda * reg_prior + + bg_eval = bg_local[valid_pixel_mask] / local_scale + + if nonneg_lambda > 0: + reg_nonneg = torch.relu(-bg_eval).pow(2).mean() + reg_loss = reg_loss + nonneg_lambda * reg_nonneg + + if monotonic_lambda > 0 and bg_eval.shape[1] > 1: + slope = bg_eval[:, 1:] - bg_eval[:, :-1] + reg_monotonic = torch.relu(slope).pow(2).mean() + reg_loss = reg_loss + monotonic_lambda * reg_monotonic + + if smoothness_lambda > 0 and bg_eval.shape[1] > 2: + curvature = bg_eval[:, 2:] - 2.0 * bg_eval[:, 1:-1] + bg_eval[:, :-2] + reg_smooth = curvature.pow(2).mean() + reg_loss = reg_loss + smoothness_lambda * reg_smooth + + return reg_loss + + def _local_loss(pred_local, conc_local, bg_local): pred_eval, target_eval = _prepare_local_loss_inputs(pred_local) loss_data = eds_data_loss( @@ -604,13 +646,15 @@ def _local_loss(pred_local, conc_local): target_eval, loss=effective_loss_local, ) + loss_total = loss_data + _background_regularization(bg_local) + if spatial_lambda <= 0: - return loss_data + return loss_total conc_maps = conc_local.view(n_y, n_x, n_elements).permute(2, 0, 1) conc_maps = conc_maps / torch.clamp(global_scale, min=1e-8) loss_smooth = abundance_smoothness_l2(conc_maps) - return loss_data + spatial_lambda * loss_smooth + return loss_total + spatial_lambda * loss_smooth if verbose: print("fitting spectrum position-by-position") @@ -619,20 +663,20 @@ def _local_loss(pred_local, conc_local): def _local_closure(): local_opt.zero_grad() - pred_local, conc_local = _forward_model() - loss_total = _local_loss(pred_local, conc_local) + pred_local, conc_local, bg_local = _forward_model() + loss_total = _local_loss(pred_local, conc_local, bg_local) loss_total.backward() return loss_total loss_value = local_opt.step(_local_closure) if not torch.is_tensor(loss_value): with torch.no_grad(): - pred_local, conc_local = _forward_model() - loss_value = _local_loss(pred_local, conc_local) + pred_local, conc_local, bg_local = _forward_model() + loss_value = _local_loss(pred_local, conc_local, bg_local) else: local_opt.zero_grad() - pred_local, conc_local = _forward_model() - loss_value = _local_loss(pred_local, conc_local) + pred_local, conc_local, bg_local = _forward_model() + loss_value = _local_loss(pred_local, conc_local, bg_local) loss_value.backward() local_opt.step() @@ -641,12 +685,18 @@ def _local_closure(): print(f"iter {i + 1:4d}/{num_iters}: loss={loss_history[-1]:.6g}") with torch.no_grad(): - pred_final, conc_final = _forward_model() - bg_final = bg_coeffs @ background_basis + pred_final, conc_final, bg_final = _forward_model() + + # Keep global/local/data comparison on equal footing by averaging + # over the same valid-pixel mask used in the global stage. + mean_input_spectrum = spectra_flat[valid_pixel_mask].mean(dim=0).cpu().numpy() + mean_fitted_spectrum = pred_final[valid_pixel_mask].mean(dim=0).cpu().numpy() + mean_background_spectrum = bg_final[valid_pixel_mask].mean(dim=0).cpu().numpy() - mean_input_spectrum = spectra_flat.mean(dim=0).cpu().numpy() - mean_fitted_spectrum = pred_final.mean(dim=0).cpu().numpy() - mean_background_spectrum = bg_final.mean(dim=0).cpu().numpy() + # Also provide all-pixel aggregates for diagnostics. + mean_input_spectrum_all = spectra_flat.mean(dim=0).cpu().numpy() + mean_fitted_spectrum_all = pred_final.mean(dim=0).cpu().numpy() + mean_background_spectrum_all = bg_final.mean(dim=0).cpu().numpy() abundance_maps = conc_final.view(n_y, n_x, n_elements).permute(2, 0, 1).cpu().numpy() peak_widths = nn.functional.softplus(peak_width_params).detach().cpu().numpy() @@ -703,7 +753,7 @@ def _local_closure(): ) ax.set_xlim(energy_range[0], energy_range[1]) ax.legend() - ax.set_title("fit spectrum after local fitting (x/y-averaged)") + ax.set_title("fit spectrum after local fitting (valid-pixel averaged)") ax.set_xlabel("Energy (keV)") ax.set_ylabel("Counts") plt.tight_layout() @@ -723,6 +773,9 @@ def _local_closure(): "input_spectrum": mean_input_spectrum, "fitted_spectrum": mean_fitted_spectrum, "background_spectrum": mean_background_spectrum, + "input_spectrum_all_pixels": mean_input_spectrum_all, + "fitted_spectrum_all_pixels": mean_fitted_spectrum_all, + "background_spectrum_all_pixels": mean_background_spectrum_all, "fit_range": energy_range, } From 75e8adc1dd607b746ae6e30d61e9dafd0f8698bb Mon Sep 17 00:00:00 2001 From: Sangoda <87961379+Sangoda@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:46:39 -0800 Subject: [PATCH 043/136] Apply local spectroscopy edits after pulling latest --- .../spectroscopy/dataset3dspectroscopy.py | 281 ++++++++++++------ 1 file changed, 183 insertions(+), 98 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index f1089629..c3b7024b 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -479,7 +479,7 @@ def _plot_pca_results( # QUANTIFICATION ----------------------------------------------- def quantify_composition( - self, roi=None, elements=None, k_factors=None, method="cliff_lorimer", mask=None + self, roi=None, elements=None, k_factors=None, k_factor_file=None, method="cliff_lorimer", mask=None ): """ Quantify elemental composition from EDS spectrum using Cliff-Lorimer approach. @@ -496,7 +496,10 @@ def quantify_composition( k_factors : dict, optional K-factors for element pairs relative to first element. Format: {'Pt': 1.0, 'Co': 1.23} where first element = 1.0 - If None, uses theoretical k-factors from element database. + If None, must provide k_factor_file to load k-factors. + k_factor_file : str, optional + Path or filename of CSV file containing k-factors (e.g., 'kfacs_Titan_300_keV.csv'). + Required if k_factors is None. File should have columns: Element, K, L, M. method : str, optional Quantification method. Currently supports 'cliff_lorimer'. mask : array, optional @@ -512,8 +515,8 @@ def quantify_composition( Examples -------- - # Basic quantification with theoretical k-factors - comp = dataset.quantify_composition(elements=['Pt', 'Co']) + # Quantification using k-factors from file + comp = dataset.quantify_composition(elements=['Pt', 'Co'], k_factor_file='kfacs_Titan_300_keV.csv') # With experimental k-factors k_factors = {'Pt': 1.0, 'Co': 1.23} @@ -536,20 +539,28 @@ def quantify_composition( spectrum_data = self._extract_spectrum_for_quantification(roi, mask) spec = spectrum_data["spectrum"] E = spectrum_data["energy"] - - # Get X-ray line intensities for each element - intensities = {} - for element in elements: - intensity = self._integrate_element_intensity(element, spec, E) - intensities[element] = intensity - - # Handle k-factors - if k_factors is None: # if they arent provided, calculate from kfacs_Titan_300_keV.csv - k_factors = self._calculate_theoretical_k_factors(elements) + + # Determine max usable energy from the actual dataset + max_energy = float(E.max()) if len(E) > 0 else 20.0 + + # Handle k-factors and determine appropriate shell for each element + if k_factors is None: + if k_factor_file is None: + raise ValueError("Must provide either k_factors dict or k_factor_file path") + k_factors, element_shells = self._calculate_theoretical_k_factors(elements, k_factor_file, max_energy) else: # Validate k-factors if not all(elem in k_factors for elem in elements): raise ValueError("k_factors must include all elements") + # When user provides k-factors manually, determine shells from available lines + element_shells = self._determine_element_shells(elements, max_energy) + + # Get X-ray line intensities for each element using the correct shell + intensities = {} + for element in elements: + shell = element_shells.get(element, "K") # Default to K if not determined + intensity = self._integrate_element_intensity(element, spec, E, shell) + intensities[element] = intensity # Apply Cliff-Lorimer quantification if method == "cliff_lorimer": @@ -603,8 +614,20 @@ def _extract_spectrum_for_quantification(self, roi, mask): return {"spectrum": spec, "energy": E} - def _integrate_element_intensity(self, element, spectrum, energy): - """Integrate X-ray intensity for a specific element using its characteristic lines.""" + def _integrate_element_intensity(self, element, spectrum, energy, shell="K"): + """Integrate X-ray intensity for a specific element using characteristic lines from the specified shell. + + Parameters + ---------- + element : str + Element symbol + spectrum : array + Spectrum intensities + energy : array + Energy axis in keV + shell : str + X-ray shell to use: 'K', 'L', or 'M' + """ all_info = type(self).element_info if element not in all_info: raise ValueError(f"Element {element} not found in database") @@ -612,16 +635,29 @@ def _integrate_element_intensity(self, element, spectrum, energy): total_intensity = 0.0 element_lines = all_info[element] - # Get the most intense lines (K-alpha, L-alpha, etc.) - weighted_lines = [ - (info["weight"], info["energy (keV)"], line_name) - for line_name, info in element_lines.items() - if info["energy (keV)"] <= 12.0 - ] # Ignore high energy lines - weighted_lines.sort(reverse=True) # Sort by weight (highest first) - - # Use top 3 most intense lines for integration - for weight, line_energy, line_name in weighted_lines[:3]: + # Filter lines by the specified shell (K, L, or M) + # For K-shell: Ka, Kb lines + # For L-shell: La, Lb, Lg lines + # For M-shell: Ma, Mb lines + shell_lines = [] + for line_name, info in element_lines.items(): + line_energy = info["energy (keV)"] + line_weight = info["weight"] + + # Check if line belongs to the specified shell + if shell == "K" and ("Ka" in line_name or "Kb" in line_name): + shell_lines.append((line_weight, line_energy, line_name)) + elif shell == "L" and ("La" in line_name or "Lb" in line_name or "Lg" in line_name): + shell_lines.append((line_weight, line_energy, line_name)) + elif shell == "M" and ("Ma" in line_name or "Mb" in line_name): + shell_lines.append((line_weight, line_energy, line_name)) + + # Sort by weight (highest first) and ignore lines beyond detector range + shell_lines = [(w, e, n) for w, e, n in shell_lines if e <= 12.0] + shell_lines.sort(reverse=True) + + # Use top 3 most intense lines from the specified shell for integration + for weight, line_energy, line_name in shell_lines[:3]: if weight > 0.1: # Only significant lines # Find integration window around the line # Use +/- 0.1 keV window or adaptive based on energy resolution @@ -644,11 +680,71 @@ def _integrate_element_intensity(self, element, spectrum, energy): return total_intensity - def _calculate_theoretical_k_factors(self, elements): - """Load k-factors from Titan 300 keV CSV file.""" - # Get the path to the CSV file (same directory as this Python file) - current_dir = os.path.dirname(os.path.abspath(__file__)) - csv_path = os.path.join(current_dir, "kfacs_Titan_300_keV.csv") + def _determine_element_shells(self, elements, max_energy): + """Determine the appropriate X-ray shell (K, L, or M) for each element based on available lines. + + Parameters + ---------- + elements : list + List of element symbols + max_energy : float + Maximum energy in keV from the dataset + """ + all_info = type(self).element_info + element_shells = {} + + for element in elements: + if element not in all_info: + element_shells[element] = "K" # Default + continue + + element_lines = all_info[element] + + # Check which X-ray series is present AND within usable energy range + has_usable_k_lines = any( + ("Ka" in line or "Kb" in line) and info["energy (keV)"] <= max_energy + for line, info in element_lines.items() + ) + has_usable_l_lines = any( + ("La" in line or "Lb" in line or "Lg" in line) and info["energy (keV)"] <= max_energy + for line, info in element_lines.items() + ) + has_usable_m_lines = any( + ("Ma" in line or "Mb" in line) and info["energy (keV)"] <= max_energy + for line, info in element_lines.items() + ) + + # Prioritize K-lines, then L-lines, then M-lines (only if within usable range) + if has_usable_k_lines: + element_shells[element] = "K" + elif has_usable_l_lines: + element_shells[element] = "L" + elif has_usable_m_lines: + element_shells[element] = "M" + else: + element_shells[element] = "K" # Default fallback + + return element_shells + + def _calculate_theoretical_k_factors(self, elements, k_factor_file, max_energy): + """Load k-factors from specified CSV file. + + Parameters + ---------- + elements : list + List of element symbols + k_factor_file : str + Path or filename of CSV file + max_energy : float + Maximum energy in keV from the dataset + """ + # Get the path to the CSV file + # If it's just a filename (not absolute path), look in the same directory as this Python file + if not os.path.isabs(k_factor_file): + current_dir = os.path.dirname(os.path.abspath(__file__)) + csv_path = os.path.join(current_dir, k_factor_file) + else: + csv_path = k_factor_file # Load k-factors from CSV k_factor_data = {} @@ -663,73 +759,90 @@ def _calculate_theoretical_k_factors(self, elements): "M": float(row["M"]), } except FileNotFoundError: - print(f"Warning: K-factor CSV file not found at {csv_path}") - print("Using simplified k-factors (all set to 1.0)") - return {elem: 1.0 for elem in elements} + raise FileNotFoundError( + f"K-factor CSV file not found at {csv_path}. " + f"Please provide a valid path to a k-factor file." + ) # Get element info database to determine which X-ray line to use all_info = type(self).element_info k_factors = {} + element_shells = {} # Track which shell is used for each element + for element in elements: if element not in k_factor_data: print(f"Warning: Element {element} not found in k-factor database, using 1.0") k_factors[element] = 1.0 + element_shells[element] = "K" continue - # Determine which X-ray line (K, L, or M) to use based on the element's primary lines + # Determine which X-ray line (K, L, or M) to use based on energy range and availability + line_type = "K" # Default if element in all_info: element_lines = all_info[element] - # Check which X-ray series is most prominent for this element - has_k_lines = any("Ka" in line or "Kb" in line for line in element_lines.keys()) - has_l_lines = any("La" in line or "Lb" in line for line in element_lines.keys()) - has_m_lines = any("Ma" in line or "Mb" in line for line in element_lines.keys()) + # Check which X-ray series is within usable energy range + has_usable_k_lines = any( + ("Ka" in line or "Kb" in line) and info["energy (keV)"] <= max_energy + for line, info in element_lines.items() + ) + has_usable_l_lines = any( + ("La" in line or "Lb" in line or "Lg" in line) and info["energy (keV)"] <= max_energy + for line, info in element_lines.items() + ) + has_usable_m_lines = any( + ("Ma" in line or "Mb" in line) and info["energy (keV)"] <= max_energy + for line, info in element_lines.items() + ) - # Prioritize K-lines, then L-lines, then M-lines - if has_k_lines and k_factor_data[element]["K"] > 0: + # Prioritize K-lines, then L-lines, then M-lines (only if within usable range and k-factor available) + if has_usable_k_lines and k_factor_data[element]["K"] > 0: k_factors[element] = k_factor_data[element]["K"] - # line_type = "K" - elif has_l_lines and k_factor_data[element]["L"] > 0: + line_type = "K" + elif has_usable_l_lines and k_factor_data[element]["L"] > 0: k_factors[element] = k_factor_data[element]["L"] - # line_type = "L" - elif has_m_lines and k_factor_data[element]["M"] > 0: + line_type = "L" + elif has_usable_m_lines and k_factor_data[element]["M"] > 0: k_factors[element] = k_factor_data[element]["M"] - # line_type = "M" + line_type = "M" else: - # Default to K-line k-factor if available - if k_factor_data[element]["K"] > 0: - k_factors[element] = k_factor_data[element]["K"] - # line_type = "K" - elif k_factor_data[element]["L"] > 0: + # Fallback: try any available k-factor even if lines are out of range + if has_usable_l_lines and k_factor_data[element]["L"] > 0: k_factors[element] = k_factor_data[element]["L"] - # line_type = "L" - elif k_factor_data[element]["M"] > 0: + line_type = "L" + elif has_usable_k_lines and k_factor_data[element]["K"] > 0: + k_factors[element] = k_factor_data[element]["K"] + line_type = "K" + elif has_usable_m_lines and k_factor_data[element]["M"] > 0: k_factors[element] = k_factor_data[element]["M"] - # line_type = "M" + line_type = "M" else: k_factors[element] = 1.0 - # line_type = "default" + line_type = "K" else: - # Element not in database, use K-line if available + # Element not in database, use any available k-factor if k_factor_data[element]["K"] > 0: k_factors[element] = k_factor_data[element]["K"] - # line_type = "K" + line_type = "K" elif k_factor_data[element]["L"] > 0: k_factors[element] = k_factor_data[element]["L"] - # line_type = "L" + line_type = "L" elif k_factor_data[element]["M"] > 0: k_factors[element] = k_factor_data[element]["M"] - # line_type = "M" + line_type = "M" else: k_factors[element] = 1.0 - # line_type = "default" + line_type = "K" + + element_shells[element] = line_type - print(f"Using k-factors from Titan 300 keV database: {csv_path}") + print(f"Using k-factors from: {csv_path}") for elem in elements: - print(f" {elem}: {k_factors[elem]:.3f}") + shell = element_shells[elem] + print(f" {elem} ({shell}-shell): {k_factors[elem]:.3f}") - return k_factors + return k_factors, element_shells def _cliff_lorimer_quantification(self, elements, intensities, k_factors, method, roi): """Apply Cliff-Lorimer quantification method.""" @@ -1115,7 +1228,6 @@ def show_mean_spectrum( snr_min=None, snr_threshold=None, distance_threshold_for_sample=0.05, - contamination_elements=None, grid_peaks=None, data_type="eds", peaks=15, @@ -1159,7 +1271,7 @@ def show_mean_spectrum( from peak distribution (typically 20-30 based on data characteristics). Lower values detect more peaks, higher values are more selective. snr_threshold : float, optional - Minimum SNR for identifying a peak as a sample element (not contamination). + Minimum SNR for identifying a peak as a sample element. If None, automatically determined based on peak statistics. For sparse spectra (few strong peaks), uses lower threshold (~30). For dense spectra (many peaks), uses higher threshold (~50-80) to filter noise. @@ -1167,13 +1279,9 @@ def show_mean_spectrum( Maximum energy distance (keV) between detected peak and characteristic line for identifying as a sample element. Default: 0.05. Stricter values (smaller) reduce false positives. - contamination_elements : set or list, optional - Element symbols to exclude from sample detection (e.g., {'C', 'Cu', 'O'}). - Default: {'C', 'N', 'O', 'Cu', 'Si', 'K', 'Kr', 'Po', 'Pb', 'Os', 'Ir', 'At', 'Do', 'Po'} - These are common TEM support materials and artifacts. grid_peaks : dict, optional Dictionary of known grid/support peaks for labeling, e.g., {'C': 0.260, 'Cu': 8.020}. - Default: {'C': 0.260, 'Cu': 8.020} for carbon support film and copper TEM grid. + Default: {} (empty). Provide grid materials as needed. background_subtraction : str, optional Background subtraction method. Options: - 'none' (default): No background subtraction @@ -1194,28 +1302,9 @@ def show_mean_spectrum( """ # Set defaults for detection parameters - if contamination_elements is None: - contamination_elements = { - "C", - "N", - "O", - "Cu", - "Si", - "K", - "Kr", - "Po", - "Pb", - "Os", - "Ir", - "At", - "Do", - "Po", - } - else: - contamination_elements = set(contamination_elements) if grid_peaks is None: - grid_peaks = {"C": 0.260, "Cu": 8.020} + grid_peaks = {} # ADJUST ROI BASED ON GIVEN FLAGS ----------------------------------------------- # Parse ROI parameter @@ -1490,7 +1579,6 @@ def show_mean_spectrum( # Strategy: keep only peaks that: # 1. Match a characteristic line within distance_threshold_for_sample (very tight tolerance) # 2. Have SNR > snr_threshold_for_sample (strong peaks) - # 3. Are from non-contamination elements (or requested elements if specified) detected_elements = set() detected_sample_peaks = {} # Map peak_energy -> is_sample_element for line styling @@ -1508,17 +1596,14 @@ def show_mean_spectrum( snr > snr_threshold_for_sample # Strong peak and distance < distance_threshold_for_sample ): # Very close match to characteristic line - # If specific elements requested, only keep those; otherwise exclude contamination + # If specific elements requested, only keep those if search_elements is not None: if element in search_elements: detected_elements.add(element) detected_sample_peaks[peak_energy] = True else: - if ( - element not in contamination_elements - ): # Not a known contamination - detected_elements.add(element) - detected_sample_peaks[peak_energy] = True + detected_elements.add(element) + detected_sample_peaks[peak_energy] = True # MULTI-PEAK COHERENCE CHECK: Filter out elements with only single weak matches # Count DISTINCT characteristic lines for each element (Ka vs Kb, La vs Lb, etc.) @@ -1621,7 +1706,7 @@ def show_mean_spectrum( break if is_grid_peak: print( - f"Peak at {peak_energy} keV may come from the grid or contamination." + f"Peak at {peak_energy} keV may come from the grid." ) # If elements were detected, use them for element identification only (not for line plotting) From 420e4663ddd26051f5f44162a8d0ee209c23e651 Mon Sep 17 00:00:00 2001 From: Sangoda <87961379+Sangoda@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:08:26 -0800 Subject: [PATCH 044/136] quantem spectroscopy: update dataset3dspectroscopy and add atomic weights --- src/quantem/spectroscopy/atomic_weights.json | 122 +++++++ .../spectroscopy/dataset3dspectroscopy.py | 327 +++++++++--------- 2 files changed, 289 insertions(+), 160 deletions(-) create mode 100644 src/quantem/spectroscopy/atomic_weights.json diff --git a/src/quantem/spectroscopy/atomic_weights.json b/src/quantem/spectroscopy/atomic_weights.json new file mode 100644 index 00000000..3d99a969 --- /dev/null +++ b/src/quantem/spectroscopy/atomic_weights.json @@ -0,0 +1,122 @@ +{ + "atomic_weights": { + "H": 1.008, + "He": 4.0026, + "Li": 6.94, + "Be": 9.0122, + "B": 10.81, + "C": 12.011, + "N": 14.007, + "O": 15.999, + "F": 18.998, + "Ne": 20.18, + "Na": 22.99, + "Mg": 24.305, + "Al": 26.982, + "Si": 28.085, + "P": 30.974, + "S": 32.06, + "Cl": 35.45, + "Ar": 39.948, + "K": 39.098, + "Ca": 40.078, + "Sc": 44.956, + "Ti": 47.867, + "V": 50.942, + "Cr": 51.996, + "Mn": 54.938, + "Fe": 55.845, + "Co": 58.933, + "Ni": 58.693, + "Cu": 63.546, + "Zn": 65.38, + "Ga": 69.723, + "Ge": 72.63, + "As": 74.922, + "Se": 78.971, + "Br": 79.904, + "Kr": 83.798, + "Rb": 85.468, + "Sr": 87.62, + "Y": 88.906, + "Zr": 91.224, + "Nb": 92.906, + "Mo": 95.95, + "Tc": 98.0, + "Ru": 101.07, + "Rh": 102.905, + "Pd": 106.42, + "Ag": 107.868, + "Cd": 112.414, + "In": 114.818, + "Sn": 118.71, + "Sb": 121.76, + "Te": 127.6, + "I": 126.904, + "Xe": 131.293, + "Cs": 132.905, + "Ba": 137.327, + "La": 138.905, + "Ce": 140.116, + "Pr": 140.908, + "Nd": 144.242, + "Pm": 145.0, + "Sm": 150.36, + "Eu": 151.964, + "Gd": 157.25, + "Tb": 158.925, + "Dy": 162.5, + "Ho": 164.93, + "Er": 167.259, + "Tm": 168.934, + "Yb": 173.045, + "Lu": 174.967, + "Hf": 178.49, + "Ta": 180.948, + "W": 183.84, + "Re": 186.207, + "Os": 190.23, + "Ir": 192.217, + "Pt": 195.084, + "Au": 196.967, + "Hg": 200.592, + "Tl": 204.38, + "Pb": 207.2, + "Bi": 208.98, + "Po": 209.0, + "At": 210.0, + "Rn": 222.0, + "Fr": 223.0, + "Ra": 226.0, + "Ac": 227.0, + "Th": 232.038, + "Pa": 231.036, + "U": 238.029, + "Np": 237.0, + "Pu": 244.0, + "Am": 243.0, + "Cm": 247.0, + "Bk": 247.0, + "Cf": 251.0, + "Es": 252.0, + "Fm": 257.0, + "Md": 258.0, + "No": 259.0, + "Lr": 266.0, + "Rf": 267.0, + "Db": 268.0, + "Sg": 269.0, + "Bh": 270.0, + "Hs": 269.0, + "Mt": 278.0, + "Ds": 281.0, + "Rg": 282.0, + "Cn": 285.0, + "Nh": 286.0, + "Fl": 289.0, + "Mc": 290.0, + "Lv": 293.0, + "Ts": 294.0, + "Og": 294.0 + } +} diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index c3b7024b..39d852b2 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -1,4 +1,3 @@ -import csv import json import os from typing import Any, Optional @@ -17,6 +16,8 @@ class Dataset3dspectroscopy(Dataset3d): # stores the element line info so you don't need to reload each time element_info = None element_info_path = "xray_lines.json" + atomic_weights = None + atomic_weights_path = "atomic_weights.json" dataset_type = "EDS" def __init__( @@ -66,6 +67,25 @@ def load_element_info( with open(full_path, "r") as f: cls.element_info = json.load(f)["elements"] + @classmethod + def load_atomic_weights(cls): + """Load atomic weights table from JSON once per class.""" + if cls.atomic_weights is not None: + return + + base_dir = os.path.dirname(os.path.abspath(__file__)) + full_path = os.path.join(base_dir, cls.atomic_weights_path) + with open(full_path, "r") as f: + data = json.load(f) + + if isinstance(data, dict) and "atomic_weights" in data: + data = data["atomic_weights"] + + if not isinstance(data, dict): + raise ValueError("atomic_weights.json must contain a JSON object") + + cls.atomic_weights = data + def format_spectral_features_table( self, element: str, @@ -253,10 +273,33 @@ def add_elements_to_model(self, elements): if el in all_info: self.model_elements[el] = all_info[el] - def clear_model_elements(self): - """Clear all elements from the model.""" + def remove_elements_from_model(self, elements): + """ + Remove element(s) from the persistent model used in show_mean_spectrum. + + Parameters + ---------- + elements : list or str + Element symbol(s) to remove. Can be a single string (e.g., 'Al') + or list of symbols (e.g., ['Au', 'Cu']). + """ + if self.model_elements is None: + return + + if isinstance(elements, str): + elements = [elements] + + if isinstance(elements, list): + for el in elements: + self.model_elements.pop(el, None) + + if len(self.model_elements) == 0: self.model_elements = None + def clear_model_elements(self): + """Clear all elements from the model.""" + self.model_elements = None + # Storage of spectra alongside dataset def add_spectrum_to_data(self, spectrum, energy_axis): @@ -479,7 +522,7 @@ def _plot_pca_results( # QUANTIFICATION ----------------------------------------------- def quantify_composition( - self, roi=None, elements=None, k_factors=None, k_factor_file=None, method="cliff_lorimer", mask=None + self, roi=None, elements=None, k_factors=None, method="cliff_lorimer", mask=None ): """ Quantify elemental composition from EDS spectrum using Cliff-Lorimer approach. @@ -493,13 +536,13 @@ def quantify_composition( Region of interest as [y, x, dy, dx]. If None, uses full image. elements : list, required List of element symbols to quantify (e.g., ['Pt', 'Co']). - k_factors : dict, optional - K-factors for element pairs relative to first element. - Format: {'Pt': 1.0, 'Co': 1.23} where first element = 1.0 - If None, must provide k_factor_file to load k-factors. - k_factor_file : str, optional - Path or filename of CSV file containing k-factors (e.g., 'kfacs_Titan_300_keV.csv'). - Required if k_factors is None. File should have columns: Element, K, L, M. + k_factors : dict or array-like, required + K-factors for the quantified elements. + - dict format: {'Pt': 1.0, 'Co': 1.23} + - array/list format: [1.0, 1.23] mapped in the same order as ``elements`` + - per-shell dict format: + {'Pt': {'K': 0, 'L': 1.12, 'M': 0}, 'Co': {'K': 1.23, 'L': 0, 'M': 0}} + where 0 means shell unavailable. method : str, optional Quantification method. Currently supports 'cliff_lorimer'. mask : array, optional @@ -515,13 +558,20 @@ def quantify_composition( Examples -------- - # Quantification using k-factors from file - comp = dataset.quantify_composition(elements=['Pt', 'Co'], k_factor_file='kfacs_Titan_300_keV.csv') - - # With experimental k-factors + # With dictionary k-factors k_factors = {'Pt': 1.0, 'Co': 1.23} comp = dataset.quantify_composition(elements=['Pt', 'Co'], k_factors=k_factors) + # With array-like k-factors (same order as elements) + comp = dataset.quantify_composition(elements=['Pt', 'Co'], k_factors=[1.0, 1.23]) + + # With per-shell k-factors (0 means unavailable shell) + shell_kf = { + 'Pt': {'K': 0, 'L': 1.12, 'M': 0}, + 'Co': {'K': 1.23, 'L': 0, 'M': 0}, + } + comp = dataset.quantify_composition(elements=['Pt', 'Co'], k_factors=shell_kf) + # Access results print(f"Pt: {comp['atomic_percent']['Pt']:.1f} at%") print(f"Co: {comp['atomic_percent']['Co']:.1f} at%") @@ -543,17 +593,12 @@ def quantify_composition( # Determine max usable energy from the actual dataset max_energy = float(E.max()) if len(E) > 0 else 20.0 - # Handle k-factors and determine appropriate shell for each element + # Determine shell for each element and validate/normalize k-factors if k_factors is None: - if k_factor_file is None: - raise ValueError("Must provide either k_factors dict or k_factor_file path") - k_factors, element_shells = self._calculate_theoretical_k_factors(elements, k_factor_file, max_energy) - else: - # Validate k-factors - if not all(elem in k_factors for elem in elements): - raise ValueError("k_factors must include all elements") - # When user provides k-factors manually, determine shells from available lines - element_shells = self._determine_element_shells(elements, max_energy) + raise ValueError("Must provide k_factors as a dict or array-like") + + element_shells = self._determine_element_shells(elements, max_energy) + k_factors = self._normalize_k_factors(elements, k_factors, element_shells) # Get X-ray line intensities for each element using the correct shell intensities = {} @@ -726,123 +771,97 @@ def _determine_element_shells(self, elements, max_energy): return element_shells - def _calculate_theoretical_k_factors(self, elements, k_factor_file, max_energy): - """Load k-factors from specified CSV file. - - Parameters - ---------- - elements : list - List of element symbols - k_factor_file : str - Path or filename of CSV file - max_energy : float - Maximum energy in keV from the dataset + def _normalize_k_factors(self, elements, k_factors, element_shells=None): + """Normalize k-factors input to a dict keyed by element symbol. + + Supports: + - scalar dict per element, e.g. {'Pt': 1.0, 'Co': 1.23} + - array-like values aligned with ``elements`` order + - per-shell dict per element, e.g. {'Pt': {'K': 0, 'L': 1.1, 'M': 0}} + where non-positive values are treated as unavailable shell entries. """ - # Get the path to the CSV file - # If it's just a filename (not absolute path), look in the same directory as this Python file - if not os.path.isabs(k_factor_file): - current_dir = os.path.dirname(os.path.abspath(__file__)) - csv_path = os.path.join(current_dir, k_factor_file) - else: - csv_path = k_factor_file + shell_order = ("K", "L", "M") + if element_shells is None: + element_shells = {} - # Load k-factors from CSV - k_factor_data = {} - try: - with open(csv_path, "r") as f: - reader = csv.DictReader(f) - for row in reader: - element = row["Element"] - k_factor_data[element] = { - "K": float(row["K"]), - "L": float(row["L"]), - "M": float(row["M"]), - } - except FileNotFoundError: - raise FileNotFoundError( - f"K-factor CSV file not found at {csv_path}. " - f"Please provide a valid path to a k-factor file." + def _to_positive_float_or_none(value): + try: + parsed = float(value) + except (TypeError, ValueError): + return None + if not np.isfinite(parsed) or parsed <= 0: + return None + return parsed + + def _extract_shell_value(elem, shell_values): + preferred_shell = str(element_shells.get(elem, "K")).upper() + candidate_order = [preferred_shell] + [s for s in shell_order if s != preferred_shell] + + normalized_shell_values = {} + for shell in shell_order: + raw_value = shell_values.get(shell) + if raw_value is None: + raw_value = shell_values.get(shell.lower()) + normalized_shell_values[shell] = _to_positive_float_or_none(raw_value) + + for shell in candidate_order: + value = normalized_shell_values.get(shell) + if value is not None: + return value + + raise ValueError( + f"k_factors['{elem}'] has no usable positive shell value in K/L/M" ) - # Get element info database to determine which X-ray line to use - all_info = type(self).element_info + if isinstance(k_factors, dict): + missing = [elem for elem in elements if elem not in k_factors] + if missing: + raise ValueError(f"k_factors is missing elements: {missing}") - k_factors = {} - element_shells = {} # Track which shell is used for each element - - for element in elements: - if element not in k_factor_data: - print(f"Warning: Element {element} not found in k-factor database, using 1.0") - k_factors[element] = 1.0 - element_shells[element] = "K" - continue + normalized = {} + for elem in elements: + raw_entry = k_factors[elem] - # Determine which X-ray line (K, L, or M) to use based on energy range and availability - line_type = "K" # Default - if element in all_info: - element_lines = all_info[element] + if isinstance(raw_entry, dict): + value = _extract_shell_value(elem, raw_entry) + else: + try: + value = float(raw_entry) + except (TypeError, ValueError): + raise TypeError( + f"k_factors['{elem}'] must be numeric or a dict with K/L/M entries" + ) + if not np.isfinite(value) or value <= 0: + raise ValueError(f"k_factors['{elem}'] must be a positive finite number") - # Check which X-ray series is within usable energy range - has_usable_k_lines = any( - ("Ka" in line or "Kb" in line) and info["energy (keV)"] <= max_energy - for line, info in element_lines.items() - ) - has_usable_l_lines = any( - ("La" in line or "Lb" in line or "Lg" in line) and info["energy (keV)"] <= max_energy - for line, info in element_lines.items() - ) - has_usable_m_lines = any( - ("Ma" in line or "Mb" in line) and info["energy (keV)"] <= max_energy - for line, info in element_lines.items() - ) + normalized[elem] = value + return normalized - # Prioritize K-lines, then L-lines, then M-lines (only if within usable range and k-factor available) - if has_usable_k_lines and k_factor_data[element]["K"] > 0: - k_factors[element] = k_factor_data[element]["K"] - line_type = "K" - elif has_usable_l_lines and k_factor_data[element]["L"] > 0: - k_factors[element] = k_factor_data[element]["L"] - line_type = "L" - elif has_usable_m_lines and k_factor_data[element]["M"] > 0: - k_factors[element] = k_factor_data[element]["M"] - line_type = "M" - else: - # Fallback: try any available k-factor even if lines are out of range - if has_usable_l_lines and k_factor_data[element]["L"] > 0: - k_factors[element] = k_factor_data[element]["L"] - line_type = "L" - elif has_usable_k_lines and k_factor_data[element]["K"] > 0: - k_factors[element] = k_factor_data[element]["K"] - line_type = "K" - elif has_usable_m_lines and k_factor_data[element]["M"] > 0: - k_factors[element] = k_factor_data[element]["M"] - line_type = "M" - else: - k_factors[element] = 1.0 - line_type = "K" - else: - # Element not in database, use any available k-factor - if k_factor_data[element]["K"] > 0: - k_factors[element] = k_factor_data[element]["K"] - line_type = "K" - elif k_factor_data[element]["L"] > 0: - k_factors[element] = k_factor_data[element]["L"] - line_type = "L" - elif k_factor_data[element]["M"] > 0: - k_factors[element] = k_factor_data[element]["M"] - line_type = "M" - else: - k_factors[element] = 1.0 - line_type = "K" - - element_shells[element] = line_type + if isinstance(k_factors, (str, bytes)): + raise TypeError("k_factors must be a dict or array-like of numeric values") - print(f"Using k-factors from: {csv_path}") - for elem in elements: - shell = element_shells[elem] - print(f" {elem} ({shell}-shell): {k_factors[elem]:.3f}") + try: + values = list(k_factors) + except TypeError: + raise TypeError("k_factors must be a dict or array-like of numeric values") + + if len(values) != len(elements): + raise ValueError( + "Array-like k_factors length must match elements length " + f"({len(values)} != {len(elements)})" + ) + + normalized = {} + for elem, raw_value in zip(elements, values): + try: + value = float(raw_value) + except (TypeError, ValueError): + raise TypeError(f"k_factors value for '{elem}' must be numeric") + if not np.isfinite(value) or value <= 0: + raise ValueError(f"k_factors value for '{elem}' must be a positive finite number") + normalized[elem] = value - return k_factors, element_shells + return normalized def _cliff_lorimer_quantification(self, elements, intensities, k_factors, method, roi): """Apply Cliff-Lorimer quantification method.""" @@ -867,43 +886,27 @@ def _cliff_lorimer_quantification(self, elements, intensities, k_factors, method atomic_percent[element] = 0.0 # Calculate weight percentages (requires atomic weights) - atomic_weights = { - "C": 12.01, - "N": 14.01, - "O": 16.00, - "F": 19.00, - "Na": 22.99, - "Mg": 24.31, - "Al": 26.98, - "Si": 28.09, - "P": 30.97, - "S": 32.07, - "Cl": 35.45, - "K": 39.10, - "Ca": 40.08, - "Ti": 47.87, - "Cr": 52.00, - "Mn": 54.94, - "Fe": 55.85, - "Co": 58.93, - "Ni": 58.69, - "Cu": 63.55, - "Zn": 65.38, - "Ag": 107.87, - "Pt": 195.08, - "Au": 196.97, - } + if type(self).atomic_weights is None: + type(self).load_atomic_weights() + atomic_weights = type(self).atomic_weights or {} + + missing_weights = [element for element in elements if element not in atomic_weights] + if missing_weights: + raise ValueError( + f"Atomic weights not found for elements: {missing_weights}. " + "Use valid element symbols (e.g., 'Fe', 'Au', 'Te')." + ) # Convert atomic % to weight % weight_sum = 0.0 for element in elements: - atomic_wt = atomic_weights.get(element, 55.85) # Default to Fe + atomic_wt = atomic_weights[element] weight_sum += (atomic_percent[element] / 100.0) * atomic_wt weight_percent = {} for element in elements: if weight_sum > 0: - atomic_wt = atomic_weights.get(element, 55.85) + atomic_wt = atomic_weights[element] weight_percent[element] = ( (atomic_percent[element] / 100.0) * atomic_wt / weight_sum ) * 100.0 @@ -1303,6 +1306,10 @@ def show_mean_spectrum( # Set defaults for detection parameters + # Ensure spectral line database is available for peak matching + if type(self).element_info is None: + type(self).load_element_info() + if grid_peaks is None: grid_peaks = {} From 3029623d8d34106a2a379949d7735189b41cd848 Mon Sep 17 00:00:00 2001 From: Kovidh Singh Date: Fri, 13 Feb 2026 03:00:29 -0800 Subject: [PATCH 045/136] updates to zlp --- src/quantem/spectroscopy/dataset3deels.py | 200 +++++++++++++++++----- 1 file changed, 155 insertions(+), 45 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index 825dff6f..8dd1b410 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -1,5 +1,11 @@ from typing import Any +import numpy as np + +from scipy.interpolate import interp1d + +from scipy.ndimage import median_filter + from numpy.typing import NDArray from quantem.spectroscopy import Dataset3dspectroscopy @@ -55,48 +61,152 @@ def __init__( ) self._virtual_images = {} - def calculate_background_iterative(self, spectrum): - """ - Subtract background typical for EELS using iterative Gaussian fitting. - This method isolates the continuum background from the low-loss region. - - WARNING: Only use with EELS data! Will remove peaks if used with EDS. - - Parameters - ---------- - spectrum : ndarray - 1D EELS spectrum - energy_axis : ndarray - Energy axis corresponding to spectrum - - Returns - ------- - ndarray - Background-subtracted spectrum - """ - - from scipy.ndimage import gaussian_filter - from scipy.stats import norm - - # Smooth for better fitting - spec_smooth = gaussian_filter(spectrum, sigma=1.0) - pixel_vals = spec_smooth.copy() - - # Iteratively fit Gaussian to low-intensity values (the continuum) - # Remove outliers (edge peaks) iteratively - num_iterations = 10 - cutoff = 3 # +/- 3 sigma - - for _ in range(num_iterations): - mu, std = norm.fit(pixel_vals) - if std == 0: - break - # Keep only values within +/- 3 sigma (removes edge contributions) - lower = mu - cutoff * std - upper = mu + cutoff * std - pixel_vals = pixel_vals[(pixel_vals >= lower) & (pixel_vals <= upper)] - - # Subtract the estimated background level - background_fit = mu - - return background_fit \ No newline at end of file + def calculate_background_iterative(self, spectrum): + """ + Subtract background typical for EELS using iterative Gaussian fitting. + This method isolates the continuum background from the low-loss region. + + WARNING: Only use with EELS data! Will remove peaks if used with EDS. + + Parameters + ---------- + spectrum : ndarray + 1D EELS spectrum + energy_axis : ndarray + Energy axis corresponding to spectrum + + Returns + ------- + ndarray + Background-subtracted spectrum + """ + + from scipy.ndimage import gaussian_filter + from scipy.stats import norm + + # Smooth for better fitting + spec_smooth = gaussian_filter(spectrum, sigma=1.0) + pixel_vals = spec_smooth.copy() + + # Iteratively fit Gaussian to low-intensity values (the continuum) + # Remove outliers (edge peaks) iteratively + num_iterations = 10 + cutoff = 3 # +/- 3 sigma + + for _ in range(num_iterations): + mu, std = norm.fit(pixel_vals) + if std == 0: + break + # Keep only values within +/- 3 sigma (removes edge contributions) + lower = mu - cutoff * std + upper = mu + cutoff * std + pixel_vals = pixel_vals[(pixel_vals >= lower) & (pixel_vals <= upper)] + + # Subtract the estimated background level + background_fit = mu + + return background_fit + + def calibrate_zero_loss_peak(self, center_guess=None, search_window=10): + """ + Calibrate the energy axis by centering the zero loss peak at 0 eV. + Finds the ZLP at every pixel, fits a 2D plane to the ZLP positions, + and shifts each spectrum individually so the ZLP sits at 0, while aligning + all ZLPs to the same channel index, allowing a single origin to correctly + calibrate the entire dataset. + + Parameters + ---------- + center_guess : float or None + Expected energy position of the ZLP in eV. If None, uses the + tallest peak in each spectrum as the ZLP. If provided, searches + for the tallest peak within the search window around that energy. + search_window : int + Number of channels to search on either side of center_guess. + Only used when center_guess is not None. Default is 10. + + Returns + ------- + Dataset3deels + New dataset with corrected energy calibration. + """ + + n_energy, n_y, n_x = self.array.shape + + dE = float(self.sampling[0]) + E0 = float(self.origin[0]) + energy_axis = E0 + np.arange(n_energy) * dE + + # --- Build ZLP position map --- + # For every pixel, find the energy where the ZLP sits. + # A median filter is applied to each spectrum first to remove + # hot pixels (cosmic rays, detector glitches) that could be + # brighter than the ZLP and fool the peak finder. + # If center_guess is provided, only look within a window + # of search_window channels around that energy. + # If center_guess is None, just find the tallest peak. + + zlp_map = np.zeros((n_y, n_x)) + + if center_guess is not None: + guess_index = int(round((center_guess - E0) / dE)) + lo = max(guess_index - search_window, 0) + hi = min(guess_index + search_window + 1, n_energy) + + for iy in range(n_y): + for ix in range(n_x): + spectrum = median_filter(self.array[:, iy, ix], size=3) + + if center_guess is None: + peak_index = np.argmax(spectrum) + else: + peak_index = lo + np.argmax(spectrum[lo:hi]) + + zlp_map[iy, ix] = E0 + peak_index * dE + + # --- Fit a 2D plane to the ZLP map --- + # The plane equation is: zlp_energy(y, x) = a*y + b*x + c + # This smooths out noisy per-pixel ZLP measurements by assuming + # the drift varies linearly across the scan area. + + y_coords, x_coords = np.meshgrid( + np.arange(n_y), np.arange(n_x), indexing="ij" + ) + y_flat = y_coords.ravel() + x_flat = x_coords.ravel() + z_flat = zlp_map.ravel() + + A = np.column_stack([y_flat, x_flat, np.ones(len(y_flat))]) + coeffs, _, _, _ = np.linalg.lstsq(A, z_flat, rcond=None) + a, b, c = coeffs + + zlp_plane = a * y_coords + b * x_coords + c + + # --- Shift each spectrum so the ZLP lands at 0 eV --- + # For each pixel, subtract its plane-predicted ZLP position from + # the energy axis, then interpolate the spectrum back onto the + # original energy grid. This physically moves the data so all + # ZLPs align at the same channel index. + + corrected_array = np.zeros_like(self.array) + + for iy in range(n_y): + for ix in range(n_x): + shift = zlp_plane[iy, ix] + shifted_energy = energy_axis - shift + interpolator = interp1d( + shifted_energy, + self.array[:, iy, ix], + kind="linear", + bounds_error=False, + fill_value=0.0, + ) + corrected_array[:, iy, ix] = interpolator(energy_axis) + + return Dataset3deels.from_array( + array=corrected_array, + name=self.name, + sampling=self.sampling, + origin=self.origin, + units=self.units, + ) \ No newline at end of file From 09597770fa220495599f2d487287e49c29d3852a Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Fri, 13 Feb 2026 09:56:41 -0800 Subject: [PATCH 046/136] small cleanup and eels axis label fix --- src/quantem/spectroscopy/dataset3dspectroscopy.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index f1089629..3047499c 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -274,7 +274,7 @@ def add_spectrum_to_data(self, spectrum, energy_axis): def clear_attached_spectra(self): self.attached_spectra = None - def plot_attached_spectrum(self, spectrum_index=0): + def plot_attached_spectrum(self, data_type='eds',spectrum_index=0): fig, (ax_spec) = plt.subplots(1, 1, figsize=(12, 4)) ax_spec.plot( @@ -282,7 +282,10 @@ def plot_attached_spectrum(self, spectrum_index=0): self.attached_spectra[spectrum_index][0], linewidth=1.5, ) - ax_spec.set_xlabel("Energy (keV)") + if data_type =='eds': + ax_spec.set_xlabel("Energy (keV)") + elif data_type == 'eels': + ax_spec.set_xlabel("Energy (eV)") ax_spec.set_ylabel("Intensity") ax_spec.set_title(f"Spectrum in index {spectrum_index}") ax_spec.grid(True, alpha=0.1) @@ -1098,6 +1101,9 @@ def calculate_mean_spectrum( if attach_mean_spectrum: self.add_spectrum_to_data(spec, E) + print( + f"Spectrum recorded to index {len(self.attached_spectra) - 1} of attached_spectra in {self}" + ) return spec From fa5a58d57061f17b50771f6ee9bc661534855d76 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Tue, 17 Feb 2026 13:46:00 -0800 Subject: [PATCH 047/136] dataset1d for spectra --- src/quantem/core/datastructures/__init__.py | 1 + src/quantem/core/datastructures/dataset1d.py | 216 +++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 src/quantem/core/datastructures/dataset1d.py diff --git a/src/quantem/core/datastructures/__init__.py b/src/quantem/core/datastructures/__init__.py index dfb5b47a..60cddd99 100644 --- a/src/quantem/core/datastructures/__init__.py +++ b/src/quantem/core/datastructures/__init__.py @@ -5,3 +5,4 @@ from quantem.core.datastructures.dataset4d import Dataset4d as Dataset4d from quantem.core.datastructures.dataset3d import Dataset3d as Dataset3d from quantem.core.datastructures.dataset2d import Dataset2d as Dataset2d +from quantem.core.datastructures.dataset1d import Dataset1d as Dataset1d diff --git a/src/quantem/core/datastructures/dataset1d.py b/src/quantem/core/datastructures/dataset1d.py new file mode 100644 index 00000000..79d4d957 --- /dev/null +++ b/src/quantem/core/datastructures/dataset1d.py @@ -0,0 +1,216 @@ +from typing import Self + +import matplotlib.pyplot as plt +import numpy as np +from numpy.typing import NDArray + +from quantem.core.datastructures.dataset import Dataset +from quantem.core.utils.validators import ensure_valid_array + + +@Dataset.register_dimension(1) +class Dataset1d(Dataset): + """1D dataset class that inherits from Dataset. + + This class represents a 1D dataset, such as spectra from EDS or EELS. + + Attributes + ---------- + None beyond base Dataset. + """ + + def __init__( + self, + array: NDArray, + name: str, + origin: NDArray | tuple | list | float | int, + sampling: NDArray | tuple | list | float | int, + units: list[str] | tuple | list | None = None, + signal_units: str = "arb. units", + metadata: dict = {}, + _token: object | None = None, + ): + """Initialize a 1D dataset. + + Parameters + ---------- + array : NDArray + The underlying 1D array data + name : str + A descriptive name for the dataset + origin : NDArray | tuple | list | float | int + The origin coordinates for each dimension + sampling : NDArray | tuple | list | float | int + The sampling rate/spacing for each dimension + units : list[str] | tuple | list + Units for each dimension + signal_units : str, optional + Units for the array values, by default "arb. units" + _token : object | None, optional + Token to prevent direct instantiation, by default None + """ + super().__init__( + array=array, + name=name, + origin=origin, + sampling=sampling, + units=units, + signal_units=signal_units, + metadata=metadata, + _token=_token, + ) + + @classmethod + def from_array( + cls, + array: NDArray, + name: str | None = None, + origin: NDArray | tuple | list | float | int | None = None, + sampling: NDArray | tuple | list | float | int | None = None, + units: list[str] | tuple | list | None = None, + signal_units: str = "arb. units", + ) -> Self: + """Create a Dataset1d from a 1D array. + + Parameters + ---------- + array : NDArray + 1D array with shape (length) + name : str | None + Dataset name. Default: "1D dataset" + origin : NDArray | tuple | list | float | int | None + Origin for each dimension. Default: [0] + sampling : NDArray | tuple | list | float | int | None + Sampling for each dimension. Default: [1] + units : list[str] | tuple | list | None + Units for each dimension. Default: ["pixels"] + signal_units : str + Units for array values. Default: "arb. units" + + Returns + ------- + Dataset1d + + Examples + -------- + >>> import numpy as np + >>> from quantem.core.datastructures import Dataset1d + >>> arr = np.random.rand(10) + >>> data = Dataset3d.from_array(arr) + >>> data.shape + (10,) + + With calibration: + + >>> data = Dataset1d.from_array( + ... arr, + ... sampling=[0.15], + ... units=["eV"], + ... ) + + Visualize: + + >>> data.show() # all frames in grid + >>> data.show(index=0) # single frame + >>> data.show(ncols=2) # 2 columns + """ + array = ensure_valid_array(array, ndim=1) + return cls( + array=array, + name=name if name is not None else "1D dataset", + origin=origin if origin is not None else np.zeros(1), + sampling=sampling if sampling is not None else np.ones(1), + units=units if units is not None else ["pixels"], + signal_units=signal_units, + _token=cls._token, + ) + + @classmethod + def from_shape( + cls, + shape: tuple[int], + name: str = "constant 1D dataset", + fill_value: float = 0.0, + origin: NDArray | tuple | list | float | int | None = None, + sampling: NDArray | tuple | list | float | int | None = None, + units: list[str] | tuple | list | None = None, + signal_units: str = "arb. units", + ) -> Self: + """Create a Dataset1d filled with a constant value. + + Parameters + ---------- + shape : tuple[int, int, int] + Shape (n_frames, height, width) + name : str + Dataset name. Default: "constant 1D dataset" + fill_value : float + Value to fill array with. Default: 0.0 + origin : NDArray | tuple | list | float | int | None + Origin for each dimension + sampling : NDArray | tuple | list | float | int | None + Sampling for each dimension + units : list[str] | tuple | list | None + Units for each dimension + signal_units : str + Units for array values + + Returns + ------- + Dataset1d + + Examples + -------- + >>> data = Dataset1d.from_shape((10000)) + >>> data.shape + (10000,) + >>> data.array.max() + 0.0 + """ + array = np.full(shape, fill_value, dtype=np.float32) + return cls.from_array( + array=array, + name=name, + origin=origin, + sampling=sampling, + units=units, + signal_units=signal_units, + ) + + def show( + self, + title: str | None = None, + returnfig: bool = False, + **kwargs, + ): + """ + Plots 1D dataset + + Parameters + ---------- + scalebar: ScalebarConfig or bool + If True, displays scalebar + title: str + Title of Dataset + **kwargs: dict + Keyword arguments for show_2d + """ + + if title is None: + title = self.name + + fig, (ax) = plt.subplots(1, 1, figsize=(4, 4)) + + ax.plot( + float(self.origin[0]) + float(self.sampling[0]) * np.arange(self.shape[0]), + self.array, + linewidth=1.5, + ) + ax.set_xlabel(self.units[0]) + ax.set_ylabel(self.signal_units) + ax.set_title(title) + + fig.tight_layout() + plt.show() + + return (fig, ax) if returnfig else None From 422da10e1fab9b861b847cbc949535d2b2eb4ed1 Mon Sep 17 00:00:00 2001 From: smribet Date: Wed, 18 Feb 2026 11:14:20 -0800 Subject: [PATCH 048/136] removing titan k factors --- .../spectroscopy/kfacs_Titan_300_keV.csv | 119 ------------------ 1 file changed, 119 deletions(-) delete mode 100644 src/quantem/spectroscopy/kfacs_Titan_300_keV.csv diff --git a/src/quantem/spectroscopy/kfacs_Titan_300_keV.csv b/src/quantem/spectroscopy/kfacs_Titan_300_keV.csv deleted file mode 100644 index 41431179..00000000 --- a/src/quantem/spectroscopy/kfacs_Titan_300_keV.csv +++ /dev/null @@ -1,119 +0,0 @@ -Element,K,L,M -H,0.000,0.000,0.000 -He,0.000,0.000,0.000 -Li,0.000,0.000,0.000 -Be,124.024,0.000,0.000 -B,5.043,0.000,0.000 -C,2.752,0.000,0.000 -N,1.667,0.000,0.000 -O,1.240,0.000,0.000 -F,1.179,0.000,0.000 -Ne,0.824,0.000,0.000 -Na,0.882,0.000,0.000 -Mg,0.939,0.000,0.000 -Al,1.002,0.000,0.000 -Si,1.000,0.000,0.000 -P,1.096,0.000,0.000 -S,1.039,0.000,0.000 -Cl,1.093,0.000,0.000 -Ar,1.188,0.000,0.000 -K,1.119,0.000,0.000 -Ca,1.200,31.337,0.000 -Sc,1.232,21.223,0.000 -Ti,1.249,15.857,0.000 -V,1.297,9.514,0.000 -Cr,1.314,5.235,0.000 -Mn,1.396,3.360,0.000 -Fe,1.440,2.116,0.000 -Co,1.552,1.922,0.000 -Ni,1.587,1.582,0.000 -Cu,1.773,1.523,0.000 -Zn,1.895,1.631,0.000 -Ga,2.112,2.026,0.000 -Ge,2.322,2.008,0.000 -As,2.561,2.109,0.000 -Se,2.923,2.156,0.000 -Br,3.255,2.135,0.000 -Kr,3.780,2.220,0.000 -Rb,4.364,2.227,0.000 -Sr,5.075,2.279,0.000 -Y,5.919,2.438,0.000 -Zr,7.018,2.458,0.000 -Nb,8.282,2.410,0.000 -Mo,10.021,2.464,0.000 -Tc,12.010,2.628,0.000 -Ru,14.672,2.595,0.000 -Rh,17.523,2.728,0.000 -Pd,21.416,2.847,0.000 -Ag,25.856,2.914,0.000 -Cd,31.748,3.052,0.000 -In,38.737,3.049,0.000 -Sn,47.291,3.427,0.000 -Sb,57.584,3.546,0.000 -Te,71.403,3.702,0.000 -I,83.812,3.679,0.000 -Xe,102.774,3.773,0.000 -Cs,122.868,3.798,0.000 -Ba,150.444,3.907,208.795 -La,179.606,3.894,32.017 -Ce,213.080,3.890,19.339 -Pr,252.760,3.879,14.609 -Nd,303.895,3.940,11.826 -Pm,357.415,3.948,9.249 -Sm,434.673,4.138,9.091 -Eu,513.444,4.153,6.834 -Gd,621.351,4.276,3.561 -Tb,733.816,4.282,5.246 -Dy,873.762,4.320,4.151 -Ho,1034.116,4.386,3.867 -Er,1218.828,4.407,3.773 -Tm,1432.640,4.451,2.730 -Yb,1702.171,4.387,2.623 -Lu,1998.379,4.643,1.892 -Hf,2360.330,4.751,1.831 -Ta,2763.368,4.815,1.764 -W,3246.236,4.901,1.740 -Re,3804.888,4.890,2.282 -Os,4484.376,4.915,0.765 -Ir,5218.560,4.899,1.332 -Pt,6103.642,4.986,1.500 -Au,7084.786,4.987,1.599 -Hg,8301.074,5.104,2.034 -Tl,9740.107,5.254,2.968 -Pb,11340.739,5.411,3.714 -Bi,13103.192,5.575,3.596 -Po,15023.039,5.713,3.644 -At,17307.534,5.871,3.624 -Rn,20926.528,6.421,3.874 -Fr,24049.540,6.657,3.727 -Ra,27822.490,6.973,3.636 -Ac,31894.971,7.301,3.523 -Th,37230.598,7.767,3.397 -Pa,42235.953,7.882,3.359 -U,49591.305,7.881,3.324 -Np,56282.882,8.047,3.247 -Pu,65884.009,8.698,3.342 -Am,74663.091,9.038,3.223 -Cm,86382.575,10.449,3.144 -Bk,98350.202,11.113,3.128 -Cf,113576.844,11.859,3.159 -Es,0,0,0 -Fm,0,0,0 -Md,0,0,0 -No,0,0,0 -Lr,0,0,0 -Rf,0,0,0 -Db,0,0,0 -Sg,0,0,0 -Bh,0,0,0 -Hs,0,0,0 -Mt,0,0,0 -Ds,0,0,0 -Rg,0,0,0 -Cn,0,0,0 -Uut,0,0,0 -Fl,0,0,0 -Uup,0,0,0 -Lv,0,0,0 -Uus,0,0,0 -Uuo,0,0,0 \ No newline at end of file From c69cb66ec306e7e77479252a7488838cc429a079 Mon Sep 17 00:00:00 2001 From: smribet Date: Wed, 18 Feb 2026 11:14:20 -0800 Subject: [PATCH 049/136] removing titan k factors --- .../spectroscopy/kfacs_Titan_300_keV.csv | 119 ------------------ 1 file changed, 119 deletions(-) delete mode 100644 src/quantem/spectroscopy/kfacs_Titan_300_keV.csv diff --git a/src/quantem/spectroscopy/kfacs_Titan_300_keV.csv b/src/quantem/spectroscopy/kfacs_Titan_300_keV.csv deleted file mode 100644 index 41431179..00000000 --- a/src/quantem/spectroscopy/kfacs_Titan_300_keV.csv +++ /dev/null @@ -1,119 +0,0 @@ -Element,K,L,M -H,0.000,0.000,0.000 -He,0.000,0.000,0.000 -Li,0.000,0.000,0.000 -Be,124.024,0.000,0.000 -B,5.043,0.000,0.000 -C,2.752,0.000,0.000 -N,1.667,0.000,0.000 -O,1.240,0.000,0.000 -F,1.179,0.000,0.000 -Ne,0.824,0.000,0.000 -Na,0.882,0.000,0.000 -Mg,0.939,0.000,0.000 -Al,1.002,0.000,0.000 -Si,1.000,0.000,0.000 -P,1.096,0.000,0.000 -S,1.039,0.000,0.000 -Cl,1.093,0.000,0.000 -Ar,1.188,0.000,0.000 -K,1.119,0.000,0.000 -Ca,1.200,31.337,0.000 -Sc,1.232,21.223,0.000 -Ti,1.249,15.857,0.000 -V,1.297,9.514,0.000 -Cr,1.314,5.235,0.000 -Mn,1.396,3.360,0.000 -Fe,1.440,2.116,0.000 -Co,1.552,1.922,0.000 -Ni,1.587,1.582,0.000 -Cu,1.773,1.523,0.000 -Zn,1.895,1.631,0.000 -Ga,2.112,2.026,0.000 -Ge,2.322,2.008,0.000 -As,2.561,2.109,0.000 -Se,2.923,2.156,0.000 -Br,3.255,2.135,0.000 -Kr,3.780,2.220,0.000 -Rb,4.364,2.227,0.000 -Sr,5.075,2.279,0.000 -Y,5.919,2.438,0.000 -Zr,7.018,2.458,0.000 -Nb,8.282,2.410,0.000 -Mo,10.021,2.464,0.000 -Tc,12.010,2.628,0.000 -Ru,14.672,2.595,0.000 -Rh,17.523,2.728,0.000 -Pd,21.416,2.847,0.000 -Ag,25.856,2.914,0.000 -Cd,31.748,3.052,0.000 -In,38.737,3.049,0.000 -Sn,47.291,3.427,0.000 -Sb,57.584,3.546,0.000 -Te,71.403,3.702,0.000 -I,83.812,3.679,0.000 -Xe,102.774,3.773,0.000 -Cs,122.868,3.798,0.000 -Ba,150.444,3.907,208.795 -La,179.606,3.894,32.017 -Ce,213.080,3.890,19.339 -Pr,252.760,3.879,14.609 -Nd,303.895,3.940,11.826 -Pm,357.415,3.948,9.249 -Sm,434.673,4.138,9.091 -Eu,513.444,4.153,6.834 -Gd,621.351,4.276,3.561 -Tb,733.816,4.282,5.246 -Dy,873.762,4.320,4.151 -Ho,1034.116,4.386,3.867 -Er,1218.828,4.407,3.773 -Tm,1432.640,4.451,2.730 -Yb,1702.171,4.387,2.623 -Lu,1998.379,4.643,1.892 -Hf,2360.330,4.751,1.831 -Ta,2763.368,4.815,1.764 -W,3246.236,4.901,1.740 -Re,3804.888,4.890,2.282 -Os,4484.376,4.915,0.765 -Ir,5218.560,4.899,1.332 -Pt,6103.642,4.986,1.500 -Au,7084.786,4.987,1.599 -Hg,8301.074,5.104,2.034 -Tl,9740.107,5.254,2.968 -Pb,11340.739,5.411,3.714 -Bi,13103.192,5.575,3.596 -Po,15023.039,5.713,3.644 -At,17307.534,5.871,3.624 -Rn,20926.528,6.421,3.874 -Fr,24049.540,6.657,3.727 -Ra,27822.490,6.973,3.636 -Ac,31894.971,7.301,3.523 -Th,37230.598,7.767,3.397 -Pa,42235.953,7.882,3.359 -U,49591.305,7.881,3.324 -Np,56282.882,8.047,3.247 -Pu,65884.009,8.698,3.342 -Am,74663.091,9.038,3.223 -Cm,86382.575,10.449,3.144 -Bk,98350.202,11.113,3.128 -Cf,113576.844,11.859,3.159 -Es,0,0,0 -Fm,0,0,0 -Md,0,0,0 -No,0,0,0 -Lr,0,0,0 -Rf,0,0,0 -Db,0,0,0 -Sg,0,0,0 -Bh,0,0,0 -Hs,0,0,0 -Mt,0,0,0 -Ds,0,0,0 -Rg,0,0,0 -Cn,0,0,0 -Uut,0,0,0 -Fl,0,0,0 -Uup,0,0,0 -Lv,0,0,0 -Uus,0,0,0 -Uuo,0,0,0 \ No newline at end of file From 88dafe27df786f4ba0aca341035aa4b34b06094b Mon Sep 17 00:00:00 2001 From: Sangoda <87961379+Sangoda@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:52:50 -0800 Subject: [PATCH 050/136] Added a dedicated EDS auto-ID flow (peak_autoid). Kept show_mean_spectrum focused on plotting only. Added show_energy_window_map. Improved saved model element handling. Saved model markers and peak detection markers added. Cleaned noisy console outputs. --- src/quantem/spectroscopy/dataset3deds.py | 982 ++++++++++++++++++ .../spectroscopy/dataset3dspectroscopy.py | 750 +++++-------- 2 files changed, 1264 insertions(+), 468 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index d868f012..fbf48367 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -1,3 +1,4 @@ +import builtins from typing import Any import matplotlib.pyplot as plt @@ -5,6 +6,7 @@ import torch import torch.nn as nn from numpy.typing import NDArray +from scipy.signal import find_peaks from quantem.core.visualization import show_2d from quantem.spectroscopy import Dataset3dspectroscopy @@ -75,6 +77,986 @@ def __init__( self._virtual_images = {} self.dataset_type = "EDS" + def peak_autoid( + self, + roi=None, + energy_range=None, + elements=None, + ignore_elements=None, + ignore_range=None, + threshold=5.0, + tolerance=0.15, + mask=None, + show_text=True, + snr_min=None, + snr_threshold=None, + distance_threshold_for_sample=0.05, + grid_peaks=None, + peaks=15, + return_details=False, + ): + """Auto-detect and label EDS peaks from the mean ROI spectrum. + + This method calls ``show_mean_spectrum`` to generate the baseline plot, then + overlays peak markers and element labels from X-ray line matching. + + Parameters + ---------- + return_details : bool, optional + If True, return full internal results (matches/confidence/peaks). + If False (default), return only figure and axes. + """ + if type(self).element_info is None: + type(self).load_element_info() + + if grid_peaks is None: + grid_peaks = {} + + if isinstance(elements, str): + elements = [elements] + if elements is not None and not isinstance(elements, (list, tuple, builtins.set)): + raise TypeError("elements must be None, a string, or a sequence of strings") + + def _line_matches_selector(line_name: str, selector: str) -> bool: + line = str(line_name).strip().lower() + token = str(selector).strip().lower() + if token in {"k", "l", "m"}: + return line.startswith(token) + return token in line + + def _parse_requested_elements_with_edges(element_specs): + if element_specs is None: + return None + + parsed = {} + for spec in element_specs: + raw = str(spec).strip() + if not raw: + continue + + tokens = raw.replace(",", " ").split() + if len(tokens) == 0: + continue + + element_key = str(tokens[0]) + selectors = [str(token).strip() for token in tokens[1:] if str(token).strip()] + + if element_key not in parsed: + parsed[element_key] = None if len(selectors) == 0 else builtins.set(selectors) + continue + + existing = parsed[element_key] + if existing is None: + continue + if len(selectors) == 0: + parsed[element_key] = None + else: + existing.update(selectors) + + return parsed if len(parsed) > 0 else None + + def _edge_filters_from_saved_model(model_elements): + if not isinstance(model_elements, dict) or len(model_elements) == 0: + return None + + parsed = {} + for element_name, lines_info in model_elements.items(): + element_key = str(element_name) + if isinstance(lines_info, dict) and len(lines_info) > 0: + parsed[element_key] = builtins.set(str(line_name) for line_name in lines_info.keys()) + else: + parsed[element_key] = None + + return parsed if len(parsed) > 0 else None + + requested_edge_filters = _parse_requested_elements_with_edges(elements) + saved_model_edge_filters = _edge_filters_from_saved_model(getattr(self, "model_elements", None)) + using_saved_model_elements = False + if requested_edge_filters is None and elements is None: + if saved_model_edge_filters is not None: + requested_edge_filters = saved_model_edge_filters + using_saved_model_elements = True + if requested_edge_filters is not None: + elements = list(requested_edge_filters.keys()) + + if isinstance(ignore_elements, str): + ignore_elements = [ignore_elements] + if ignore_elements is not None and not isinstance( + ignore_elements, (list, tuple, builtins.set) + ): + raise TypeError("ignore_elements must be None, a string, or a sequence of strings") + ignored_elements = ( + {str(element_name) for element_name in ignore_elements} + if ignore_elements is not None + else builtins.set() + ) + + fig, (ax_img, ax_spec) = self.show_mean_spectrum( + roi=roi, + energy_range=energy_range, + elements=elements, + ignore_range=ignore_range, + threshold=threshold, + tolerance=tolerance, + mask=mask, + show_lines=True, + show_text=show_text, + snr_min=snr_min, + snr_threshold=snr_threshold, + distance_threshold_for_sample=distance_threshold_for_sample, + grid_peaks=grid_peaks, + data_type="eds", + peaks=peaks, + show=False, + ) + + spec = self.calculate_mean_spectrum(roi, energy_range, ignore_range, mask) + dE = float(self.sampling[0]) + E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 + E = E0 + dE * np.arange(self.shape[0]) + + if energy_range is not None: + indices = np.where((E >= energy_range[0]) & (E <= energy_range[1]))[0] + E = E[indices] + + if ignore_range is None: + ignore_range = [0, 0.25] + + peak_indices, peak_properties = find_peaks(spec, height=0, distance=5) + peak_heights = peak_properties["peak_heights"] + + background_std = np.nanstd(spec[spec <= np.nanpercentile(spec, 50)]) + if not np.isfinite(background_std) or background_std <= 0: + background_std = np.nanstd(spec) + if not np.isfinite(background_std) or background_std <= 0: + background_std = 1.0 + + initial_snrs = [] + for _, height in zip(peak_indices, peak_heights): + initial_snrs.append(height / background_std) + + if len(initial_snrs) > 0: + snr_median = float(np.nanmedian(initial_snrs)) + snr_75th = float(np.nanpercentile(initial_snrs, 75)) + num_high_snr_peaks = int(np.sum(np.array(initial_snrs) > 50)) + else: + snr_median = 0.0 + snr_75th = 0.0 + num_high_snr_peaks = 0 + + if snr_min is None: + if len(initial_snrs) > 0: + snr_values = np.asarray(initial_snrs, dtype=float) + target_rank = max(1, min(int(peaks), int(snr_values.size))) + kth_largest_snr = float(np.sort(snr_values)[-target_rank]) + distribution_cutoff = float(np.percentile(snr_values, 55)) + adaptive_cutoff = min(distribution_cutoff, 0.9 * kth_largest_snr) + min_snr = float(np.clip(adaptive_cutoff, 8.0, 20.0)) + else: + min_snr = 8.0 + else: + min_snr = float(snr_min) + + if snr_threshold is None: + if num_high_snr_peaks > 50: + snr_threshold_for_sample = min(80.0, snr_75th * 1.2) + elif num_high_snr_peaks > 20: + snr_threshold_for_sample = min(60.0, snr_75th * 1.1) + elif num_high_snr_peaks < 10: + snr_threshold_for_sample = max(30.0, snr_75th * 0.8) + else: + snr_threshold_for_sample = 40.0 + else: + snr_threshold_for_sample = float(snr_threshold) + + all_candidate_peaks = [] + significant_peaks = [] + for peak_idx, height in zip(peak_indices, peak_heights): + peak_energy = E[peak_idx] + + if ignore_range is not None and len(ignore_range) == 2: + min_ignore, max_ignore = ignore_range + if min_ignore <= peak_energy <= max_ignore: + continue + + snr = height / background_std + all_candidate_peaks.append((peak_idx, height, peak_energy, snr)) + if snr >= min_snr: + significant_peaks.append((peak_idx, height, peak_energy, snr)) + + significant_peaks.sort(key=lambda item: item[3], reverse=True) + + display_peaks = significant_peaks[:peaks] + + all_info = type(self).element_info + peak_matches = [] + + def _line_shell(line_name): + line_upper = str(line_name).upper() + if line_upper.startswith("K"): + return "K" + if line_upper.startswith("L"): + return "L" + if line_upper.startswith("M"): + return "M" + return "?" + + def _peak_confidence(snr_value, line_weight, distance_value): + quality = max(0.0, 1.0 - (distance_value / max(tolerance, 1e-9))) + return np.log1p(max(float(snr_value), 0.0)) * (0.5 + float(line_weight)) * (0.5 + quality) + + def _line_allowed_for_element(element_name, line_name, edge_filters=None): + if edge_filters is None: + return True + + selectors = edge_filters.get(str(element_name)) + if selectors is None: + return True + + return any(_line_matches_selector(line_name, token) for token in selectors) + + def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): + best_distance = float("inf") + best_element = None + best_line_name = None + best_line_weight = 0.0 + + if not all_info: + return None + + for element_name, lines in all_info.items(): + if allowed_elements is not None and element_name not in allowed_elements: + continue + + for line_name, line_info in lines.items(): + if not _line_allowed_for_element(element_name, line_name, edge_filters): + continue + line_energy = line_info["energy (keV)"] + line_weight = line_info.get("weight", 0.5) + distance = abs(peak_energy - line_energy) + + is_m_line = "M" in line_name and not ("Ma" in line_name or "Mb" in line_name) + effective_tolerance = tolerance * 0.5 if is_m_line else tolerance + + if line_weight > 0.3 and distance <= effective_tolerance and distance < best_distance: + best_distance = distance + best_element = element_name + best_line_name = line_name + best_line_weight = line_weight + + if best_element is None: + return None + + return best_element, best_line_name, best_line_weight, best_distance + + if elements is not None: + search_elements = builtins.set(elements) + else: + search_elements = None + + for peak_idx, height, peak_energy, snr in display_peaks: + best_match_info = _best_line_match(peak_energy, search_elements, requested_edge_filters) + if best_match_info is not None: + best_element, best_line_name, best_line_weight, best_distance = best_match_info + best_element = str(best_element) + best_line_name = str(best_line_name) + best_match = f"{best_element} {best_line_name}" + match_confidence = _peak_confidence(snr, best_line_weight, best_distance) + peak_matches.append( + ( + peak_idx, + height, + peak_energy, + snr, + best_element, + best_match, + best_distance, + best_line_name, + best_line_weight, + match_confidence, + ) + ) + + detected_elements = builtins.set() + detected_sample_peaks = {} + element_confidence = {} + element_stats = {} + for ( + peak_idx, + height, + peak_energy, + snr, + element_name, + match_str, + distance, + line_name, + line_weight, + match_confidence, + ) in peak_matches: + element_name = str(element_name) + line_name = str(line_name) + if search_elements is not None and element_name not in search_elements: + continue + + shell = _line_shell(line_name) + if element_name not in element_stats: + element_stats[element_name] = { + "raw_conf": 0.0, + "shells": builtins.set(), + "lines": builtins.set(), + "strong_matches": 0, + "match_count": 0, + "best_match_conf": 0.0, + "best_match_energy": 0.0, + "best_match_distance": float("inf"), + "best_match_weight": 0.0, + "best_match_shell": "?", + } + + element_stats[element_name]["raw_conf"] += float(match_confidence) + element_stats[element_name]["shells"].add(shell) + element_stats[element_name]["lines"].add(str(line_name)) + element_stats[element_name]["match_count"] += 1 + if snr > snr_threshold_for_sample and distance < distance_threshold_for_sample: + element_stats[element_name]["strong_matches"] += 1 + + if float(match_confidence) > float(element_stats[element_name]["best_match_conf"]): + element_stats[element_name]["best_match_conf"] = float(match_confidence) + element_stats[element_name]["best_match_energy"] = float(peak_energy) + element_stats[element_name]["best_match_distance"] = float(distance) + element_stats[element_name]["best_match_weight"] = float(line_weight) + element_stats[element_name]["best_match_shell"] = shell + + for element_name, stats in element_stats.items(): + valid_shells = {shell for shell in stats["shells"] if shell in {"K", "L", "M"}} + num_shells = len(valid_shells) + num_lines = len(stats["lines"]) + has_major_shell = len(valid_shells.intersection({"K", "L"})) > 0 + + shell_bonus = 1.0 + 0.45 * max(0, num_shells - 1) + line_bonus = 1.0 + 0.15 * max(0, min(num_lines, 4) - 1) + strong_bonus = 1.0 + 0.20 * stats["strong_matches"] + major_bonus = 1.15 if has_major_shell else 1.0 + + confidence = stats["raw_conf"] * shell_bonus * line_bonus * strong_bonus * major_bonus + element_confidence[element_name] = float(confidence) + + if element_confidence: + conf_values = np.array(list(element_confidence.values()), dtype=float) + confidence_cutoff = max(np.percentile(conf_values, 45), 0.30 * float(conf_values.max())) + + for element_name, confidence in element_confidence.items(): + stats = element_stats[element_name] + valid_shells = {shell for shell in stats["shells"] if shell in {"K", "L", "M"}} + has_major_shell = len(valid_shells.intersection({"K", "L"})) > 0 + is_supported = confidence >= confidence_cutoff + is_near_cutoff_but_consistent = ( + confidence >= 0.75 * confidence_cutoff + and stats["match_count"] >= 2 + and has_major_shell + ) + is_high_energy_singleton_anchor = ( + stats["match_count"] == 1 + and stats["best_match_energy"] >= 6.0 + and stats["best_match_weight"] >= 0.8 + and stats["best_match_distance"] <= 0.35 * tolerance + and confidence >= 0.45 * confidence_cutoff + ) + + if is_supported or is_near_cutoff_but_consistent or is_high_energy_singleton_anchor: + detected_elements.add(element_name) + + refined_peak_matches = [] + raw_match_by_idx = {int(match[0]): match for match in peak_matches} + anchor_elements = { + element_name + for element_name in detected_elements + if element_name in element_stats + and element_stats[element_name].get("best_match_energy", 0.0) >= 6.0 + and element_stats[element_name].get("best_match_weight", 0.0) >= 0.8 + } + max_detected_conf = ( + max([element_confidence.get(el, 0.0) for el in detected_elements]) + if len(detected_elements) > 0 + else 0.0 + ) + + def _best_supported_line_match_with_prior( + peak_energy, snr, allowed_elements, edge_filters=None + ): + if not all_info or not allowed_elements: + return None + + best_tuple = None + best_score = -float("inf") + denom = max(float(max_detected_conf), 1e-9) + + for element_name, lines in all_info.items(): + if element_name not in allowed_elements: + continue + + prior = float(element_confidence.get(element_name, 0.0)) / denom + prior_factor = 1.0 + 0.5 * prior + + for line_name, line_info in lines.items(): + if not _line_allowed_for_element(element_name, line_name, edge_filters): + continue + line_energy = line_info["energy (keV)"] + line_weight = line_info.get("weight", 0.5) + distance = abs(peak_energy - line_energy) + shell = _line_shell(line_name) + + is_m_line = "M" in line_name and not ("Ma" in line_name or "Mb" in line_name) + effective_tolerance = tolerance * 0.5 if is_m_line else tolerance + + if line_weight <= 0.3 or distance > effective_tolerance: + continue + + local_conf = _peak_confidence(snr, line_weight, distance) + anchor_boost = 1.0 + if element_name in anchor_elements and shell == "M" and peak_energy <= 3.0: + anchor_boost = 2.2 + elif element_name in anchor_elements and shell in {"K", "L"}: + anchor_boost = 1.15 + + score = local_conf * prior_factor * anchor_boost + + if score > best_score: + best_score = score + best_tuple = (element_name, line_name, line_weight, distance) + + return best_tuple + + for peak_idx, height, peak_energy, snr in display_peaks: + match = raw_match_by_idx.get(int(peak_idx)) + + if detected_elements: + alt_match_info = _best_supported_line_match_with_prior( + peak_energy, snr, detected_elements, requested_edge_filters + ) + if alt_match_info is not None: + alt_element, alt_line_name, alt_line_weight, alt_distance = alt_match_info + alt_element = str(alt_element) + alt_line_name = str(alt_line_name) + alt_match_str = f"{alt_element} {alt_line_name}" + alt_conf = _peak_confidence(snr, alt_line_weight, alt_distance) + match = ( + peak_idx, + height, + peak_energy, + snr, + alt_element, + alt_match_str, + alt_distance, + alt_line_name, + alt_line_weight, + alt_conf, + ) + + if match is not None: + refined_peak_matches.append(match) + + peak_matches = refined_peak_matches + + # Keep confidence-based detected elements, but ensure they still have + # at least one matched peak after rematching. + matched_elements = {str(match[4]) for match in peak_matches} + detected_elements = { + str(element_name) for element_name in detected_elements if str(element_name) in matched_elements + } + if ignored_elements: + detected_elements = { + str(element_name) + for element_name in detected_elements + if str(element_name) not in ignored_elements + } + + refined_match_by_idx = {int(match[0]): match for match in peak_matches} + + displayed_peak_count = len(display_peaks) + total_over_snr_peak_count = len(significant_peaks) + + candidate_elements = sorted( + str(element_name) + for element_name in builtins.set(element_stats.keys()) + if str(element_name) not in detected_elements + ) + + def _format_saved_model_elements(edge_filters): + if edge_filters is None: + return "None" + + formatted = [] + for element_name in sorted(str(name) for name in edge_filters.keys()): + selectors = edge_filters.get(element_name) + if selectors is None or len(selectors) == 0: + formatted.append(f"{element_name} [all]") + continue + + selector_names = sorted(str(token) for token in selectors) + formatted.append(f"{element_name} [{', '.join(selector_names)}]") + + return "\n".join(formatted) if len(formatted) > 0 else "None" + + def _format_elements_with_lines(element_names): + formatted = [] + for element_name in sorted(str(name) for name in element_names): + stats = element_stats.get(str(element_name), {}) + lines = stats.get("lines", builtins.set()) + line_names = sorted(str(line_name) for line_name in lines) + if len(line_names) > 0: + formatted.append(f"{element_name} [{', '.join(line_names)}]") + else: + formatted.append(str(element_name)) + return ", ".join(formatted) + + model_elements_header = "Saved Model Elements (Plotted):\n" if using_saved_model_elements else "Saved Model Elements (Not Plotted When Elements Specified):\n" + print(f"\n{model_elements_header} {_format_saved_model_elements(saved_model_edge_filters)}") + + if detected_elements: + print(f"\nAutodetected: {_format_elements_with_lines(detected_elements)}") + else: + print("\nAutodetected: None") + if candidate_elements: + print(f"Possible: {_format_elements_with_lines(candidate_elements)}") + else: + print("Possible: None") + + # Stable per-element colors (same element color across K/L/M lines) + elements_for_color = builtins.set(detected_elements) + if search_elements is not None: + elements_for_color.update(str(el) for el in search_elements) + elements_for_color.update(str(match[4]) for match in peak_matches) + + sorted_elements_for_colors = sorted(elements_for_color) + high_contrast_palette = [ + "#1f77b4", # blue + "#d62728", # red + "#2ca02c", # green + "#9467bd", # purple + "#ff7f0e", # orange + "#8c564b", # brown + "#e377c2", # pink + "#17becf", # cyan + "#bcbd22", # olive + "#7f7f7f", # gray + "#003f5c", # dark blue + "#7a5195", # deep violet + "#ef5675", # strong rose + "#ffa600", # amber + "#2f4b7c", # slate blue + ] + color_palette = [ + high_contrast_palette[i % len(high_contrast_palette)] + for i in range(max(1, len(sorted_elements_for_colors))) + ] + element_color_map = { + element: color_palette[i] + for i, element in enumerate(sorted_elements_for_colors) + } + + table_rows = [] + matched_row_count = 0 + for peak_idx, height, peak_energy, snr in display_peaks: + match = refined_match_by_idx.get(int(peak_idx)) + if match is not None: + table_rows.append((peak_energy, height, snr, str(match[5]))) + matched_row_count += 1 + else: + row_label = "Unmatched" if search_elements is not None else "Unknown" + table_rows.append((peak_energy, height, snr, row_label)) + + sorted_table_rows = sorted(table_rows, key=lambda item: item[0]) + + for ( + peak_idx, + height, + peak_energy, + snr, + element_name, + match_str, + distance, + line_name, + line_weight, + match_confidence, + ) in peak_matches: + if element_name in detected_elements: + detected_sample_peaks[peak_energy] = True + + filtered_sample_peaks = {} + for peak_energy in detected_sample_peaks: + for ( + peak_idx, + height, + matched_energy, + snr, + element_name, + match_str, + distance, + line_name, + line_weight, + match_confidence, + ) in peak_matches: + if abs(matched_energy - peak_energy) < 0.001 and element_name in detected_elements: + filtered_sample_peaks[peak_energy] = True + break + detected_sample_peaks = filtered_sample_peaks + + y_min = float(np.nanmin(spec)) if len(spec) > 0 else 0.0 + y_max = float(np.nanmax(spec)) if len(spec) > 0 else 1.0 + y_span = max(1e-9, y_max - y_min) + y_scale = max(y_span, abs(y_max), 1.0) + y_dot = -0.04 * y_scale + + def _infer_requested_element_for_color(peak_energy): + if search_elements is None or not all_info: + return None + + best_element = None + best_distance = float("inf") + for element_name in search_elements: + element_key = str(element_name) + lines_info = all_info.get(element_key, {}) + if not isinstance(lines_info, dict): + continue + for line_name, line_info in lines_info.items(): + if not _line_allowed_for_element( + element_key, line_name, requested_edge_filters + ): + continue + if not isinstance(line_info, dict): + continue + line_energy = line_info.get("energy (keV)") + if line_energy is None: + continue + try: + distance = abs(float(peak_energy) - float(line_energy)) + except (TypeError, ValueError): + continue + if distance < best_distance: + best_distance = distance + best_element = element_key + + return best_element + + for peak_idx, height, peak_energy, snr in display_peaks: + is_sample = detected_sample_peaks.get(peak_energy, False) + match = refined_match_by_idx.get(int(peak_idx)) + if match is not None: + peak_element = match[4] + line_color = element_color_map.get(peak_element, "red") + else: + inferred_element = _infer_requested_element_for_color(peak_energy) + if inferred_element is not None: + line_color = element_color_map.get(str(inferred_element), "red") + else: + line_color = "red" + + if is_sample: + ax_spec.axvline( + peak_energy, + color=line_color, + linestyle="-", + alpha=0.5, + linewidth=1.5, + ) + else: + ax_spec.plot( + [peak_energy], + [y_dot], + marker="|", + markersize=4, + color="gray", + alpha=0.8, + linestyle="None", + ) + + if show_text and is_sample: + y_pos = height * 0.7 + is_grid_peak = False + for grid_element, grid_energy in grid_peaks.items(): + if abs(peak_energy - grid_energy) < 0.1: + ax_spec.text( + peak_energy, + y_pos, + f"{grid_element}\n(grid)", + ha="center", + va="bottom", + fontsize=8, + color="gray", + style="italic", + ) + is_grid_peak = True + break + if is_grid_peak: + print(f"Peak at {peak_energy} keV may come from the grid.") + + current_bottom, current_top = ax_spec.get_ylim() + dot_padding = 0.02 * y_scale + target_bottom = min(current_bottom, y_dot - dot_padding, -dot_padding) + ax_spec.set_ylim(bottom=target_bottom, top=current_top) + + # If elements were explicitly requested, overlay reference X-ray lines from the + # database even when they are not peak-matched by auto-id. + dotted_reference_rows = [] + if search_elements is not None: + energy_min = float(np.min(E)) + energy_max = float(np.max(E)) + reference_label_counts = {} + existing_matches_by_element = {} + for ( + peak_idx, + height, + peak_energy, + snr, + element_name, + match_str, + distance, + line_name, + line_weight, + match_confidence, + ) in peak_matches: + element_key = str(element_name) + if element_key not in existing_matches_by_element: + existing_matches_by_element[element_key] = [] + existing_matches_by_element[element_key].append(float(peak_energy)) + + y_top = float(np.nanmax(spec)) if len(spec) > 0 else 1.0 + y_top = max(y_top, 1.0) + + for element_name in sorted(search_elements): + element_key = str(element_name) + lines_info = all_info.get(element_key, {}) if all_info is not None else {} + if not isinstance(lines_info, dict) or len(lines_info) == 0: + continue + + candidate_lines = [] + for line_name, line_info in lines_info.items(): + if not _line_allowed_for_element(element_key, line_name, requested_edge_filters): + continue + if not isinstance(line_info, dict): + continue + energy_val = line_info.get("energy (keV)") + if energy_val is None: + continue + try: + line_energy = float(energy_val) + except (TypeError, ValueError): + continue + if not (energy_min <= line_energy <= energy_max): + continue + + line_weight = float(line_info.get("weight", 0.0)) + candidate_lines.append((line_name, line_energy, line_weight)) + + if len(candidate_lines) == 0: + continue + + # Keep meaningful requested-element lines while avoiding excessive clutter. + filtered_lines = [line for line in candidate_lines if line[2] >= 0.05] + if len(filtered_lines) == 0: + filtered_lines = sorted(candidate_lines, key=lambda item: item[2], reverse=True)[:1] + else: + filtered_lines = sorted(filtered_lines, key=lambda item: item[2], reverse=True)[:6] + + for line_name, line_energy, line_weight in filtered_lines: + matched_energies = existing_matches_by_element.get(element_key, []) + if any(abs(line_energy - matched_energy) <= max(0.05, 0.5 * tolerance) for matched_energy in matched_energies): + continue + + line_color = element_color_map.get(element_key, "black") + ax_spec.axvline( + line_energy, + color=line_color, + linestyle="--", + alpha=0.3, + linewidth=1.2, + ) + dotted_reference_rows.append((element_key, str(line_name), float(line_energy))) + label_index = reference_label_counts.get(element_key, 0) + reference_label_counts[element_key] = label_index + 1 + y_label = y_top * (0.95 - 0.05 * (label_index % 3)) + ax_spec.text( + line_energy, + y_label, + f"{element_key} {line_name}", + ha="center", + va="bottom", + fontsize=8, + color=line_color, + rotation=90, + alpha=0.8, + ) + + if detected_elements: + labels_to_plot = [] + for ( + peak_idx, + height, + peak_energy, + snr, + element_name, + match_str, + distance, + line_name, + line_weight, + match_confidence, + ) in peak_matches: + if element_name in detected_elements and detected_sample_peaks.get(peak_energy, False): + line_name = match_str.split()[-1] if match_str else "" + label_text = f"{element_name} {line_name}" if line_name else element_name + color = element_color_map.get(element_name, "black") + labels_to_plot.append((peak_energy, label_text, color, height)) + + labels_to_plot.sort(key=lambda item: item[0]) + + if show_text: + # Merge labels that are too close in energy into a single line of text + # to avoid unreadable overlap. + overlap_threshold = max(0.10, 0.7 * float(tolerance)) + same_label_overlap_threshold = max(0.16, 1.1 * float(tolerance)) + same_color_overlap_threshold = max(0.22, 1.6 * float(tolerance)) + grouped_labels = [] + current_group = [] + + def _same_color(c1, c2): + try: + return np.allclose(np.asarray(c1), np.asarray(c2)) + except Exception: + return str(c1) == str(c2) + + for label in labels_to_plot: + if not current_group: + current_group.append(label) + continue + + prev_energy = current_group[-1][0] + prev_text = current_group[-1][1] + prev_color = current_group[-1][2] + energy_delta = abs(label[0] - prev_energy) + + should_group = energy_delta <= overlap_threshold + if not should_group and label[1] == prev_text: + should_group = energy_delta <= same_label_overlap_threshold + if not should_group and _same_color(label[2], prev_color): + should_group = energy_delta <= same_color_overlap_threshold + + if should_group: + current_group.append(label) + else: + grouped_labels.append(current_group) + current_group = [label] + + if current_group: + grouped_labels.append(current_group) + + label_vertical_offset = max(0.03 * y_scale, 0.08) + grouped_bucket_step = max(0.02 * y_scale, 0.05) + + for group in grouped_labels: + if len(group) == 1: + peak_energy, label_text, color, height = group[0] + y_pos = height + label_vertical_offset + ax_spec.text( + peak_energy, + y_pos, + label_text, + ha="center", + va="bottom", + fontsize=10, + color=color, + weight="bold", + rotation=90, + ) + else: + x_pos = float(np.mean([item[0] for item in group])) + y_pos = max(item[3] for item in group) + label_vertical_offset + merged_text = ", ".join(item[1] for item in group) + first_color = group[0][2] + all_same_color = all( + _same_color(item[2], first_color) for item in group + ) + if all_same_color: + ax_spec.text( + x_pos, + y_pos, + merged_text, + ha="center", + va="bottom", + fontsize=9, + color=group[0][2], + weight="bold", + rotation=90, + ) + else: + # Keep grouped behavior, but color by respective line colors. + # Build one comma-list per color and stack them tightly. + color_buckets = [] + for _, label_text, label_color, _ in group: + matched_bucket = None + for bucket in color_buckets: + if _same_color(bucket["color"], label_color): + matched_bucket = bucket + break + + if matched_bucket is None: + color_buckets.append( + {"color": label_color, "labels": [label_text]} + ) + else: + matched_bucket["labels"].append(label_text) + + for bucket_index, bucket in enumerate(color_buckets): + bucket_text = ", ".join(bucket["labels"]) + y_offset = bucket_index * grouped_bucket_step + ax_spec.text( + x_pos, + y_pos + y_offset, + bucket_text, + ha="center", + va="bottom", + fontsize=9, + color=bucket["color"], + weight="bold", + rotation=90, + ) + + fig.tight_layout() + plt.show() + + print(f"{'Energy (keV)':<12} {'Intensity':<12} {'SNR':<8} {'Best Match':<25}") + print("-" * 60) + for peak_energy, height, snr, best_match in sorted_table_rows: + print(f"{peak_energy:<12.3f} {height:<12.1f} {snr:<8.1f} {best_match:<25}") + if dotted_reference_rows: + print("-" * 60) + for element_key, line_name, line_energy in sorted( + dotted_reference_rows, key=lambda item: item[2] + ): + print( + f"{line_energy:<12.3f} {'-':<12} {'-':<8} " + f"{(element_key + ' ' + line_name + ' (ref)'):<25}" + ) + print("-" * 60) + print( + f"{displayed_peak_count} of {total_over_snr_peak_count} peaks above " + f"snr_min={min_snr:.1f}, snr_threshold={snr_threshold_for_sample:.1f} displayed.\n" + ) + + if return_details: + return { + "figure": fig, + "axes": (ax_img, ax_spec), + "detected_elements": sorted(detected_elements), + "element_confidence": element_confidence, + "display_peaks": display_peaks, + "peak_matches": peak_matches, + "snr_min": min_snr, + "snr_threshold": snr_threshold_for_sample, + } + + return fig, (ax_img, ax_spec) + def _fit_mean_model_pytorch( self, energy_axis, diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 8e909a8a..36231c09 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -6,7 +6,6 @@ import numpy as np from matplotlib.patches import Rectangle from numpy.typing import NDArray -from scipy.signal import find_peaks from sklearn.decomposition import PCA from quantem.core.datastructures.dataset3d import Dataset3d @@ -249,29 +248,79 @@ def add_elements_to_model(self, elements): Parameters ---------- elements : list or str - Element symbol(s) to add to the model. Can be a single string (e.g., 'Al') - or list of symbols (e.g., ['Au', 'Cu', 'Si']). + Element/line spec(s) to add. Examples: + - 'Al' (all lines for Al) + - 'Te La' (only Te La line) + - ['Au Ma', 'Te La', 'Si'] """ # Load element info if not already loaded if type(self).element_info is None: type(self).load_element_info() - # Convert to list if single string provided - if isinstance(elements, str): - elements = [elements] + def _normalize_specs(specs): + if isinstance(specs, str): + return [s.strip() for s in specs.split(",") if s.strip()] + if isinstance(specs, (list, tuple, set)): + out = [] + for spec in specs: + out.extend([s.strip() for s in str(spec).split(",") if s.strip()]) + return out + raise TypeError("elements must be a string or a sequence of strings") + + def _resolve_element_key(all_info, token): + token_norm = str(token).strip().lower() + for key in all_info.keys(): + if str(key).lower() == token_norm: + return key + return None + + def _select_lines(line_dict, selectors): + if not isinstance(line_dict, dict): + return {} + if selectors is None or len(selectors) == 0: + return dict(line_dict) + + selector_norm = [str(sel).strip().lower() for sel in selectors if str(sel).strip()] + selected = {} + for line_name, line_info in line_dict.items(): + line_norm = str(line_name).strip().lower() + if any(line_norm == sel or line_norm.startswith(sel) for sel in selector_norm): + selected[line_name] = line_info + return selected - # Convert list of element symbols to dict using class element_info - if isinstance(elements, list): - all_info = type(self).element_info - if all_info is not None: - # Initialize model_elements as dict if it doesn't exist - if self.model_elements is None: - self.model_elements = {} + all_info = type(self).element_info + if all_info is None: + return - # Add new elements to existing model - for el in elements: - if el in all_info: - self.model_elements[el] = all_info[el] + specs = _normalize_specs(elements) + if self.model_elements is None: + self.model_elements = {} + + for spec in specs: + tokens = str(spec).split() + if len(tokens) == 0: + continue + + element_key = _resolve_element_key(all_info, tokens[0]) + if element_key is None: + continue + + selectors = tokens[1:] + selected_lines = _select_lines(all_info[element_key], selectors) + if len(selected_lines) == 0: + continue + + if len(selectors) == 0: + self.model_elements[element_key] = selected_lines + else: + existing = self.model_elements.get(element_key) + if not isinstance(existing, dict): + existing = {} + existing.update(selected_lines) + self.model_elements[element_key] = existing + + if len(self.model_elements) == 0: + self.model_elements = None def remove_elements_from_model(self, elements): """ @@ -280,18 +329,58 @@ def remove_elements_from_model(self, elements): Parameters ---------- elements : list or str - Element symbol(s) to remove. Can be a single string (e.g., 'Al') - or list of symbols (e.g., ['Au', 'Cu']). + Element/line spec(s) to remove. Examples: + - 'Al' (remove all Al lines) + - 'Te La' (remove only Te La line) + - ['Au Ma', 'Te La'] """ if self.model_elements is None: return - if isinstance(elements, str): - elements = [elements] + def _normalize_specs(specs): + if isinstance(specs, str): + return [s.strip() for s in specs.split(",") if s.strip()] + if isinstance(specs, (list, tuple, set)): + out = [] + for spec in specs: + out.extend([s.strip() for s in str(spec).split(",") if s.strip()]) + return out + raise TypeError("elements must be a string or a sequence of strings") + + def _resolve_element_key(model_elements, token): + token_norm = str(token).strip().lower() + for key in model_elements.keys(): + if str(key).lower() == token_norm: + return key + return None + + specs = _normalize_specs(elements) + for spec in specs: + tokens = str(spec).split() + if len(tokens) == 0: + continue + + element_key = _resolve_element_key(self.model_elements, tokens[0]) + if element_key is None: + continue + + selectors = [str(token).strip().lower() for token in tokens[1:] if str(token).strip()] + if len(selectors) == 0: + self.model_elements.pop(element_key, None) + continue + + lines_info = self.model_elements.get(element_key) + if not isinstance(lines_info, dict): + self.model_elements.pop(element_key, None) + continue + + for line_name in list(lines_info.keys()): + line_norm = str(line_name).strip().lower() + if any(line_norm == sel or line_norm.startswith(sel) for sel in selectors): + lines_info.pop(line_name, None) - if isinstance(elements, list): - for el in elements: - self.model_elements.pop(el, None) + if len(lines_info) == 0: + self.model_elements.pop(element_key, None) if len(self.model_elements) == 0: self.model_elements = None @@ -1217,9 +1306,6 @@ def calculate_mean_spectrum( if attach_mean_spectrum: self.add_spectrum_to_data(spec, E) - print( - f"Spectrum recorded to index {len(self.attached_spectra) - 1} of attached_spectra in {self}" - ) return spec @@ -1240,9 +1326,10 @@ def show_mean_spectrum( grid_peaks=None, data_type="eds", peaks=15, + show=True, ): """ - Make and show a spectrum plot from a spatial ROI in a 3D EDS cube (E, Y, X). + Plot the mean spectrum from a spatial ROI in a 3D spectroscopy cube (E, Y, X). Parameters ---------- @@ -1257,52 +1344,14 @@ def show_mean_spectrum( If roi=None, uses full image. Can also be [y, x] for single pixel. energy_range : list or tuple, optional Energy range to display as [min_energy, max_energy] in keV. - elements : list or dict, optional - Element symbols to plot as X-ray lines (e.g., ['Fe', 'Pt']). - If None, automatically detects elements from statistically significant peaks. ignore_range : list or tuple, optional - Energy range to ignore during peak detection as [min_energy, max_energy] in keV. - E.g., [0, 2.5] ignores 0-2.5 keV during auto-detection. - threshold : float, optional - Statistical significance threshold (multiple of background noise). Default: 5.0 - tolerance : float, optional - Energy tolerance for X-ray line matching in keV. Default: 0.15 + Ignored in this plotting-only method. Kept for backward compatibility. mask : array, optional Boolean mask for pixel selection. - show_lines : bool, optional - Whether to show element lines and/or auto-detected peaks. - Auto-enabled if elements are specified or auto-detection is used. - show_text : bool, optional - Whether to show text labels for detected elements. Default: True. - When False, vertical lines are still shown but element labels are hidden. - snr_min : float, optional - Minimum SNR threshold for detecting any peak. If None, automatically determined - from peak distribution (typically 20-30 based on data characteristics). - Lower values detect more peaks, higher values are more selective. - snr_threshold : float, optional - Minimum SNR for identifying a peak as a sample element. - If None, automatically determined based on peak statistics. For sparse spectra - (few strong peaks), uses lower threshold (~30). For dense spectra (many peaks), - uses higher threshold (~50-80) to filter noise. - distance_threshold_for_sample : float, optional - Maximum energy distance (keV) between detected peak and characteristic line - for identifying as a sample element. Default: 0.05. Stricter values (smaller) - reduce false positives. - grid_peaks : dict, optional - Dictionary of known grid/support peaks for labeling, e.g., {'C': 0.260, 'Cu': 8.020}. - Default: {} (empty). Provide grid materials as needed. - background_subtraction : str, optional - Background subtraction method. Options: - - 'none' (default): No background subtraction - - 'auto': Automatically choose best method for data_type (EDS -> power-law, EELS -> iterative Gaussian) - - 'powerlaw': Power-law background (best for EDS, suitable for Bremsstrahlung) - - 'iterative': Iterative Gaussian fitting (best for EELS, isolates continuum) + show : bool, optional + If True, display the plot with ``plt.show()``. Set False to add overlays before showing. data_type : str, optional Type of spectroscopy data. Options: 'eds' (default) or 'eels'. - Used with background_subtraction='auto' to select optimal method. - peaks : int, optional - Maximum number of peaks to display in the output table and plot as vertical lines. - Default: 15. Limits output to peaks with highest SNR (most statistically significant). Returns ------- @@ -1310,15 +1359,6 @@ def show_mean_spectrum( The Matplotlib Figure and Axes of the spectrum plot. """ - # Set defaults for detection parameters - - # Ensure spectral line database is available for peak matching - if type(self).element_info is None: - type(self).load_element_info() - - if grid_peaks is None: - grid_peaks = {} - # ADJUST ROI BASED ON GIVEN FLAGS ----------------------------------------------- # Parse ROI parameter if roi is None: @@ -1389,7 +1429,8 @@ def show_mean_spectrum( plt.colorbar(im, ax=ax_img, label="Intensity") # RIGHT PLOT: Show spectrum - ax_spec.plot(E, spec, linewidth=1.5) + spectrum_line, = ax_spec.plot(E, spec, linewidth=1.5) + spectrum_color = spectrum_line.get_color() if data_type == "eds": ax_spec.set_xlabel("Energy (keV)") else: @@ -1398,403 +1439,179 @@ def show_mean_spectrum( ax_spec.set_title(f"Spectrum from ROI [{y}:{y + dy}, {x}:{x + dx}]") ax_spec.grid(True, alpha=0.1) - # HANDLE SHOW_LINES FLAG AND MODEL ELEMENTS ------------------------------------ - # Auto-enable show_lines if elements are specified or if auto-detection is needed - if show_lines is None: - show_lines = (elements is not None) or ( - hasattr(self, "model_elements") and self.model_elements is not None + if show_lines and isinstance(self.model_elements, dict) and len(self.model_elements) > 0: + x_min = float(np.nanmin(E)) if E.size > 0 else None + x_max = float(np.nanmax(E)) if E.size > 0 else None + model_marker_energies = [] + + energy_keys = ( + "energy (keV)", + "energy_keV", + "energy (eV)", + "onset (eV)", + "edge (eV)", + "energy", ) - # Use model elements if no elements specified but model has elements - if ( - elements is None - and hasattr(self, "model_elements") - and self.model_elements is not None - ): - elements = list(self.model_elements.keys()) - - # Skip all line plotting if show_lines is False - if not show_lines: - fig.tight_layout() - plt.show() - return fig, (ax_img, ax_spec) + for _, lines_info in self.model_elements.items(): + if not isinstance(lines_info, dict): + continue - # AUTO-DETECT PEAKS AND MATCH TO DATABASE ------------------- - if elements is None or (isinstance(elements, list) and len(elements) > 0): - # elements is either None (full auto-detection) or a list of specific elements to search for - try: - # Step 1: Find all potential peaks - peak_indices, peak_properties = find_peaks(spec, height=0, distance=5) - peak_heights = peak_properties["peak_heights"] - - # Step 2: Calculate background statistics - # Use nanpercentile to handle any NaN values in the spectrum - background_std = np.nanstd(spec[spec <= np.nanpercentile(spec, 50)]) - - # Step 3: Determine dynamic SNR thresholds if not provided - # Calculate initial SNR for all peaks to assess data characteristics - initial_snrs = [] - for peak_idx, height in zip(peak_indices, peak_heights): - snr = height / background_std if background_std > 0 else float("inf") - initial_snrs.append(snr) - - # Calculate statistics of SNR distribution - if len(initial_snrs) > 0: - snr_median = np.nanmedian(initial_snrs) - snr_75th = np.nanpercentile(initial_snrs, 75) - num_high_snr_peaks = np.sum(np.array(initial_snrs) > 50) - else: - snr_median = 0 - snr_75th = 0 - num_high_snr_peaks = 0 - - # Set snr_min (detection threshold) if not provided - if snr_min is None: - # Use adaptive threshold based on SNR distribution - # For noisy data with many weak peaks, use higher threshold - if snr_median > 30: - min_snr = 25.0 # Many peaks -> slightly higher threshold - else: - min_snr = 20.0 # Sparse peaks -> standard threshold - else: - min_snr = snr_min - - # Set snr_threshold (sample element threshold) if not provided - if snr_threshold is None: - # Adaptive threshold based on peak density and SNR distribution - # Sparse spectra (few strong peaks) -> lower threshold - # Dense spectra (many peaks) -> higher threshold to filter noise - if num_high_snr_peaks > 50: # Many high-SNR peaks (dense spectrum like map1) - snr_threshold_for_sample = min(80.0, snr_75th * 1.2) - elif num_high_snr_peaks > 20: # Moderate number of peaks - snr_threshold_for_sample = min(60.0, snr_75th * 1.1) - elif num_high_snr_peaks < 10: # Few peaks (sparse spectrum like Bare) - snr_threshold_for_sample = max(30.0, snr_75th * 0.8) - else: # Default case - snr_threshold_for_sample = 40.0 - - print( - f"Auto-determined thresholds: snr_min={min_snr:.1f}, snr_threshold={snr_threshold_for_sample:.1f}" - ) - print( - f" (Based on: median_snr={snr_median:.1f}, 75th_percentile={snr_75th:.1f}, high_snr_peaks={num_high_snr_peaks})" - ) - else: - snr_threshold_for_sample = snr_threshold - - # Step 4: Filter peaks by SNR - significant_peaks = [] - for peak_idx, height in zip(peak_indices, peak_heights): - peak_energy = E[peak_idx] - - # Skip peaks in ignore range - if ignore_range is not None and len(ignore_range) == 2: - min_ignore, max_ignore = ignore_range - if min_ignore <= peak_energy <= max_ignore: - continue - - snr = height / background_std if background_std > 0 else float("inf") - - # Keep peaks with good SNR - if snr >= min_snr: - significant_peaks.append((peak_idx, height, peak_energy, snr)) - - if len(significant_peaks) > 0: - # Sort by SNR (signal-to-noise ratio) for most statistically significant peaks - significant_peaks.sort(key=lambda x: x[3], reverse=True) - - # Limit to top N peaks for display - display_peaks = significant_peaks[:peaks] - - # Match detected peaks to xray_lines.json - all_info = type(self).element_info - peak_matches = [] # List of (peak_idx, height, peak_energy, snr, element, match_string, distance) - - # If specific elements are requested, filter the database to only those - if elements is not None and isinstance(elements, list): - search_elements = set(elements) - search_mode = f"for {search_elements}" - else: - search_elements = None - search_mode = "for all elements" - - print( - f"\nDetected {len(significant_peaks)} peaks (SNR >= {min_snr:.1f}) {search_mode}" - ) - if len(significant_peaks) > peaks: - print(f"Showing top {peaks} peaks by SNR (most statistically significant)") - print(f"{'Energy (keV)':<12} {'Intensity':<12} {'SNR':<8} {'Best Match':<25}") - print("-" * 60) - - # For each detected peak, find the best match in the database - for peak_idx, height, peak_energy, snr in display_peaks: - best_match = None - best_distance = float("inf") - best_element = None - - # Search through elements in database - if all_info: - for elem, lines in all_info.items(): - # If specific elements requested, only search those - if search_elements is not None and elem not in search_elements: - continue - - for line_name, line_info in lines.items(): - line_energy = line_info["energy (keV)"] - line_weight = line_info.get("weight", 0.5) - distance = abs(peak_energy - line_energy) - - # Prioritize K and L lines over M lines for element identification - # M-lines are very weak and prone to false positives at low energies - is_m_line = "M" in line_name and not ( - "Ma" in line_name or "Mb" in line_name - ) - - # Match to characteristic lines within tolerance - # Require weight > 0.3 (filters weakest M-lines) - # Penalize M-line matches by requiring closer distance - effective_tolerance = ( - tolerance * 0.5 if is_m_line else tolerance - ) - - if ( - line_weight > 0.3 - and distance <= effective_tolerance - and distance < best_distance - ): - best_distance = distance - best_match = f"{elem} {line_name}" - best_element = elem - - if best_match: - peak_matches.append( - ( - peak_idx, - height, - peak_energy, - snr, - best_element, - best_match, - best_distance, - ) - ) - print( - f"{peak_energy:<12.3f} {height:<12.1f} {snr:<8.1f} {best_match:<25}" - ) - else: - print( - f"{peak_energy:<12.3f} {height:<12.1f} {snr:<8.1f} {'Unknown':<25}" - ) - - print("-" * 60) - - # Detect elements: use only the strongest peaks that match VERY well - # Strategy: keep only peaks that: - # 1. Match a characteristic line within distance_threshold_for_sample (very tight tolerance) - # 2. Have SNR > snr_threshold_for_sample (strong peaks) - detected_elements = set() - detected_sample_peaks = {} # Map peak_energy -> is_sample_element for line styling - - for ( - peak_idx, - height, - peak_energy, - snr, - element, - match_str, - distance, - ) in peak_matches: - # Very strict criteria for element detection - if ( - snr > snr_threshold_for_sample # Strong peak - and distance < distance_threshold_for_sample - ): # Very close match to characteristic line - # If specific elements requested, only keep those - if search_elements is not None: - if element in search_elements: - detected_elements.add(element) - detected_sample_peaks[peak_energy] = True - else: - detected_elements.add(element) - detected_sample_peaks[peak_energy] = True - - # MULTI-PEAK COHERENCE CHECK: Filter out elements with only single weak matches - # Count DISTINCT characteristic lines for each element (Ka vs Kb, La vs Lb, etc.) - element_line_types = {} # element -> set of line types (e.g., 'Ka', 'Lb') - element_total_snr = {} - element_has_major_lines = {} # Track if element has K or L lines (not just M) - - for ( - peak_idx, - height, - peak_energy, - snr, - element, - match_str, - distance, - ) in peak_matches: - # Count ALL good matches for each element (not just sample-quality ones) - if distance < tolerance * 2: # Within 2x tolerance for counting - if element not in element_line_types: - element_line_types[element] = set() - element_total_snr[element] = 0 - element_has_major_lines[element] = False - - # Extract line type from match_str (e.g., "Pt La" -> "La") - line_type = match_str.split()[-1] if match_str else "" - element_line_types[element].add(line_type) - element_total_snr[element] += snr - - # Check if this is a major line (K or L series) - if any(x in line_type for x in ["Ka", "Kb", "La", "Lb", "Lg"]): - element_has_major_lines[element] = True - - # Filter detected_elements: keep only if multiple DISTINCT lines OR very high SNR - # CRITICAL: Reject elements with only M-lines (no K or L confirmation) - filtered_detected_elements = set() - for element in detected_elements: - distinct_line_count = len(element_line_types.get(element, set())) - total_snr = element_total_snr.get(element, 0) - avg_snr = total_snr / distinct_line_count if distinct_line_count > 0 else 0 - has_major_lines = element_has_major_lines.get(element, False) - - # Keep element if: - # - Has K or L lines (not just M-lines) - required for heavy elements - # - AND (has 2+ DISTINCT lines OR 1 line with very high SNR >70) - if has_major_lines and (distinct_line_count >= 2 or avg_snr > 70): - filtered_detected_elements.add(element) - - # Update detected_elements with filtered set - detected_elements = filtered_detected_elements - - # Update detected_sample_peaks to only include filtered elements - filtered_sample_peaks = {} - for peak_energy, is_sample in detected_sample_peaks.items(): - # Find which element this peak belongs to - for ( - peak_idx, - height, - pe, - snr, - element, - match_str, - distance, - ) in peak_matches: - if abs(pe - peak_energy) < 0.001 and element in detected_elements: - filtered_sample_peaks[peak_energy] = is_sample + for _, line_info in lines_info.items(): + if not isinstance(line_info, dict): + continue + + line_energy = None + for key in energy_keys: + if key in line_info: + try: + line_energy = float(line_info[key]) break - detected_sample_peaks = filtered_sample_peaks + except (TypeError, ValueError): + continue + + if line_energy is None: + continue + if x_min is not None and (line_energy < x_min or line_energy > x_max): + continue + + model_marker_energies.append(line_energy) + + if len(model_marker_energies) > 0: + marker_x = np.unique(np.asarray(model_marker_energies, dtype=float)) + y_min = float(np.nanmin(spec)) if spec.size > 0 else 0.0 + y_max = float(np.nanmax(spec)) if spec.size > 0 else 1.0 + y_scale = max(y_max - y_min, 1e-12) + y_dot = y_min - 0.04 * y_scale + + ax_spec.plot( + marker_x, + np.full(marker_x.shape, y_dot, dtype=float), + marker="o", + markersize=2.5, + color=spectrum_color, + alpha=0.5, + linestyle="None", + zorder=5, + ) - # Plot detected peaks with appropriate line style (limit to display_peaks) - for peak_idx, height, peak_energy, snr in display_peaks: - # Use solid line for sample elements, dotted for others - is_sample = detected_sample_peaks.get(peak_energy, False) - linestyle = "-" if is_sample else ":" + current_bottom, current_top = ax_spec.get_ylim() + dot_padding = 0.02 * y_scale + ax_spec.set_ylim(bottom=min(current_bottom, y_dot - dot_padding), top=current_top) - ax_spec.axvline( - peak_energy, color="red", linestyle=linestyle, alpha=0.3, linewidth=1.5 - ) + fig.tight_layout() + if show: + plt.show() + return fig, (ax_img, ax_spec) - # Add labels for grid artifacts and sample elements (if show_text enabled) - if show_text: - y_pos = height * 0.7 # Position label at 70% of peak height - - # Check if this is a grid/contamination peak - is_grid_peak = False - for grid_elem, grid_energy in grid_peaks.items(): - if ( - abs(peak_energy - grid_energy) < 0.1 - ): # Within 0.1 keV of known grid peak - ax_spec.text( - peak_energy, - y_pos, - f"{grid_elem}\n(grid)", - ha="center", - va="bottom", - fontsize=8, - color="gray", - style="italic", - ) - is_grid_peak = True - break - if is_grid_peak: - print( - f"Peak at {peak_energy} keV may come from the grid." - ) - - # If elements were detected, use them for element identification only (not for line plotting) - if detected_elements: - print(f"\nDetected elements: {', '.join(sorted(detected_elements))}") - - # Prepare labels with vertical orientation and offset handling - # Group labels by energy proximity (within 0.3 keV) - labels_to_plot = [] # List of (peak_energy, label_text, color, height) - colors_map = {"Fe": "darkblue", "Pt": "darkred"} - - for ( - peak_idx, - height, - peak_energy, - snr, - element, - match_str, - distance, - ) in peak_matches: - if element in detected_elements: - # Extract line name from match_str (e.g., "Fe Ka" -> "Ka") - line_name = match_str.split()[-1] if match_str else "" - label_text = f"{element} {line_name}" if line_name else element - color = colors_map.get(element, "black") - labels_to_plot.append((peak_energy, label_text, color, height)) - - # Sort by energy to group nearby peaks - labels_to_plot.sort(key=lambda x: x[0]) - - # Offset overlapping labels vertically - label_offset_map = {} # Map peak_energy -> vertical offset multiplier - proximity_threshold = 1.5 # 1.5 keV - - for i, (energy, label, color, height) in enumerate(labels_to_plot): - # Check if this label is close to previous labels - offset_count = 0 - for j in range(i): - prev_energy, prev_label, prev_color, prev_height = labels_to_plot[ - j - ] - if abs(energy - prev_energy) < proximity_threshold: - offset_count += 1 - - label_offset_map[energy] = offset_count - - # Plot labels with vertical text and offsets (if show_text enabled) - if show_text: - for peak_energy, label_text, color, height in labels_to_plot: - # Position label above the peak - y_pos = height * 1.2 - - ax_spec.text( - peak_energy, - y_pos, - label_text, - ha="center", - va="bottom", - fontsize=10, - color=color, - weight="bold", - rotation=90, - ) - else: - print(f"\nNo peaks detected with SNR >= {min_snr:.1f}") + def show_energy_window_map( + self, + energy_window, + roi=None, + mask=None, + data_type="eds", + cmap="viridis", + show=True, + ): + """Show a spatial map integrated over a selected energy window. - except ImportError: - print("scipy is required for peak detection. Please install scipy.") + This is a complementary view to ``show_mean_spectrum``: + - ``show_mean_spectrum`` answers *what energies are present*. + - ``show_energy_window_map`` answers *where a chosen energy range is present*. - # Skip element lines plotting - only show detected peaks - # (Element characteristic lines are not plotted when using auto-detection) + Parameters + ---------- + energy_window : list[float] | tuple[float, float] + Energy interval [emin, emax] to integrate. + roi : list | tuple | None, optional + ROI as ``[y, x]`` or ``[y, x, dy, dx]`` (with ``None`` defaults), + used only for overlay rectangle. + mask : array-like | None, optional + Optional boolean mask over energy channels. If provided, it is + combined with ``energy_window``. + data_type : str, optional + "eds" (keV) or "eels" (eV), used for title/unit text. + cmap : str, optional + Matplotlib colormap for the map. + show : bool, optional + If True, call ``plt.show()``. + + Returns + ------- + tuple + ``(fig, ax, energy_map)`` where ``energy_map`` is the integrated 2D array. + """ + if energy_window is None or len(energy_window) != 2: + raise ValueError("energy_window must be [min_energy, max_energy]") + emin = float(energy_window[0]) + emax = float(energy_window[1]) + if not np.isfinite(emin) or not np.isfinite(emax) or emin >= emax: + raise ValueError("Invalid energy_window. Expected [min_energy, max_energy] with min < max") + + # Parse ROI (for optional overlay only) + if roi is None: + y, x, dy, dx = 0, 0, int(self.shape[1]), int(self.shape[2]) + has_roi_overlay = False + elif len(roi) == 2: + y, x, dy, dx = int(roi[0]), int(roi[1]), 1, 1 + has_roi_overlay = True + elif len(roi) == 4: + y_val, x_val, dy_val, dx_val = roi + y = 0 if y_val is None else int(y_val) + x = 0 if x_val is None else int(x_val) + dy = int(self.shape[1]) - y if dy_val is None else int(dy_val) + dx = int(self.shape[2]) - x if dx_val is None else int(dx_val) + has_roi_overlay = True + else: + raise ValueError("roi must be None, [y, x], or [y, x, dy, dx] (with None defaults)") + + dE = float(self.sampling[0]) + E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 + E = E0 + dE * np.arange(self.shape[0]) + + window_mask = (E >= emin) & (E <= emax) + if mask is not None: + mask = np.asarray(mask, dtype=bool) + if mask.shape != (self.shape[0],): + raise ValueError( + f"Mask shape {mask.shape} does not match energy axis shape ({self.shape[0]},)" + ) + window_mask = window_mask & mask + + if not np.any(window_mask): + raise ValueError("No energy channels selected. Adjust energy_window or mask") + + arr = np.asarray(self.array, dtype=float) + energy_map = arr[window_mask, :, :].sum(axis=0) + + fig, ax = plt.subplots(1, 1, figsize=(6, 5)) + im = ax.imshow(energy_map, cmap=cmap, origin="lower") + + unit_label = "keV" if str(data_type).lower() == "eds" else "eV" + ax.set_title(f"Energy-Window Map [{emin:.3f}, {emax:.3f}] {unit_label}") + ax.set_xlabel("X (pixels)") + ax.set_ylabel("Y (pixels)") + + if has_roi_overlay: + rect = Rectangle( + (x - 0.5, y - 0.5), + dx, + dy, + linewidth=2, + edgecolor="red", + facecolor="none", + alpha=0.8, + ) + ax.add_patch(rect) + + plt.colorbar(im, ax=ax, label="Integrated Intensity") fig.tight_layout() - plt.show() - return fig, (ax_img, ax_spec) + + if show: + plt.show() + + return fig, ax, energy_map # BACKGROND SUBTRACTION @@ -1896,9 +1713,6 @@ def subtract_background( print("Notice: no 3D dataset was returned") if attach_spectrum: - print( - f"Spectrum recorded to index {len(self.attached_spectra) - 1} of attached_spectra in {self}" - ) self.add_spectrum_to_data(subtracted_mean_spectrum, E) else: print(f"Notice: no spectrum recorded to attached_spectra in {self}") From 08228249bc281871e2b1f3c0063f7c0095ad2905 Mon Sep 17 00:00:00 2001 From: Sangoda <87961379+Sangoda@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:55:16 -0800 Subject: [PATCH 051/136] -Added a new auto-ID method for EDS peaks -Kept show_mean_spectrum mainly for plotting (cleaner and simpler). -Added show_energy_window_map to show where signal appears in a chosen energy range. -Improved model element handling: you can now use inputs like Te La, and those saved model lines show as small markers on the spectrum. -Cleaned some noisy print messages and minor plotting behavior. --- src/quantem/spectroscopy/dataset3dspectroscopy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 36231c09..bb40343a 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -1426,7 +1426,7 @@ def show_mean_spectrum( ax_img.add_patch(rect) # Add colorbar for the image - plt.colorbar(im, ax=ax_img, label="Intensity") + plt.colorbar(im, ax=ax_img) # RIGHT PLOT: Show spectrum spectrum_line, = ax_spec.plot(E, spec, linewidth=1.5) From 266dc74c2faf02a70daeca9879580f3889d427c9 Mon Sep 17 00:00:00 2001 From: smribet Date: Thu, 19 Feb 2026 06:06:20 -0800 Subject: [PATCH 052/136] let's see if I broke the repo... changing X-ray lines reference: https://xdb.lbl.gov/Section1/Table_1-3.pdf --- src/quantem/spectroscopy/dataset3deds.py | 80 +- .../spectroscopy/dataset3dspectroscopy.py | 55 +- .../spectroscopy/spectroscopy_models.py | 8 +- src/quantem/spectroscopy/utils.py | 57 + src/quantem/spectroscopy/x_ray_lines.csv | 702 +++ src/quantem/spectroscopy/xray_lines.json | 3985 ----------------- 6 files changed, 850 insertions(+), 4037 deletions(-) create mode 100644 src/quantem/spectroscopy/utils.py create mode 100644 src/quantem/spectroscopy/x_ray_lines.csv delete mode 100644 src/quantem/spectroscopy/xray_lines.json diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index fbf48367..5619bd50 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -33,7 +33,7 @@ class Dataset3deds(Dataset3dspectroscopy): """ element_info = None - element_info_path = "xray_lines.json" + element_info_path = "x_ray_lines.csv" dataset_type = "EDS" def __init__( @@ -163,14 +163,18 @@ def _edge_filters_from_saved_model(model_elements): for element_name, lines_info in model_elements.items(): element_key = str(element_name) if isinstance(lines_info, dict) and len(lines_info) > 0: - parsed[element_key] = builtins.set(str(line_name) for line_name in lines_info.keys()) + parsed[element_key] = builtins.set( + str(line_name) for line_name in lines_info.keys() + ) else: parsed[element_key] = None return parsed if len(parsed) > 0 else None requested_edge_filters = _parse_requested_elements_with_edges(elements) - saved_model_edge_filters = _edge_filters_from_saved_model(getattr(self, "model_elements", None)) + saved_model_edge_filters = _edge_filters_from_saved_model( + getattr(self, "model_elements", None) + ) using_saved_model_elements = False if requested_edge_filters is None and elements is None: if saved_model_edge_filters is not None: @@ -236,11 +240,11 @@ def _edge_filters_from_saved_model(model_elements): initial_snrs.append(height / background_std) if len(initial_snrs) > 0: - snr_median = float(np.nanmedian(initial_snrs)) + # snr_median = float(np.nanmedian(initial_snrs)) snr_75th = float(np.nanpercentile(initial_snrs, 75)) num_high_snr_peaks = int(np.sum(np.array(initial_snrs) > 50)) else: - snr_median = 0.0 + # snr_median = 0.0 snr_75th = 0.0 num_high_snr_peaks = 0 @@ -303,7 +307,9 @@ def _line_shell(line_name): def _peak_confidence(snr_value, line_weight, distance_value): quality = max(0.0, 1.0 - (distance_value / max(tolerance, 1e-9))) - return np.log1p(max(float(snr_value), 0.0)) * (0.5 + float(line_weight)) * (0.5 + quality) + return ( + np.log1p(max(float(snr_value), 0.0)) * (0.5 + float(line_weight)) * (0.5 + quality) + ) def _line_allowed_for_element(element_name, line_name, edge_filters=None): if edge_filters is None: @@ -338,7 +344,11 @@ def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): is_m_line = "M" in line_name and not ("Ma" in line_name or "Mb" in line_name) effective_tolerance = tolerance * 0.5 if is_m_line else tolerance - if line_weight > 0.3 and distance <= effective_tolerance and distance < best_distance: + if ( + line_weight > 0.3 + and distance <= effective_tolerance + and distance < best_distance + ): best_distance = distance best_element = element_name best_line_name = line_name @@ -355,7 +365,9 @@ def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): search_elements = None for peak_idx, height, peak_energy, snr in display_peaks: - best_match_info = _best_line_match(peak_energy, search_elements, requested_edge_filters) + best_match_info = _best_line_match( + peak_energy, search_elements, requested_edge_filters + ) if best_match_info is not None: best_element, best_line_name, best_line_weight, best_distance = best_match_info best_element = str(best_element) @@ -443,7 +455,9 @@ def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): if element_confidence: conf_values = np.array(list(element_confidence.values()), dtype=float) - confidence_cutoff = max(np.percentile(conf_values, 45), 0.30 * float(conf_values.max())) + confidence_cutoff = max( + np.percentile(conf_values, 45), 0.30 * float(conf_values.max()) + ) for element_name, confidence in element_confidence.items(): stats = element_stats[element_name] @@ -463,7 +477,11 @@ def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): and confidence >= 0.45 * confidence_cutoff ) - if is_supported or is_near_cutoff_but_consistent or is_high_energy_singleton_anchor: + if ( + is_supported + or is_near_cutoff_but_consistent + or is_high_energy_singleton_anchor + ): detected_elements.add(element_name) refined_peak_matches = [] @@ -562,7 +580,9 @@ def _best_supported_line_match_with_prior( # at least one matched peak after rematching. matched_elements = {str(match[4]) for match in peak_matches} detected_elements = { - str(element_name) for element_name in detected_elements if str(element_name) in matched_elements + str(element_name) + for element_name in detected_elements + if str(element_name) in matched_elements } if ignored_elements: detected_elements = { @@ -610,8 +630,14 @@ def _format_elements_with_lines(element_names): formatted.append(str(element_name)) return ", ".join(formatted) - model_elements_header = "Saved Model Elements (Plotted):\n" if using_saved_model_elements else "Saved Model Elements (Not Plotted When Elements Specified):\n" - print(f"\n{model_elements_header} {_format_saved_model_elements(saved_model_edge_filters)}") + model_elements_header = ( + "Saved Model Elements (Plotted):\n" + if using_saved_model_elements + else "Saved Model Elements (Not Plotted When Elements Specified):\n" + ) + print( + f"\n{model_elements_header} {_format_saved_model_elements(saved_model_edge_filters)}" + ) if detected_elements: print(f"\nAutodetected: {_format_elements_with_lines(detected_elements)}") @@ -651,8 +677,7 @@ def _format_elements_with_lines(element_names): for i in range(max(1, len(sorted_elements_for_colors))) ] element_color_map = { - element: color_palette[i] - for i, element in enumerate(sorted_elements_for_colors) + element: color_palette[i] for i, element in enumerate(sorted_elements_for_colors) } table_rows = [] @@ -832,7 +857,9 @@ def _infer_requested_element_for_color(peak_energy): candidate_lines = [] for line_name, line_info in lines_info.items(): - if not _line_allowed_for_element(element_key, line_name, requested_edge_filters): + if not _line_allowed_for_element( + element_key, line_name, requested_edge_filters + ): continue if not isinstance(line_info, dict): continue @@ -855,13 +882,20 @@ def _infer_requested_element_for_color(peak_energy): # Keep meaningful requested-element lines while avoiding excessive clutter. filtered_lines = [line for line in candidate_lines if line[2] >= 0.05] if len(filtered_lines) == 0: - filtered_lines = sorted(candidate_lines, key=lambda item: item[2], reverse=True)[:1] + filtered_lines = sorted( + candidate_lines, key=lambda item: item[2], reverse=True + )[:1] else: - filtered_lines = sorted(filtered_lines, key=lambda item: item[2], reverse=True)[:6] + filtered_lines = sorted( + filtered_lines, key=lambda item: item[2], reverse=True + )[:6] for line_name, line_energy, line_weight in filtered_lines: matched_energies = existing_matches_by_element.get(element_key, []) - if any(abs(line_energy - matched_energy) <= max(0.05, 0.5 * tolerance) for matched_energy in matched_energies): + if any( + abs(line_energy - matched_energy) <= max(0.05, 0.5 * tolerance) + for matched_energy in matched_energies + ): continue line_color = element_color_map.get(element_key, "black") @@ -902,7 +936,9 @@ def _infer_requested_element_for_color(peak_energy): line_weight, match_confidence, ) in peak_matches: - if element_name in detected_elements and detected_sample_peaks.get(peak_energy, False): + if element_name in detected_elements and detected_sample_peaks.get( + peak_energy, False + ): line_name = match_str.split()[-1] if match_str else "" label_text = f"{element_name} {line_name}" if line_name else element_name color = element_color_map.get(element_name, "black") @@ -973,9 +1009,7 @@ def _same_color(c1, c2): y_pos = max(item[3] for item in group) + label_vertical_offset merged_text = ", ".join(item[1] for item in group) first_color = group[0][2] - all_same_color = all( - _same_color(item[2], first_color) for item in group - ) + all_same_color = all(_same_color(item[2], first_color) for item in group) if all_same_color: ax_spec.text( x_pos, diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index bb40343a..cca403c4 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -9,12 +9,13 @@ from sklearn.decomposition import PCA from quantem.core.datastructures.dataset3d import Dataset3d +from quantem.spectroscopy.utils import load_xray_lines_database class Dataset3dspectroscopy(Dataset3d): # stores the element line info so you don't need to reload each time element_info = None - element_info_path = "xray_lines.json" + element_info_path = "x_ray_lines.csv" atomic_weights = None atomic_weights_path = "atomic_weights.json" dataset_type = "EDS" @@ -54,17 +55,20 @@ def load_element_info( if class_type == "eels": path = "eels_binding_energies.json" elif class_type == "eds": - path = "xray_lines.json" + path = getattr(cls, "element_info_path", "x_ray_lines.csv") else: - path = getattr(cls, "element_info_path", "xray_lines.json") + path = getattr(cls, "element_info_path", "x_ray_lines.csv") if cls.element_info is not None: # don't reload if already loaded return base_dir = os.path.dirname(os.path.abspath(__file__)) full_path = os.path.join(base_dir, path) - with open(full_path, "r") as f: - cls.element_info = json.load(f)["elements"] + if str(path).lower().endswith(".csv"): + cls.element_info = load_xray_lines_database(full_path) + else: + with open(full_path, "r") as f: + cls.element_info = json.load(f)["elements"] @classmethod def load_atomic_weights(cls): @@ -406,7 +410,7 @@ def add_spectrum_to_data(self, spectrum, energy_axis): def clear_attached_spectra(self): self.attached_spectra = None - def plot_attached_spectrum(self, data_type='eds',spectrum_index=0): + def plot_attached_spectrum(self, data_type="eds", spectrum_index=0): fig, (ax_spec) = plt.subplots(1, 1, figsize=(12, 4)) ax_spec.plot( @@ -414,9 +418,9 @@ def plot_attached_spectrum(self, data_type='eds',spectrum_index=0): self.attached_spectra[spectrum_index][0], linewidth=1.5, ) - if data_type =='eds': + if data_type == "eds": ax_spec.set_xlabel("Energy (keV)") - elif data_type == 'eels': + elif data_type == "eels": ax_spec.set_xlabel("Energy (eV)") ax_spec.set_ylabel("Intensity") ax_spec.set_title(f"Spectrum in index {spectrum_index}") @@ -681,7 +685,7 @@ def quantify_composition( spectrum_data = self._extract_spectrum_for_quantification(roi, mask) spec = spectrum_data["spectrum"] E = spectrum_data["energy"] - + # Determine max usable energy from the actual dataset max_energy = float(E.max()) if len(E) > 0 else 20.0 @@ -753,7 +757,7 @@ def _extract_spectrum_for_quantification(self, roi, mask): def _integrate_element_intensity(self, element, spectrum, energy, shell="K"): """Integrate X-ray intensity for a specific element using characteristic lines from the specified shell. - + Parameters ---------- element : str @@ -774,13 +778,13 @@ def _integrate_element_intensity(self, element, spectrum, energy, shell="K"): # Filter lines by the specified shell (K, L, or M) # For K-shell: Ka, Kb lines - # For L-shell: La, Lb, Lg lines + # For L-shell: La, Lb, Lg lines # For M-shell: Ma, Mb lines shell_lines = [] for line_name, info in element_lines.items(): line_energy = info["energy (keV)"] line_weight = info["weight"] - + # Check if line belongs to the specified shell if shell == "K" and ("Ka" in line_name or "Kb" in line_name): shell_lines.append((line_weight, line_energy, line_name)) @@ -788,7 +792,7 @@ def _integrate_element_intensity(self, element, spectrum, energy, shell="K"): shell_lines.append((line_weight, line_energy, line_name)) elif shell == "M" and ("Ma" in line_name or "Mb" in line_name): shell_lines.append((line_weight, line_energy, line_name)) - + # Sort by weight (highest first) and ignore lines beyond detector range shell_lines = [(w, e, n) for w, e, n in shell_lines if e <= 12.0] shell_lines.sort(reverse=True) @@ -819,7 +823,7 @@ def _integrate_element_intensity(self, element, spectrum, energy, shell="K"): def _determine_element_shells(self, elements, max_energy): """Determine the appropriate X-ray shell (K, L, or M) for each element based on available lines. - + Parameters ---------- elements : list @@ -829,28 +833,29 @@ def _determine_element_shells(self, elements, max_energy): """ all_info = type(self).element_info element_shells = {} - + for element in elements: if element not in all_info: element_shells[element] = "K" # Default continue - + element_lines = all_info[element] - + # Check which X-ray series is present AND within usable energy range has_usable_k_lines = any( ("Ka" in line or "Kb" in line) and info["energy (keV)"] <= max_energy for line, info in element_lines.items() ) has_usable_l_lines = any( - ("La" in line or "Lb" in line or "Lg" in line) and info["energy (keV)"] <= max_energy + ("La" in line or "Lb" in line or "Lg" in line) + and info["energy (keV)"] <= max_energy for line, info in element_lines.items() ) has_usable_m_lines = any( ("Ma" in line or "Mb" in line) and info["energy (keV)"] <= max_energy for line, info in element_lines.items() ) - + # Prioritize K-lines, then L-lines, then M-lines (only if within usable range) if has_usable_k_lines: element_shells[element] = "K" @@ -860,7 +865,7 @@ def _determine_element_shells(self, elements, max_energy): element_shells[element] = "M" else: element_shells[element] = "K" # Default fallback - + return element_shells def _normalize_k_factors(self, elements, k_factors, element_shells=None): @@ -901,9 +906,7 @@ def _extract_shell_value(elem, shell_values): if value is not None: return value - raise ValueError( - f"k_factors['{elem}'] has no usable positive shell value in K/L/M" - ) + raise ValueError(f"k_factors['{elem}'] has no usable positive shell value in K/L/M") if isinstance(k_factors, dict): missing = [elem for elem in elements if elem not in k_factors] @@ -1429,7 +1432,7 @@ def show_mean_spectrum( plt.colorbar(im, ax=ax_img) # RIGHT PLOT: Show spectrum - spectrum_line, = ax_spec.plot(E, spec, linewidth=1.5) + (spectrum_line,) = ax_spec.plot(E, spec, linewidth=1.5) spectrum_color = spectrum_line.get_color() if data_type == "eds": ax_spec.set_xlabel("Energy (keV)") @@ -1547,7 +1550,9 @@ def show_energy_window_map( emin = float(energy_window[0]) emax = float(energy_window[1]) if not np.isfinite(emin) or not np.isfinite(emax) or emin >= emax: - raise ValueError("Invalid energy_window. Expected [min_energy, max_energy] with min < max") + raise ValueError( + "Invalid energy_window. Expected [min_energy, max_energy] with min < max" + ) # Parse ROI (for optional overlay only) if roi is None: diff --git a/src/quantem/spectroscopy/spectroscopy_models.py b/src/quantem/spectroscopy/spectroscopy_models.py index 2b1cfb39..fbdb2449 100644 --- a/src/quantem/spectroscopy/spectroscopy_models.py +++ b/src/quantem/spectroscopy/spectroscopy_models.py @@ -1,10 +1,11 @@ -import json from pathlib import Path import numpy as np import torch import torch.nn as nn +from quantem.spectroscopy.utils import load_xray_lines_database + def inverse_softplus(x: torch.Tensor, min_value: float = 1e-8) -> torch.Tensor: """Numerically stable inverse of softplus for positive initialization values.""" @@ -114,8 +115,7 @@ def __init__(self, energy_axis, peak_width, elements_to_fit=None): super().__init__() current_dir = Path(__file__).parent - with open(current_dir / "xray_lines.json", "r") as f: - data = json.load(f) + data = load_xray_lines_database(current_dir / "x_ray_lines.csv") energy_axis_tensor = ( energy_axis.float() @@ -131,7 +131,7 @@ def __init__(self, energy_axis, peak_width, elements_to_fit=None): # Parse and filter elements all_element_data = {} - for elem, lines in data["elements"].items(): + for elem, lines in data.items(): if len(lines) > 0: energies = [] weights = [] diff --git a/src/quantem/spectroscopy/utils.py b/src/quantem/spectroscopy/utils.py new file mode 100644 index 00000000..f1742176 --- /dev/null +++ b/src/quantem/spectroscopy/utils.py @@ -0,0 +1,57 @@ +import csv +from pathlib import Path +from typing import Optional, Union + + +def _parse_float(row: dict[str, str], keys: tuple[str, ...]) -> Optional[float]: + for key in keys: + value = row.get(key) + if value is None: + continue + text = str(value).strip() + if not text: + continue + try: + return float(text) + except ValueError: + continue + return None + + +def load_xray_lines_database(path: Union[Path, str]) -> dict[str, dict[str, dict[str, float]]]: + """Load X-ray lines CSV into the legacy element->line metadata mapping.""" + elements: dict[str, dict[str, dict[str, float]]] = {} + duplicate_counts: dict[tuple[str, str], int] = {} + + with open(path, "r", encoding="utf-8", newline="") as f: + reader = csv.DictReader(f) + for row in reader: + element = str(row.get("element", "")).strip() + line_name = str(row.get("line", "")).strip() + if not element or not line_name: + continue + + energy_kev = _parse_float(row, ("energy_keV", "energy (keV)", "energy")) + if energy_kev is None: + energy_ev = _parse_float(row, ("energy_eV", "energy (eV)")) + if energy_ev is None: + continue + energy_kev = energy_ev / 1000.0 + + # Use the normalized CSV column as the X-ray line weight. + weight = _parse_float(row, ("col4_norm", "weight", "relative_intensity")) + if weight is None: + weight = 0.0 + + element_lines = elements.setdefault(element, {}) + key = (element, line_name) + if line_name in element_lines: + duplicate_counts[key] = duplicate_counts.get(key, 1) + 1 + line_name = f"{line_name}__{duplicate_counts[key]}" + + element_lines[line_name] = { + "energy (keV)": float(energy_kev), + "weight": float(weight), + } + + return elements diff --git a/src/quantem/spectroscopy/x_ray_lines.csv b/src/quantem/spectroscopy/x_ray_lines.csv new file mode 100644 index 00000000..3e45b4ef --- /dev/null +++ b/src/quantem/spectroscopy/x_ray_lines.csv @@ -0,0 +1,702 @@ +energy_eV,atomic_number,element,line,relative_intensity,col4_norm +22162.9,47,Ag,Ka1,100,0.662252 +21990.3,47,Ag,Ka2,53,0.350993 +24942.4,47,Ag,Kb1,16,0.10596 +25456.4,47,Ag,Kb2,4,0.02649 +24911.5,47,Ag,Kb3,9,0.059603 +2984.3,47,Ag,La1,100,0.900901 +2978.2,47,Ag,La2,11,0.099099 +3150.9,47,Ag,Lb1,56,0.504505 +3347.8,47,Ag,"Lb2,15",13,0.117117 +3519.6,47,Ag,Lg1,6,0.054054 +2633.7,47,Ag,Ll,4,0.036036 +1486.7,13,Al,Ka1,100,0.662252 +1486.3,13,Al,Ka2,50,0.331126 +1557.4,13,Al,Kb1,1,0.006623 +2957.7,18,Ar,Ka1,100,0.662252 +2955.6,18,Ar,Ka2,50,0.331126 +3190.5,18,Ar,"Kb1,3",10,0.066225 +10543.7,33,As,Ka1,100,0.662252 +10508,33,As,Ka2,51,0.337748 +11726.2,33,As,Kb1,13,0.086093 +11864,33,As,Kb2,1,0.006623 +11720.3,33,As,Kb3,6,0.039735 +1282,33,As,"La1,2",111,1 +1317,33,As,Lb1,60,0.540541 +1120,33,As,Ll,6,0.054054 +68803.7,79,Au,Ka1,100,0.662252 +66989.5,79,Au,"Ka1,2",2,0.013245 +77984,79,Au,Kb1,23,0.152318 +80150,79,Au,Kb2,8,0.05298 +77580,79,Au,Kb3,12,0.07947 +9713.3,79,Au,La1,100,0.900901 +9628,79,Au,La2,11,0.099099 +11442.3,79,Au,Lb1,67,0.603604 +11584.7,79,Au,Lb2,23,0.207207 +13381.7,79,Au,Lg1,13,0.117117 +8493.9,79,Au,Ll,5,0.045045 +2122.9,79,Au,Ma1,100,1 +183.3,5,B,"Ka1,2",151,1 +32193.6,56,Ba,Ka1,100,0.662252 +31817.1,56,Ba,Ka2,54,0.357616 +36378.2,56,Ba,Kb1,18,0.119205 +37257,56,Ba,Kb2,6,0.039735 +36304,56,Ba,Kb3,10,0.066225 +4466.3,56,Ba,La1,100,0.900901 +4450.9,56,Ba,La2,11,0.099099 +4827.5,56,Ba,Lb1,60,0.540541 +5156.5,56,Ba,"Lb2,15",20,0.18018 +5531.1,56,Ba,Lg1,9,0.081081 +3954.1,56,Ba,Ll,4,0.036036 +108.5,4,Be,"Ka1,2",150,0.993377 +77107.9,83,Bi,Ka1,100,0.662252 +74814.8,83,Bi,Ka2,60,0.397351 +87343,83,Bi,Kb1,23,0.152318 +89830,83,Bi,Kb2,9,0.059603 +86834,83,Bi,Kb3,12,0.07947 +10838.8,83,Bi,La1,100,0.900901 +10730.9,83,Bi,La2,11,0.099099 +13023.5,83,Bi,Lb1,67,0.603604 +12979.9,83,Bi,Lb2,25,0.225225 +15247.7,83,Bi,Lg1,14,0.126126 +9420.4,83,Bi,Ll,6,0.054054 +2422.6,83,Bi,Ma1,100,1 +11924.2,35,Br,Ka1,100,0.662252 +11877.6,35,Br,Ka2,52,0.344371 +13291.4,35,Br,Kb1,14,0.092715 +13469.5,35,Br,Kb2,1,0.006623 +13284.5,35,Br,Kb3,7,0.046358 +1480.4,35,Br,"La1,2",111,1 +1525.9,35,Br,Lb1,59,0.531532 +1293.5,35,Br,Ll,5,0.045045 +277,6,C,"Ka1,2",147,0.97351 +3691.7,20,Ca,Ka1,100,0.662252 +3688.1,20,Ca,Ka2,50,0.331126 +4012.7,20,Ca,"Kb1,3",13,0.086093 +23173.6,48,Cd,Ka1,100,0.662252 +22984.1,48,Cd,Ka2,53,0.350993 +26095.5,48,Cd,Kb1,17,0.112583 +26643.8,48,Cd,Kb2,4,0.02649 +26061.2,48,Cd,Kb3,9,0.059603 +3133.7,48,Cd,La1,100,0.900901 +3126.9,48,Cd,La2,11,0.099099 +3316.6,48,Cd,Lb1,58,0.522523 +3528.1,48,Cd,"Lb2,15",15,0.135135 +3716.9,48,Cd,Lg1,6,0.054054 +2767.4,48,Cd,Ll,4,0.036036 +34719.7,58,Ce,Ka1,100,0.662252 +34278.9,58,Ce,Ka2,55,0.364238 +39257.3,58,Ce,Kb1,19,0.125828 +40233,58,Ce,Kb2,6,0.039735 +39170.1,58,Ce,Kb3,10,0.066225 +4840.2,58,Ce,La1,100,0.900901 +4823,58,Ce,La2,11,0.099099 +5262.2,58,Ce,Lb1,61,0.54955 +5613.4,58,Ce,"Lb2,15",21,0.189189 +6052,58,Ce,Lg1,9,0.081081 +4287.5,58,Ce,Ll,4,0.036036 +883,58,Ce,Ma1,100,1 +2622.4,17,Cl,Ka1,100,0.662252 +2620.8,17,Cl,Ka2,50,0.331126 +2815.6,17,Cl,Kb1,6,0.039735 +6930.3,27,Co,Ka1,100,0.662252 +6915.3,27,Co,Ka2,51,0.337748 +7649.4,27,Co,"Kb1,3",17,0.112583 +776.2,27,Co,"La1,2",111,1 +791.4,27,Co,Lb1,76,0.684685 +677.8,27,Co,Ll,10,0.09009 +5414.7,24,Cr,Ka1,100,0.662252 +5405.5,24,Cr,Ka2,50,0.331126 +5946.7,24,Cr,"Kb1,3",15,0.099338 +572.8,24,Cr,"La1,2",111,1 +582.8,24,Cr,Lb1,79,0.711712 +500.3,24,Cr,Ll,17,0.153153 +30972.8,55,Cs,"Ka1,2",1,0.006623 +30625.1,55,Cs,Ka2,54,0.357616 +34986.9,55,Cs,Kb1,18,0.119205 +35822,55,Cs,Kb2,6,0.039735 +34919.4,55,Cs,Kb3,9,0.059603 +4286.5,55,Cs,La1,100,0.900901 +4272.2,55,Cs,La2,11,0.099099 +4619.8,55,Cs,Lb1,61,0.54955 +4935.9,55,Cs,"Lb2,15",20,0.18018 +5280.4,55,Cs,Lg1,8,0.072072 +3795,55,Cs,Ll,4,0.036036 +8047.8,29,Cu,Ka1,100,0.662252 +8027.8,29,Cu,Ka2,51,0.337748 +8905.3,29,Cu,"Kb1,3",17,0.112583 +929.7,29,Cu,"La1,2",111,1 +949.8,29,Cu,Lb1,65,0.585586 +811.1,29,Cu,Ll,8,0.072072 +45998.4,66,Dy,Ka1,100,0.662252 +45207.8,66,Dy,Ka2,56,0.370861 +52119,66,Dy,Kb1,20,0.13245 +53476,66,Dy,Kb2,7,0.046358 +51957,66,Dy,Kb3,10,0.066225 +6495.2,66,Dy,La1,100,0.900901 +6457.7,66,Dy,La2,11,0.099099 +7247.7,66,Dy,Lb1,62,0.558559 +7635.7,66,Dy,Lb2,20,0.18018 +8418.8,66,Dy,Lg1,11,0.099099 +5743.1,66,Dy,Ll,4,0.036036 +1293,66,Dy,Ma1,100,1 +49127.7,68,Er,Ka1,100,0.662252 +48221.1,68,Er,Ka2,56,0.370861 +55681,68,Er,Kb1,21,0.139073 +57210,68,Er,Kb2,7,0.046358 +55494,68,Er,Kb3,11,0.072848 +6948.7,68,Er,La1,100,0.900901 +6905,68,Er,La2,11,0.099099 +7810.9,68,Er,Lb1,64,0.576577 +8189,68,Er,"Lb2,15",20,0.18018 +9089,68,Er,Lg1,11,0.099099 +6152,68,Er,Ll,4,0.036036 +1406,68,Er,Ma1,100,1 +41542.2,63,Eu,Ka1,100,0.662252 +40901.9,63,Eu,Ka2,56,0.370861 +47037.9,63,Eu,Kb1,19,0.125828 +48256,63,Eu,Kb2,6,0.039735 +46903.6,63,Eu,Kb3,10,0.066225 +5845.7,63,Eu,La1,100,0.900901 +5816.6,63,Eu,La2,11,0.099099 +6456.4,63,Eu,Lb1,62,0.558559 +6843.2,63,Eu,"Lb2,15",21,0.189189 +7480.3,63,Eu,Lg1,10,0.09009 +5177.2,63,Eu,Ll,4,0.036036 +1131,63,Eu,Ma1,100,1 +676.8,9,F,"Ka1,2",148,0.980132 +6403.8,26,Fe,Ka1,100,0.662252 +6390.8,26,Fe,Ka2,50,0.331126 +7058,26,Fe,"Kb1,3",17,0.112583 +705,26,Fe,"La1,2",111,1 +718.5,26,Fe,Lb1,66,0.594595 +615.2,26,Fe,Ll,10,0.09009 +9251.7,31,Ga,Ka1,100,0.662252 +9224.8,31,Ga,Ka2,51,0.337748 +10264.2,31,Ga,Kb1,66,0.437086 +10260.3,31,Ga,Kb3,5,0.033113 +1097.9,31,Ga,"La1,2",111,1 +1124.8,31,Ga,Lb1,66,0.594595 +957.2,31,Ga,Ll,7,0.063063 +42996.2,64,Gd,Ka1,100,0.662252 +42308.9,64,Gd,Ka2,56,0.370861 +48697,64,Gd,Kb1,20,0.13245 +49959,64,Gd,Kb2,7,0.046358 +48555,64,Gd,Kb3,10,0.066225 +6057.2,64,Gd,La1,100,0.900901 +6025,64,Gd,La2,11,0.099099 +6713.2,64,Gd,Lb1,1,0.009009 +7102.8,64,Gd,"Lb2,15",21,0.189189 +7785.8,64,Gd,Lg1,11,0.099099 +5362.1,64,Gd,Ll,4,0.036036 +1185,64,Gd,Ma1,100,1 +9886.4,32,Ge,Ka1,100,0.662252 +9855.3,32,Ge,Ka2,51,0.337748 +10982.1,32,Ge,Kb1,60,0.397351 +10978,32,Ge,Kb3,6,0.039735 +1188,32,Ge,"La1,2",111,1 +1218.5,32,Ge,Lb1,60,0.540541 +1036.2,32,Ge,Ll,6,0.054054 +55790.2,72,Hf,Ka1,100,0.662252 +54611.4,72,Hf,Ka2,57,0.377483 +63234,72,Hf,Kb1,22,0.145695 +64980,72,Hf,Kb2,7,0.046358 +62980,72,Hf,Kb3,11,0.072848 +7899,72,Hf,La1,100,0.900901 +7844.6,72,Hf,La2,11,0.099099 +9022.7,72,Hf,Lb1,67,0.603604 +9347.3,72,Hf,Lb2,20,0.18018 +10515.8,72,Hf,Lg1,12,0.108108 +6959.6,72,Hf,Ll,5,0.045045 +1644.6,72,Hf,Ma1,100,1 +70819,80,Hg,Ka1,100,0.662252 +68895,80,Hg,Ka2,59,0.390728 +80253,80,Hg,Kb1,23,0.152318 +82515,80,Hg,Kb2,8,0.05298 +79822,80,Hg,Kb3,12,0.07947 +9988.8,80,Hg,La1,100,0.900901 +9897.6,80,Hg,La2,11,0.099099 +11822.6,80,Hg,Lb1,67,0.603604 +11924.1,80,Hg,Lb2,24,0.216216 +13830.1,80,Hg,Lg1,14,0.126126 +8721,80,Hg,Ll,5,0.045045 +2195.3,80,Hg,Ma1,100,1 +47546.7,67,Ho,Ka1,100,0.662252 +46699.7,67,Ho,Ka2,56,0.370861 +53877,67,Ho,Kb1,20,0.13245 +55293,67,Ho,Kb2,7,0.046358 +53711,67,Ho,Kb3,11,0.072848 +6719.8,67,Ho,La1,100,0.900901 +6679.5,67,Ho,La2,11,0.099099 +7525.3,67,Ho,Lb1,64,0.576577 +7911,67,Ho,"Lb2,15",20,0.18018 +8747,67,Ho,Lg1,11,0.099099 +5943.4,67,Ho,Ll,4,0.036036 +1348,67,Ho,Ma1,100,1 +28612,53,I,Ka1,100,0.662252 +28317.2,53,I,Ka2,54,0.357616 +32294.7,53,I,Kb1,18,0.119205 +33042,53,I,Kb2,5,0.033113 +32239.4,53,I,Kb3,9,0.059603 +3937.6,53,I,"La1,2",1,0.009009 +3926,53,I,La2,11,0.099099 +4220.7,53,I,Lb1,61,0.54955 +4507.5,53,I,"Lb2,15",19,0.171171 +4800.9,53,I,Lg1,8,0.072072 +3485,53,I,Ll,4,0.036036 +24209.7,49,In,Ka1,100,0.662252 +24002,49,In,Ka2,53,0.350993 +27275.9,49,In,Kb1,17,0.112583 +27860.8,49,In,Kb2,5,0.033113 +27237.7,49,In,Kb3,9,0.059603 +3286.9,49,In,La1,100,0.900901 +3279.3,49,In,La2,11,0.099099 +3487.2,49,In,Lb1,1,0.009009 +3713.8,49,In,"Lb2,15",15,0.135135 +3920.8,49,In,Lg1,6,0.054054 +2904.4,49,In,Ll,4,0.036036 +64895.6,77,Ir,Ka1,100,0.662252 +63286.7,77,Ir,Ka2,58,0.384106 +73560.8,77,Ir,Kb1,23,0.152318 +75575,77,Ir,Kb2,8,0.05298 +73202.7,77,Ir,Kb3,12,0.07947 +9175.1,77,Ir,La1,100,0.900901 +9099.5,77,Ir,La2,11,0.099099 +10708.3,77,Ir,Lb1,66,0.594595 +10920.3,77,Ir,Lb2,22,0.198198 +12512.6,77,Ir,Lg1,13,0.117117 +8045.8,77,Ir,Ll,5,0.045045 +1979.9,77,Ir,Ma1,100,1 +3313.8,19,K,Ka1,100,0.662252 +3311.1,19,K,Ka2,50,0.331126 +3589.6,19,K,"Kb1,3",11,0.072848 +12649,36,Kr,Ka1,100,0.662252 +12598,36,Kr,"Ka1,2",2,0.013245 +14112,36,Kr,Kb1,14,0.092715 +14315,36,Kr,Kb2,2,0.013245 +14104,36,Kr,Kb3,7,0.046358 +1586,36,Kr,"La1,2",111,1 +1636.6,36,Kr,Lb1,57,0.513514 +1386,36,Kr,Ll,5,0.045045 +33441.8,57,La,Ka1,100,0.662252 +33034.1,57,La,Ka2,54,0.357616 +37801,57,La,Kb1,19,0.125828 +38729.9,57,La,Kb2,6,0.039735 +37720.2,57,La,Kb3,10,0.066225 +4651,57,La,La1,100,0.900901 +4634.2,57,La,La2,11,0.099099 +5042.1,57,La,Lb1,60,0.540541 +5383.5,57,La,"Lb2,15",21,0.189189 +5788.5,57,La,Lg1,9,0.081081 +4124,57,La,Ll,4,0.036036 +833,57,La,Ma1,100,1 +54.3,3,Li,"Ka1,2",150,0.993377 +54069.8,71,Lu,Ka1,100,0.662252 +52965,71,Lu,Ka2,57,0.377483 +61283,71,Lu,Kb1,21,0.139073 +62970,71,Lu,Kb2,7,0.046358 +61050,71,Lu,Kb3,11,0.072848 +7655.5,71,Lu,La1,100,0.900901 +7604.9,71,Lu,La2,11,0.099099 +8709,71,Lu,Lb1,66,0.594595 +9048.9,71,Lu,Lb2,19,0.171171 +10143.4,71,Lu,Lg1,12,0.108108 +6752.8,71,Lu,Ll,4,0.036036 +1581.3,71,Lu,Ma1,100,1 +1253.6,12,Mg,"Ka1,2",150,0.993377 +5898.8,25,Mn,Ka1,100,0.662252 +5887.6,25,Mn,Ka2,50,0.331126 +6490.4,25,Mn,"Kb1,3",17,0.112583 +637.4,25,Mn,"La1,2",111,1 +648.8,25,Mn,Lb1,77,0.693694 +556.3,25,Mn,Ll,15,0.135135 +17479.3,42,Mo,Ka1,100,0.662252 +17374.3,42,Mo,Ka2,52,0.344371 +19608.3,42,Mo,Kb1,15,0.099338 +19965.2,42,Mo,Kb2,3,0.019868 +19590.3,42,Mo,Kb3,8,0.05298 +2293.2,42,Mo,La1,100,0.900901 +2289.8,42,Mo,La2,11,0.099099 +2394.8,42,Mo,Lb1,53,0.477477 +2518.3,42,Mo,"Lb2,15",5,0.045045 +2623.5,42,Mo,Lg1,3,0.027027 +2015.7,42,Mo,Ll,5,0.045045 +392.4,7,N,"Ka1,2",150,0.993377 +1041,11,Na,"Ka1,2",150,0.993377 +16615.1,41,Nb,Ka1,100,0.662252 +16521,41,Nb,Ka2,52,0.344371 +18622.5,41,Nb,Kb1,15,0.099338 +18953,41,Nb,Kb2,3,0.019868 +18606.3,41,Nb,Kb3,8,0.05298 +2165.9,41,Nb,La1,100,0.900901 +2163,41,Nb,La2,11,0.099099 +2257.4,41,Nb,Lb1,52,0.468468 +2367,41,Nb,"Lb2,15",3,0.027027 +2461.8,41,Nb,Lg1,2,0.018018 +1902.2,41,Nb,Ll,5,0.045045 +37361,60,Nd,Ka1,100,0.662252 +36847.4,60,Nd,Ka2,55,0.364238 +42271.3,60,Nd,Kb1,19,0.125828 +43335,60,Nd,Kb2,6,0.039735 +42166.5,60,Nd,Kb3,10,0.066225 +5230.4,60,Nd,La1,100,0.900901 +5207.7,60,Nd,La2,11,0.099099 +5721.6,60,Nd,Lb1,60,0.540541 +6089.4,60,Nd,"Lb2,15",21,0.189189 +6602.1,60,Nd,Lg1,10,0.09009 +4633,60,Nd,Ll,4,0.036036 +978,60,Nd,Ma1,100,1 +848.6,10,Ne,"Ka1,2",150,0.993377 +7478.2,28,Ni,Ka1,100,0.662252 +7460.9,28,Ni,Ka2,51,0.337748 +8264.7,28,Ni,"Kb1,3",17,0.112583 +851.5,28,Ni,"La1,2",12,0.108108 +868.8,28,Ni,Lb1,68,0.612613 +742.7,28,Ni,Ll,9,0.081081 +524.9,8,O,"Ka1,2",12,0.07947 +63000.5,76,Os,Ka1,100,0.662252 +61486.7,76,Os,Ka2,58,0.384106 +71413,76,Os,Kb1,23,0.152318 +73363,76,Os,Kb2,8,0.05298 +71077,76,Os,Kb3,12,0.07947 +8911.7,76,Os,La1,100,0.900901 +8841,76,Os,La2,11,0.099099 +10355.3,76,Os,Lb1,67,0.603604 +10598.5,76,Os,Lb2,22,0.198198 +12095.3,76,Os,Lg1,13,0.117117 +7822.2,76,Os,Ll,5,0.045045 +1910.2,76,Os,Ma1,100,1 +2013.7,15,P,Ka1,100,0.662252 +2012.7,15,P,Ka2,50,0.331126 +2139.1,15,P,Kb1,3,0.019868 +74969.4,82,Pb,Ka1,100,0.662252 +72804.2,82,Pb,Ka2,60,0.397351 +84936,82,Pb,Kb1,23,0.152318 +87320,82,Pb,Kb2,8,0.05298 +84450,82,Pb,Kb3,12,0.07947 +10551.5,82,Pb,La1,100,0.900901 +10449.5,82,Pb,La2,11,0.099099 +12613.7,82,Pb,Lb1,66,0.594595 +12622.6,82,Pb,Lb2,25,0.225225 +14764.4,82,Pb,Lg1,14,0.126126 +9184.5,82,Pb,Ll,6,0.054054 +2345.5,82,Pb,Ma1,100,1 +21177.1,46,Pd,Ka1,100,0.662252 +21020.1,46,Pd,Ka2,53,0.350993 +23818.7,46,Pd,Kb1,16,0.10596 +24299.1,46,Pd,Kb2,4,0.02649 +23791.1,46,Pd,Kb3,8,0.05298 +2838.6,46,Pd,La1,100,0.900901 +2833.3,46,Pd,La2,11,0.099099 +2990.2,46,Pd,Lb1,53,0.477477 +3171.8,46,Pd,"Lb2,15",12,0.108108 +3328.7,46,Pd,Lg1,6,0.054054 +2503.4,46,Pd,Ll,4,0.036036 +38724.7,61,Pm,Ka1,100,0.662252 +38171.2,61,Pm,Ka2,55,0.364238 +43826,61,Pm,Kb1,19,0.125828 +44942,61,Pm,Kb2,6,0.039735 +43713,61,Pm,Kb3,10,0.066225 +5432,61,Pm,La1,100,0.900901 +5408,61,Pm,La2,11,0.099099 +5961,61,Pm,Lb1,61,0.54955 +6339,61,Pm,Lb2,21,0.189189 +6892,61,Pm,Lg1,10,0.09009 +4809,61,Pm,Ll,4,0.036036 +36026.3,59,Pr,Ka1,100,0.662252 +35550.2,59,Pr,Ka2,55,0.364238 +40748.2,59,Pr,Kb1,19,0.125828 +41773,59,Pr,Kb2,6,0.039735 +40652.9,59,Pr,Kb3,10,0.066225 +5033.7,59,Pr,La1,100,0.900901 +5013.5,59,Pr,La2,11,0.099099 +5488.9,59,Pr,Lb1,61,0.54955 +5850,59,Pr,"Lb2,15",21,0.189189 +6322.1,59,Pr,Lg1,9,0.081081 +4453.2,59,Pr,Ll,4,0.036036 +929.2,59,Pr,Ma1,100,1 +66832,78,Pt,Ka1,100,0.662252 +65112,78,Pt,Ka2,58,0.384106 +75748,78,Pt,Kb1,23,0.152318 +77850,78,Pt,Kb2,8,0.05298 +75368,78,Pt,Kb3,12,0.07947 +9442.3,78,Pt,La1,100,0.900901 +9361.8,78,Pt,La2,11,0.099099 +11070.7,78,Pt,Lb1,67,0.603604 +11250.5,78,Pt,Lb1,2,0.018018 +12942,78,Pt,Lg1,13,0.117117 +8268,78,Pt,Ll,5,0.045045 +2050.5,78,Pt,Ma1,100,1 +13395.3,37,Rb,Ka1,100,0.662252 +13335.8,37,Rb,Ka2,52,0.344371 +14961.3,37,Rb,Kb1,14,0.092715 +15185,37,Rb,Kb2,2,0.013245 +14951.7,37,Rb,Kb3,7,0.046358 +1694.1,37,Rb,La1,100,0.900901 +1692.6,37,Rb,La2,11,0.099099 +1752.2,37,Rb,Lb1,58,0.522523 +1482.4,37,Rb,Ll,5,0.045045 +61140.3,75,Re,Ka1,100,0.662252 +59717.9,75,Re,Ka2,58,0.384106 +69310,75,Re,Kb1,22,0.145695 +71232,75,Re,Kb2,8,0.05298 +68994,75,Re,Kb3,12,0.07947 +8652.5,75,Re,La1,100,0.900901 +8586.2,75,Re,La2,11,0.099099 +10010,75,Re,Lb1,66,0.594595 +10275.2,75,Re,Lb2,22,0.198198 +11685.4,75,Re,Lg1,13,0.117117 +7603.6,75,Re,Ll,5,0.045045 +1842.5,75,Re,Ma1,100,1 +20216.1,45,Rh,Ka1,100,0.662252 +20073.7,45,Rh,Ka2,53,0.350993 +22723.6,45,Rh,Kb1,16,0.10596 +23172.8,45,Rh,Kb2,4,0.02649 +22698.9,45,Rh,Kb3,8,0.05298 +2696.7,45,Rh,La1,100,0.900901 +2692,45,Rh,La2,11,0.099099 +2834.4,45,Rh,Lb1,52,0.468468 +3001.3,45,Rh,"Lb2,15",10,0.09009 +3143.8,45,Rh,Lg1,5,0.045045 +2376.5,45,Rh,Ll,4,0.036036 +19279.2,44,Ru,Ka1,100,0.662252 +19150.4,44,Ru,Ka2,53,0.350993 +21656.8,44,Ru,Kb1,16,0.10596 +22074,44,Ru,Kb2,4,0.02649 +21634.6,44,Ru,Kb3,8,0.05298 +2558.6,44,Ru,La1,100,0.900901 +2554.3,44,Ru,La2,11,0.099099 +2683.2,44,Ru,Lb1,54,0.486486 +2836,44,Ru,"Lb2,15",10,0.09009 +2964.5,44,Ru,Lg1,4,0.036036 +2252.8,44,Ru,Ll,4,0.036036 +2307.8,16,S,Ka1,100,0.662252 +2306.6,16,S,Ka2,50,0.331126 +2464,16,S,Kb1,5,0.033113 +26359.1,51,Sb,"Ka1,2",1,0.006623 +26110.8,51,Sb,Ka2,54,0.357616 +29725.6,51,Sb,Kb1,18,0.119205 +30389.5,51,Sb,Kb2,5,0.033113 +29679.2,51,Sb,Kb3,9,0.059603 +3604.7,51,Sb,La1,100,0.900901 +3595.3,51,Sb,La2,11,0.099099 +3843.6,51,Sb,Lb1,61,0.54955 +4100.8,51,Sb,"Lb2,15",17,0.153153 +4347.8,51,Sb,Lg1,8,0.072072 +3188.6,51,Sb,Ll,4,0.036036 +4090.6,21,Sc,Ka1,100,0.662252 +4086.1,21,Sc,Ka2,50,0.331126 +4460.5,21,Sc,"Kb1,3",15,0.099338 +395.4,21,Sc,"La1,2",111,1 +399.6,21,Sc,Lb1,77,0.693694 +348.3,21,Sc,Ll,21,0.189189 +11222.4,34,Se,Ka1,100,0.662252 +11181.4,34,Se,Ka2,52,0.344371 +12495.9,34,Se,Kb1,13,0.086093 +12652,34,Se,Kb2,1,0.006623 +12489.6,34,Se,Kb3,6,0.039735 +1379.1,34,Se,"La1,2",111,1 +1419.2,34,Se,Lb1,59,0.531532 +1204.4,34,Se,Ll,6,0.054054 +1740,14,Si,"Ka1,2",1,0.006623 +1739.4,14,Si,Ka2,50,0.331126 +1835.9,14,Si,Kb1,2,0.013245 +40118.1,62,Sm,Ka1,100,0.662252 +39522.4,62,Sm,Ka2,55,0.364238 +45413,62,Sm,Kb1,19,0.125828 +46578,62,Sm,Kb2,6,0.039735 +45289,62,Sm,Kb3,10,0.066225 +5636.1,62,Sm,La1,100,0.900901 +5609,62,Sm,La2,11,0.099099 +6205.1,62,Sm,Lb1,61,0.54955 +6587,62,Sm,"Lb2,15",21,0.189189 +7178,62,Sm,Lg1,10,0.09009 +4994.5,62,Sm,Ll,4,0.036036 +1081,62,Sm,Ma1,100,1 +25271.3,50,Sn,Ka1,100,0.662252 +25044,50,Sn,Ka2,53,0.350993 +28486,50,Sn,Kb1,17,0.112583 +29109.3,50,Sn,Kb2,5,0.033113 +28444,50,Sn,Kb3,9,0.059603 +3444,50,Sn,La1,100,0.900901 +3435.4,50,Sn,La2,11,0.099099 +3662.8,50,Sn,Lb1,60,0.540541 +3904.9,50,Sn,"Lb2,15",16,0.144144 +4131.1,50,Sn,Lg1,7,0.063063 +3045,50,Sn,Ll,4,0.036036 +14165,38,Sr,Ka1,100,0.662252 +14097.9,38,Sr,Ka2,52,0.344371 +15835.7,38,Sr,Kb1,14,0.092715 +16084.6,38,Sr,Kb2,3,0.019868 +15824.9,38,Sr,Kb3,7,0.046358 +1806.6,38,Sr,La1,100,0.900901 +1804.7,38,Sr,La2,11,0.099099 +1871.7,38,Sr,Lb1,58,0.522523 +1582.2,38,Sr,Ll,5,0.045045 +57532,73,Ta,Ka1,100,0.662252 +56277,73,Ta,Ka2,57,0.377483 +65223,73,Ta,Kb1,22,0.145695 +66990,73,Ta,Kb2,7,0.046358 +64948.8,73,Ta,Kb3,11,0.072848 +8146.1,73,Ta,La1,100,0.900901 +8087.9,73,Ta,La2,11,0.099099 +9343.1,73,Ta,Lb1,67,0.603604 +9651.8,73,Ta,Lb2,20,0.18018 +10895.2,73,Ta,Lg1,12,0.108108 +7173.1,73,Ta,Ll,5,0.045045 +1709.6,73,Ta,Ma1,100,1 +44481.6,65,Tb,Ka1,100,0.662252 +43744.1,65,Tb,Ka2,56,0.370861 +50382,65,Tb,Kb1,20,0.13245 +51698,65,Tb,Kb2,7,0.046358 +50229,65,Tb,Kb3,10,0.066225 +6272.8,65,Tb,La1,100,0.900901 +6238,65,Tb,La2,11,0.099099 +6978,65,Tb,Lb1,61,0.54955 +7366.7,65,Tb,"Lb2,15",21,0.189189 +8102,65,Tb,Lg1,11,0.099099 +5546.7,65,Tb,Ll,4,0.036036 +1240,65,Tb,Ma1,100,1 +18367.1,43,Tc,Ka1,100,0.662252 +18250.8,43,Tc,Ka2,53,0.350993 +20619,43,Tc,Kb1,16,0.10596 +21005,43,Tc,Kb2,4,0.02649 +20599,43,Tc,Kb3,8,0.05298 +2424,43,Tc,La1,100,0.900901 +2420,43,Tc,La2,11,0.099099 +2538,43,Tc,Lb1,54,0.486486 +2674,43,Tc,"Lb2,15",7,0.063063 +2792,43,Tc,Lg1,3,0.027027 +2122,43,Tc,Ll,5,0.045045 +27472.3,52,Te,Ka1,100,0.662252 +27201.7,52,Te,Ka2,54,0.357616 +30995.7,52,Te,Kb1,18,0.119205 +31700.4,52,Te,Kb2,5,0.033113 +30944.3,52,Te,Kb3,9,0.059603 +3769.3,52,Te,La1,100,0.900901 +3758.8,52,Te,La2,11,0.099099 +4029.6,52,Te,Lb1,61,0.54955 +4301.7,52,Te,"Lb2,15",18,0.162162 +4570.9,52,Te,Lg1,8,0.072072 +3335.6,52,Te,Ll,4,0.036036 +93350,90,Th,Ka1,100,0.662252 +89953,90,Th,Ka2,62,0.410596 +105609,90,Th,Kb1,24,0.15894 +108640,90,Th,Kb2,9,0.059603 +104831,90,Th,Kb3,12,0.07947 +12968.7,90,Th,La1,100,0.900901 +12809.6,90,Th,La2,11,0.099099 +16202.2,90,Th,Lb1,69,0.621622 +15623.7,90,Th,Lb2,26,0.234234 +18982.5,90,Th,Lg1,16,0.144144 +11118.6,90,Th,Ll,6,0.054054 +2996.1,90,Th,Ma1,100,1 +4510.8,22,Ti,Ka1,100,0.662252 +4504.9,22,Ti,Ka2,50,0.331126 +4931.8,22,Ti,"Kb1,3",15,0.099338 +452.2,22,Ti,"La1,2",111,1 +458.4,22,Ti,Lb1,79,0.711712 +395.3,22,Ti,Ll,46,0.414414 +72871.5,81,Tl,Ka1,100,0.662252 +70831.9,81,Tl,Ka2,60,0.397351 +82576,81,Tl,Kb1,23,0.152318 +84910,81,Tl,Kb2,8,0.05298 +82118,81,Tl,Kb3,12,0.07947 +10268.5,81,Tl,La1,100,0.900901 +10172.8,81,Tl,La2,11,0.099099 +12213.3,81,Tl,Lb1,67,0.603604 +12271.5,81,Tl,Lb2,25,0.225225 +14291.5,81,Tl,Lg1,14,0.126126 +8953.2,81,Tl,Ll,6,0.054054 +2270.6,81,Tl,Ma1,100,1 +50741.6,69,Tm,Ka1,100,0.662252 +49772.6,69,Tm,Ka2,57,0.377483 +57517,69,Tm,Kb1,21,0.139073 +59090,69,Tm,Kb2,7,0.046358 +57304,69,Tm,Kb3,11,0.072848 +7179.9,69,Tm,La1,100,0.900901 +7133.1,69,Tm,La2,11,0.099099 +8101,69,Tm,Lb1,64,0.576577 +8468,69,Tm,"Lb2,15",20,0.18018 +9426,69,Tm,Lg1,12,0.108108 +6341.9,69,Tm,Ll,4,0.036036 +1462,69,Tm,Ma1,1,0.01 +98439,92,U,Ka1,100,0.662252 +94665,92,U,Ka2,62,0.410596 +111300,92,U,Kb1,24,0.15894 +114530,92,U,Kb2,9,0.059603 +110406,92,U,Kb3,13,0.086093 +13614.7,92,U,La1,100,0.900901 +13438.8,92,U,La2,11,0.099099 +17220,92,U,Lb1,61,0.54955 +16428.3,92,U,Lb2,26,0.234234 +20167.1,92,U,Lg1,15,0.135135 +11618.3,92,U,Ll,7,0.063063 +3170.8,92,U,Ma1,100,1 +4952.2,23,V,Ka1,100,0.662252 +4944.6,23,V,Ka2,50,0.331126 +5427.3,23,V,"Kb1,3",15,0.099338 +511.3,23,V,"La1,2",111,1 +519.2,23,V,Lb1,80,0.720721 +446.5,23,V,Ll,28,0.252252 +59318.2,74,W,Ka1,100,0.662252 +57981.7,74,W,Ka2,58,0.384106 +67244.3,74,W,Kb1,22,0.145695 +69067,74,W,Kb2,8,0.05298 +66951.4,74,W,Kb3,11,0.072848 +8397.6,74,W,La1,100,0.900901 +8335.2,74,W,La2,11,0.099099 +9672.4,74,W,Lb1,67,0.603604 +9961.5,74,W,Lb2,21,0.189189 +11285.9,74,W,Lg1,13,0.117117 +7387.8,74,W,Ll,5,0.045045 +1775.4,74,W,Ma1,100,1 +29779,54,Xe,Ka1,100,0.662252 +29458,54,Xe,Ka2,54,0.357616 +33624,54,Xe,Kb1,18,0.119205 +34415,54,Xe,Kb2,5,0.033113 +33562,54,Xe,Kb3,9,0.059603 +4109.9,54,Xe,La1,100,0.900901 +4093,54,Xe,La2,11,0.099099 +4414,54,Xe,Lb1,60,0.540541 +4714,54,Xe,"Lb2,15",20,0.18018 +5034,54,Xe,Lg1,8,0.072072 +3636,54,Xe,Ll,4,0.036036 +14958.4,39,Y,Ka1,100,0.662252 +14882.9,39,Y,Ka2,52,0.344371 +16737.8,39,Y,Kb1,15,0.099338 +17015.4,39,Y,Kb2,3,0.019868 +16725.8,39,Y,Kb3,8,0.05298 +1922.6,39,Y,La1,100,0.900901 +1920.5,39,Y,La2,11,0.099099 +1995.8,39,Y,Lb1,57,0.513514 +1685.4,39,Y,Ll,5,0.045045 +52388.9,70,Yb,Ka1,100,0.662252 +51354,70,Yb,Ka2,57,0.377483 +59370,70,Yb,Kb1,1,0.006623 +60980,70,Yb,Kb2,7,0.046358 +59140,70,Yb,Kb3,11,0.072848 +7415.6,70,Yb,La1,100,0.900901 +7367.3,70,Yb,"La1,2",2,0.018018 +8401.8,70,Yb,Lb1,65,0.585586 +8758.8,70,Yb,"Lb2,15",20,0.18018 +9780.1,70,Yb,Lg1,12,0.108108 +6545.5,70,Yb,Ll,4,0.036036 +1521.4,70,Yb,Ma1,100,1 +8638.9,30,Zn,Ka1,100,0.662252 +8615.8,30,Zn,Ka2,51,0.337748 +9572,30,Zn,"Kb1,3",17,0.112583 +1011.7,30,Zn,"La1,2",111,1 +1034.7,30,Zn,Lb1,65,0.585586 +884,30,Zn,Ll,7,0.063063 +15775.1,40,Zr,Ka1,100,0.662252 +15690.9,40,Zr,Ka2,52,0.344371 +17667.8,40,Zr,Kb1,15,0.099338 +17970,40,Zr,Kb2,3,0.019868 +17654,40,Zr,Kb3,8,0.05298 +2042.4,40,Zr,La1,100,0.900901 +2039.9,40,Zr,La2,11,0.099099 +2124.4,40,Zr,Lb1,54,0.486486 +2219.4,40,Zr,"Lb2,15",1,0.009009 +2302.7,40,Zr,Lg1,2,0.018018 +1792,40,Zr,Ll,5,0.045045 \ No newline at end of file diff --git a/src/quantem/spectroscopy/xray_lines.json b/src/quantem/spectroscopy/xray_lines.json deleted file mode 100644 index 8a465ad2..00000000 --- a/src/quantem/spectroscopy/xray_lines.json +++ /dev/null @@ -1,3985 +0,0 @@ -{ - "elements": { - "Ac": { - "Ka": { - "energy (keV)": 90.884, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 102.846, - "weight": 0.15 - }, - "La": { - "energy (keV)": 12.652, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 15.713, - "weight": 0.4 - }, - "Lb2": { - "energy (keV)": 15.234, - "weight": 0.236 - }, - "Lb3": { - "energy (keV)": 15.931, - "weight": 0.06 - }, - "Lg1": { - "energy (keV)": 18.4083, - "weight": 0.08 - }, - "Lg3": { - "energy (keV)": 18.95, - "weight": 0.017 - }, - "Ll": { - "energy (keV)": 10.869, - "weight": 0.06549 - }, - "Ln": { - "energy (keV)": 14.0812, - "weight": 0.0133 - }, - "M2N4": { - "energy (keV)": 3.9811, - "weight": 0.00674 - }, - "M3O4": { - "energy (keV)": 3.82586, - "weight": 0.01 - }, - "M3O5": { - "energy (keV)": 3.83206, - "weight": 0.01 - }, - "Ma": { - "energy (keV)": 2.9239330000000003, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 3.06626, - "weight": 0.64124 - }, - "Mg": { - "energy (keV)": 3.272, - "weight": 0.33505 - }, - "Mz": { - "energy (keV)": 2.329, - "weight": 0.03512 - } - }, - "Ag": { - "Ka": { - "energy (keV)": 22.1629, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 24.9426, - "weight": 0.15 - }, - "La": { - "energy (keV)": 2.9844, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 3.1509, - "weight": 0.35175 - }, - "Lb2": { - "energy (keV)": 3.3478, - "weight": 0.1165 - }, - "Lb3": { - "energy (keV)": 3.2344, - "weight": 0.0737 - }, - "Lb4": { - "energy (keV)": 3.2034, - "weight": 0.0444 - }, - "Lg1": { - "energy (keV)": 3.5204, - "weight": 0.03735 - }, - "Lg3": { - "energy (keV)": 3.7499, - "weight": 0.014 - }, - "Ll": { - "energy (keV)": 2.6336, - "weight": 0.04129 - }, - "Ln": { - "energy (keV)": 2.8062, - "weight": 0.0131 - } - }, - "Al": { - "Ka": { - "energy (keV)": 1.4865, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 1.5596, - "weight": 0.0132 - } - }, - "Am": {}, - "Ar": { - "Ka": { - "energy (keV)": 2.9577, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 3.1905, - "weight": 0.10169 - } - }, - "As": { - "Ka": { - "energy (keV)": 10.5436, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 11.7262, - "weight": 0.14589 - }, - "La": { - "energy (keV)": 1.2819, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 1.3174, - "weight": 0.16704 - }, - "Lb3": { - "energy (keV)": 1.386, - "weight": 0.04769 - }, - "Ll": { - "energy (keV)": 1.1196, - "weight": 0.04929 - }, - "Ln": { - "energy (keV)": 1.1551, - "weight": 0.01929 - } - }, - "At": { - "Ka": { - "energy (keV)": 81.5164, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 92.3039, - "weight": 0.15 - }, - "La": { - "energy (keV)": 11.4268, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 13.876, - "weight": 0.38048 - }, - "Lb2": { - "energy (keV)": 13.73812, - "weight": 0.2305 - }, - "Lb3": { - "energy (keV)": 14.067, - "weight": 0.06 - }, - "Lb4": { - "energy (keV)": 13.485, - "weight": 0.05809 - }, - "Lg1": { - "energy (keV)": 16.2515, - "weight": 0.08 - }, - "Lg3": { - "energy (keV)": 16.753, - "weight": 0.017 - }, - "Ll": { - "energy (keV)": 9.8965, - "weight": 0.06179 - }, - "Ln": { - "energy (keV)": 12.4677, - "weight": 0.0132 - }, - "M2N4": { - "energy (keV)": 3.4748, - "weight": 0.00863 - }, - "Mb": { - "energy (keV)": 2.71162, - "weight": 0.64124 - }, - "Mg": { - "energy (keV)": 2.95061, - "weight": 0.21845 - }, - "Mz": { - "energy (keV)": 2.0467, - "weight": 0.00354 - } - }, - "Au": { - "Ka": { - "energy (keV)": 68.8062, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 77.9819, - "weight": 0.15 - }, - "La": { - "energy (keV)": 9.713, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 11.4425, - "weight": 0.40151 - }, - "Lb2": { - "energy (keV)": 11.5848, - "weight": 0.21949 - }, - "Lb3": { - "energy (keV)": 11.6098, - "weight": 0.069 - }, - "Lb4": { - "energy (keV)": 11.205, - "weight": 0.0594 - }, - "Lg1": { - "energy (keV)": 13.3816, - "weight": 0.08407 - }, - "Lg3": { - "energy (keV)": 13.8074, - "weight": 0.0194 - }, - "Ll": { - "energy (keV)": 8.4938, - "weight": 0.0562 - }, - "Ln": { - "energy (keV)": 10.3087, - "weight": 0.01379 - }, - "M2N4": { - "energy (keV)": 2.7958, - "weight": 0.02901 - }, - "M3O4": { - "energy (keV)": 2.73469, - "weight": 0.005 - }, - "M3O5": { - "energy (keV)": 2.73621, - "weight": 0.01 - }, - "Ma": { - "energy (keV)": 2.1229, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 2.2047, - "weight": 0.59443 - }, - "Mg": { - "energy (keV)": 2.4091, - "weight": 0.08505 - }, - "Mz": { - "energy (keV)": 1.6603, - "weight": 0.01344 - } - }, - "B": { - "Ka": { - "energy (keV)": 0.1833, - "weight": 1.0 - } - }, - "Ba": { - "Ka": { - "energy (keV)": 32.1936, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 36.3784, - "weight": 0.15 - }, - "La": { - "energy (keV)": 4.4663, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 4.8275, - "weight": 0.43048 - }, - "Lb2": { - "energy (keV)": 5.1571, - "weight": 0.1905 - }, - "Lb3": { - "energy (keV)": 4.9266, - "weight": 0.13779 - }, - "Lb4": { - "energy (keV)": 4.8521, - "weight": 0.08859 - }, - "Lg1": { - "energy (keV)": 5.5311, - "weight": 0.07487 - }, - "Lg3": { - "energy (keV)": 5.8091, - "weight": 0.0331 - }, - "Ll": { - "energy (keV)": 3.9542, - "weight": 0.04299 - }, - "Ln": { - "energy (keV)": 4.3308, - "weight": 0.0151 - } - }, - "Be": { - "Ka": { - "energy (keV)": 0.10258, - "weight": 1.0 - } - }, - "Bi": { - "Ka": { - "energy (keV)": 77.1073, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 87.349, - "weight": 0.15 - }, - "La": { - "energy (keV)": 10.839, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 13.0235, - "weight": 0.4 - }, - "Lb2": { - "energy (keV)": 12.9786, - "weight": 0.2278 - }, - "Lb3": { - "energy (keV)": 13.2106, - "weight": 0.0607 - }, - "Lb4": { - "energy (keV)": 12.6912, - "weight": 0.05639 - }, - "Lg1": { - "energy (keV)": 15.2475, - "weight": 0.08 - }, - "Lg3": { - "energy (keV)": 15.7086, - "weight": 0.017 - }, - "Ll": { - "energy (keV)": 9.4195, - "weight": 0.06 - }, - "Ln": { - "energy (keV)": 11.712, - "weight": 0.0134 - }, - "M2N4": { - "energy (keV)": 3.2327, - "weight": 0.00863 - }, - "M3O4": { - "energy (keV)": 3.1504, - "weight": 0.01 - }, - "M3O5": { - "energy (keV)": 3.1525, - "weight": 0.01 - }, - "Ma": { - "energy (keV)": 2.4222, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 2.5257, - "weight": 0.64124 - }, - "Mg": { - "energy (keV)": 2.7369, - "weight": 0.21845 - }, - "Mz": { - "energy (keV)": 1.9007, - "weight": 0.0058 - } - }, - "Br": { - "Ka": { - "energy (keV)": 11.9238, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 13.2922, - "weight": 0.15289 - }, - "La": { - "energy (keV)": 1.4809, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 1.5259, - "weight": 0.39175 - }, - "Lb3": { - "energy (keV)": 1.6005, - "weight": 0.04629 - }, - "Ll": { - "energy (keV)": 1.2934, - "weight": 0.0462 - }, - "Ln": { - "energy (keV)": 1.3395, - "weight": 0.0182 - } - }, - "C": { - "Ka": { - "energy (keV)": 0.2774, - "weight": 1.0 - } - }, - "Ca": { - "Ka": { - "energy (keV)": 3.6917, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 4.0127, - "weight": 0.112 - }, - "La": { - "energy (keV)": 0.3464, - "weight": 0.0 - }, - "Ll": { - "energy (keV)": 0.3027, - "weight": 1.0 - }, - "Ln": { - "energy (keV)": 0.3063, - "weight": 0.23 - } - }, - "Cd": { - "Ka": { - "energy (keV)": 23.1737, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 26.0947, - "weight": 0.15 - }, - "La": { - "energy (keV)": 3.1338, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 3.3165, - "weight": 0.35704 - }, - "Lb2": { - "energy (keV)": 3.5282, - "weight": 0.1288 - }, - "Lb3": { - "energy (keV)": 3.4015, - "weight": 0.07719 - }, - "Lb4": { - "energy (keV)": 3.3673, - "weight": 0.0469 - }, - "Lg1": { - "energy (keV)": 3.7177, - "weight": 0.0416 - }, - "Lg3": { - "energy (keV)": 3.9511, - "weight": 0.0151 - }, - "Ll": { - "energy (keV)": 2.7673, - "weight": 0.04169 - }, - "Ln": { - "energy (keV)": 2.9568, - "weight": 0.0132 - } - }, - "Ce": { - "Ka": { - "energy (keV)": 34.7196, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 39.2576, - "weight": 0.15 - }, - "La": { - "energy (keV)": 4.8401, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 5.2629, - "weight": 0.43 - }, - "Lb2": { - "energy (keV)": 5.6134, - "weight": 0.19399 - }, - "Lb3": { - "energy (keV)": 5.3634, - "weight": 0.1325 - }, - "Lb4": { - "energy (keV)": 5.276, - "weight": 0.08699 - }, - "Lg1": { - "energy (keV)": 6.0542, - "weight": 0.0764 - }, - "Lg3": { - "energy (keV)": 6.3416, - "weight": 0.0324 - }, - "Ll": { - "energy (keV)": 4.2888, - "weight": 0.0436 - }, - "Ln": { - "energy (keV)": 4.7296, - "weight": 0.015 - }, - "M2N4": { - "energy (keV)": 1.1628, - "weight": 0.08 - }, - "Ma": { - "energy (keV)": 0.8455, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 0.8154, - "weight": 0.91 - }, - "Mg": { - "energy (keV)": 1.0754, - "weight": 0.5 - }, - "Mz": { - "energy (keV)": 0.6761, - "weight": 0.07 - } - }, - "Cl": { - "Ka": { - "energy (keV)": 2.6224, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 2.8156, - "weight": 0.0838 - } - }, - "Co": { - "Ka": { - "energy (keV)": 6.9303, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 7.6494, - "weight": 0.1277 - }, - "La": { - "energy (keV)": 0.7757, - "weight": 1.0 - }, - "Lb3": { - "energy (keV)": 0.8661, - "weight": 0.0238 - }, - "Ll": { - "energy (keV)": 0.6779, - "weight": 0.2157 - }, - "Ln": { - "energy (keV)": 0.6929, - "weight": 0.0833 - } - }, - "Cr": { - "Ka": { - "energy (keV)": 5.4147, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 5.9467, - "weight": 0.134 - }, - "La": { - "energy (keV)": 0.5722, - "weight": 1.0 - }, - "Lb3": { - "energy (keV)": 0.6521, - "weight": 0.0309 - }, - "Ll": { - "energy (keV)": 0.5004, - "weight": 0.6903 - }, - "Ln": { - "energy (keV)": 0.5096, - "weight": 0.2353 - } - }, - "Cs": { - "Ka": { - "energy (keV)": 30.9727, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 34.987, - "weight": 0.15 - }, - "La": { - "energy (keV)": 4.2864, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 4.6199, - "weight": 0.42983 - }, - "Lb2": { - "energy (keV)": 4.9354, - "weight": 0.19589 - }, - "Lb3": { - "energy (keV)": 4.7167, - "weight": 0.1399 - }, - "Lb4": { - "energy (keV)": 4.6493, - "weight": 0.08869 - }, - "Lg1": { - "energy (keV)": 5.2806, - "weight": 0.07215 - }, - "Lg3": { - "energy (keV)": 5.5527, - "weight": 0.0325 - }, - "Ll": { - "energy (keV)": 3.7948, - "weight": 0.04269 - }, - "Ln": { - "energy (keV)": 4.1423, - "weight": 0.0152 - } - }, - "Cu": { - "Ka": { - "energy (keV)": 8.0478, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 8.9053, - "weight": 0.13157 - }, - "La": { - "energy (keV)": 0.9295, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 0.9494, - "weight": 0.03197 - }, - "Lb3": { - "energy (keV)": 1.0225, - "weight": 0.00114 - }, - "Ll": { - "energy (keV)": 0.8113, - "weight": 0.08401 - }, - "Ln": { - "energy (keV)": 0.8312, - "weight": 0.01984 - } - }, - "Dy": { - "Ka": { - "energy (keV)": 45.9984, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 52.1129, - "weight": 0.15 - }, - "La": { - "energy (keV)": 6.4952, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 7.2481, - "weight": 0.444 - }, - "Lb2": { - "energy (keV)": 7.6359, - "weight": 0.2 - }, - "Lb3": { - "energy (keV)": 7.3702, - "weight": 0.12529 - }, - "Lb4": { - "energy (keV)": 7.204, - "weight": 0.0891 - }, - "Lg1": { - "energy (keV)": 8.4264, - "weight": 0.08295 - }, - "Lg3": { - "energy (keV)": 8.7529, - "weight": 0.0319 - }, - "Ll": { - "energy (keV)": 5.7433, - "weight": 0.0473 - }, - "Ln": { - "energy (keV)": 6.5338, - "weight": 0.01489 - }, - "M2N4": { - "energy (keV)": 1.6876, - "weight": 0.008 - }, - "Ma": { - "energy (keV)": 1.2907, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 1.3283, - "weight": 0.76 - }, - "Mg": { - "energy (keV)": 1.5214, - "weight": 0.08505 - }, - "Mz": { - "energy (keV)": 1.002, - "weight": 0.06 - } - }, - "Er": { - "Ka": { - "energy (keV)": 49.1276, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 55.6737, - "weight": 0.15 - }, - "La": { - "energy (keV)": 6.9486, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 7.811, - "weight": 0.45263 - }, - "Lb2": { - "energy (keV)": 8.1903, - "weight": 0.2005 - }, - "Lb3": { - "energy (keV)": 7.9395, - "weight": 0.1258 - }, - "Lb4": { - "energy (keV)": 7.7455, - "weight": 0.0922 - }, - "Lg1": { - "energy (keV)": 9.0876, - "weight": 0.08487 - }, - "Lg3": { - "energy (keV)": 9.4313, - "weight": 0.0324 - }, - "Ll": { - "energy (keV)": 6.1514, - "weight": 0.0482 - }, - "Ln": { - "energy (keV)": 7.0578, - "weight": 0.0153 - }, - "M2N4": { - "energy (keV)": 1.8291, - "weight": 0.0045 - }, - "Ma": { - "energy (keV)": 1.405, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 1.449, - "weight": 0.59443 - }, - "Mg": { - "energy (keV)": 1.6442, - "weight": 0.08505 - }, - "Mz": { - "energy (keV)": 1.0893, - "weight": 0.06 - } - }, - "Eu": { - "Ka": { - "energy (keV)": 41.5421, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 47.0384, - "weight": 0.15 - }, - "La": { - "energy (keV)": 5.846, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 6.4565, - "weight": 0.43904 - }, - "Lb2": { - "energy (keV)": 6.8437, - "weight": 0.1985 - }, - "Lb3": { - "energy (keV)": 6.5714, - "weight": 0.1265 - }, - "Lb4": { - "energy (keV)": 6.4381, - "weight": 0.0874 - }, - "Lg1": { - "energy (keV)": 7.4839, - "weight": 0.08064 - }, - "Lg3": { - "energy (keV)": 7.7954, - "weight": 0.0318 - }, - "Ll": { - "energy (keV)": 5.1769, - "weight": 0.04559 - }, - "Ln": { - "energy (keV)": 5.8171, - "weight": 0.015 - }, - "M2N4": { - "energy (keV)": 1.4807, - "weight": 0.013 - }, - "Ma": { - "energy (keV)": 1.0991, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 1.15769, - "weight": 0.87 - }, - "Mg": { - "energy (keV)": 1.3474, - "weight": 0.26 - }, - "Mz": { - "energy (keV)": 0.8743, - "weight": 0.06 - } - }, - "F": { - "Ka": { - "energy (keV)": 0.6768, - "weight": 1.0 - } - }, - "Fe": { - "Ka": { - "energy (keV)": 6.4039, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 7.058, - "weight": 0.1272 - }, - "La": { - "energy (keV)": 0.7045, - "weight": 1.0 - }, - "Lb3": { - "energy (keV)": 0.7921, - "weight": 0.02448 - }, - "Ll": { - "energy (keV)": 0.6152, - "weight": 0.3086 - }, - "Ln": { - "energy (keV)": 0.6282, - "weight": 0.12525 - } - }, - "Fr": { - "Ka": { - "energy (keV)": 86.1058, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 97.474, - "weight": 0.15 - }, - "La": { - "energy (keV)": 12.0315, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 14.7703, - "weight": 0.38327 - }, - "Lb2": { - "energy (keV)": 14.4542, - "weight": 0.2337 - }, - "Lb3": { - "energy (keV)": 14.976, - "weight": 0.05969 - }, - "Lb4": { - "energy (keV)": 14.312, - "weight": 0.0603 - }, - "Lg1": { - "energy (keV)": 17.3032, - "weight": 0.08 - }, - "Lg3": { - "energy (keV)": 17.829, - "weight": 0.017 - }, - "Ll": { - "energy (keV)": 10.3792, - "weight": 0.06339 - }, - "Ln": { - "energy (keV)": 13.2545, - "weight": 0.0134 - }, - "M2N4": { - "energy (keV)": 3.7237, - "weight": 0.00674 - }, - "Mb": { - "energy (keV)": 2.88971, - "weight": 0.64124 - }, - "Mg": { - "energy (keV)": 3.086, - "weight": 0.21845 - }, - "Mz": { - "energy (keV)": 2.1897, - "weight": 0.0058 - } - }, - "Ga": { - "Ka": { - "energy (keV)": 9.2517, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 10.2642, - "weight": 0.1287 - }, - "La": { - "energy (keV)": 1.098, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 1.1249, - "weight": 0.16704 - }, - "Lb3": { - "energy (keV)": 1.1948, - "weight": 0.0461 - }, - "Ll": { - "energy (keV)": 0.9573, - "weight": 0.0544 - }, - "Ln": { - "energy (keV)": 0.9842, - "weight": 0.02509 - } - }, - "Gd": { - "Ka": { - "energy (keV)": 42.9963, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 48.6951, - "weight": 0.15 - }, - "La": { - "energy (keV)": 6.0576, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 6.7131, - "weight": 0.44127 - }, - "Lb2": { - "energy (keV)": 7.1023, - "weight": 0.2014 - }, - "Lb3": { - "energy (keV)": 6.8316, - "weight": 0.1255 - }, - "Lb4": { - "energy (keV)": 6.6873, - "weight": 0.08789 - }, - "Lg1": { - "energy (keV)": 7.7898, - "weight": 0.08207 - }, - "Lg3": { - "energy (keV)": 8.1047, - "weight": 0.032 - }, - "Ll": { - "energy (keV)": 5.362, - "weight": 0.04629 - }, - "Ln": { - "energy (keV)": 6.0495, - "weight": 0.01489 - }, - "M2N4": { - "energy (keV)": 1.5478, - "weight": 0.014 - }, - "Ma": { - "energy (keV)": 1.17668, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 1.20792, - "weight": 0.88 - }, - "Mg": { - "energy (keV)": 1.4035, - "weight": 0.261 - }, - "Mz": { - "energy (keV)": 0.9143, - "weight": 0.06 - } - }, - "Ge": { - "Ka": { - "energy (keV)": 9.8864, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 10.9823, - "weight": 0.1322 - }, - "La": { - "energy (keV)": 1.188, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 1.2191, - "weight": 0.16704 - }, - "Lb3": { - "energy (keV)": 1.2935, - "weight": 0.04429 - }, - "Ll": { - "energy (keV)": 1.0367, - "weight": 0.0511 - }, - "Ln": { - "energy (keV)": 1.0678, - "weight": 0.02 - } - }, - "H": { - "Ka": { - "energy (keV)": 0.0013598, - "weight": 1.0 - } - }, - "He": { - "Ka": { - "energy (keV)": 0.0024587, - "weight": 1.0 - } - }, - "Hf": { - "Ka": { - "energy (keV)": 55.7901, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 63.2432, - "weight": 0.15 - }, - "La": { - "energy (keV)": 7.899, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 9.023, - "weight": 0.46231 - }, - "Lb2": { - "energy (keV)": 9.347, - "weight": 0.2048 - }, - "Lb3": { - "energy (keV)": 9.1631, - "weight": 0.1316 - }, - "Lb4": { - "energy (keV)": 8.9053, - "weight": 0.10189 - }, - "Lg1": { - "energy (keV)": 10.5156, - "weight": 0.08968 - }, - "Lg3": { - "energy (keV)": 10.8903, - "weight": 0.0347 - }, - "Ll": { - "energy (keV)": 6.9598, - "weight": 0.05089 - }, - "Ln": { - "energy (keV)": 8.1385, - "weight": 0.0158 - }, - "M2N4": { - "energy (keV)": 2.1416, - "weight": 0.01 - }, - "Ma": { - "energy (keV)": 1.6446, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 1.6993, - "weight": 0.59443 - }, - "Mg": { - "energy (keV)": 1.8939, - "weight": 0.08505 - }, - "Mz": { - "energy (keV)": 1.2813, - "weight": 0.06 - } - }, - "Hg": { - "Ka": { - "energy (keV)": 70.8184, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 80.2552, - "weight": 0.15 - }, - "La": { - "energy (keV)": 9.989, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 11.8238, - "weight": 0.39504 - }, - "Lb2": { - "energy (keV)": 11.9241, - "weight": 0.2221 - }, - "Lb3": { - "energy (keV)": 11.9922, - "weight": 0.06469 - }, - "Lb4": { - "energy (keV)": 11.5608, - "weight": 0.0566 - }, - "Lg1": { - "energy (keV)": 13.8304, - "weight": 0.0832 - }, - "Lg3": { - "energy (keV)": 14.2683, - "weight": 0.0184 - }, - "Ll": { - "energy (keV)": 8.7223, - "weight": 0.05709 - }, - "Ln": { - "energy (keV)": 10.6471, - "weight": 0.0136 - }, - "M2N4": { - "energy (keV)": 2.9002, - "weight": 0.02901 - }, - "M3O4": { - "energy (keV)": 2.8407, - "weight": 0.005 - }, - "M3O5": { - "energy (keV)": 2.8407, - "weight": 0.01 - }, - "Ma": { - "energy (keV)": 2.1964, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 2.2827, - "weight": 0.64124 - }, - "Mg": { - "energy (keV)": 2.4873, - "weight": 0.08505 - }, - "Mz": { - "energy (keV)": 1.7239, - "weight": 0.01344 - } - }, - "Ho": { - "Ka": { - "energy (keV)": 47.5466, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 53.8765, - "weight": 0.15 - }, - "La": { - "energy (keV)": 6.7197, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 7.5263, - "weight": 0.45056 - }, - "Lb2": { - "energy (keV)": 7.9101, - "weight": 0.23563 - }, - "Lb3": { - "energy (keV)": 7.653, - "weight": 0.06 - }, - "Lb4": { - "energy (keV)": 7.4714, - "weight": 0.09039 - }, - "Lg1": { - "energy (keV)": 8.7568, - "weight": 0.08448 - }, - "Lg3": { - "energy (keV)": 9.0876, - "weight": 0.0321 - }, - "Ll": { - "energy (keV)": 5.9428, - "weight": 0.04759 - }, - "Ln": { - "energy (keV)": 6.7895, - "weight": 0.0151 - }, - "M2N4": { - "energy (keV)": 1.7618, - "weight": 0.072 - }, - "Ma": { - "energy (keV)": 1.3477, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 1.3878, - "weight": 0.59443 - }, - "Mg": { - "energy (keV)": 1.5802, - "weight": 0.1418 - }, - "Mz": { - "energy (keV)": 1.0448, - "weight": 0.6629 - } - }, - "I": { - "Ka": { - "energy (keV)": 28.6123, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 32.2948, - "weight": 0.15 - }, - "La": { - "energy (keV)": 3.9377, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 4.2208, - "weight": 0.43087 - }, - "Lb2": { - "energy (keV)": 4.5075, - "weight": 0.17059 - }, - "Lb3": { - "energy (keV)": 4.3135, - "weight": 0.1464 - }, - "Lb4": { - "energy (keV)": 4.2576, - "weight": 0.09189 - }, - "Lg1": { - "energy (keV)": 4.8025, - "weight": 0.06704 - }, - "Lg3": { - "energy (keV)": 5.0654, - "weight": 0.0327 - }, - "Ll": { - "energy (keV)": 3.485, - "weight": 0.0423 - }, - "Ln": { - "energy (keV)": 3.78, - "weight": 0.0154 - } - }, - "In": { - "Ka": { - "energy (keV)": 24.2098, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 27.2756, - "weight": 0.15 - }, - "La": { - "energy (keV)": 3.287, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 3.4872, - "weight": 0.3616 - }, - "Lb2": { - "energy (keV)": 3.7139, - "weight": 0.1371 - }, - "Lb3": { - "energy (keV)": 3.5732, - "weight": 0.08779 - }, - "Lb4": { - "energy (keV)": 3.5353, - "weight": 0.05349 - }, - "Lg1": { - "energy (keV)": 3.9218, - "weight": 0.04535 - }, - "Lg3": { - "energy (keV)": 4.1601, - "weight": 0.0177 - }, - "Ll": { - "energy (keV)": 2.9045, - "weight": 0.0415 - }, - "Ln": { - "energy (keV)": 3.1124, - "weight": 0.0132 - } - }, - "Ir": { - "Ka": { - "energy (keV)": 64.8958, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 73.5603, - "weight": 0.15 - }, - "La": { - "energy (keV)": 9.1748, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 10.708, - "weight": 0.4168 - }, - "Lb2": { - "energy (keV)": 10.9203, - "weight": 0.216 - }, - "Lb3": { - "energy (keV)": 10.8678, - "weight": 0.0874 - }, - "Lb4": { - "energy (keV)": 10.5098, - "weight": 0.07269 - }, - "Lg1": { - "energy (keV)": 12.5127, - "weight": 0.08543 - }, - "Lg3": { - "energy (keV)": 12.9242, - "weight": 0.024 - }, - "Ll": { - "energy (keV)": 8.0415, - "weight": 0.05429 - }, - "Ln": { - "energy (keV)": 9.6504, - "weight": 0.01429 - }, - "M2N4": { - "energy (keV)": 2.5973, - "weight": 0.02901 - }, - "M3O4": { - "energy (keV)": 2.54264, - "weight": 0.005 - }, - "M3O5": { - "energy (keV)": 2.54385, - "weight": 0.01 - }, - "Ma": { - "energy (keV)": 1.9799, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 2.0527, - "weight": 0.59443 - }, - "Mg": { - "energy (keV)": 2.2558, - "weight": 0.08505 - }, - "Mz": { - "energy (keV)": 1.5461, - "weight": 0.01344 - } - }, - "K": { - "Ka": { - "energy (keV)": 3.3138, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 3.5896, - "weight": 0.1039 - } - }, - "Kr": { - "Ka": { - "energy (keV)": 12.6507, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 14.1118, - "weight": 0.1538 - }, - "La": { - "energy (keV)": 1.586, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 1.6383, - "weight": 0.39031 - }, - "Lb3": { - "energy (keV)": 1.7072, - "weight": 0.0465 - }, - "Lg3": { - "energy (keV)": 1.921, - "weight": 0.005 - }, - "Ll": { - "energy (keV)": 1.38657, - "weight": 0.04509 - }, - "Ln": { - "energy (keV)": 1.43887, - "weight": 0.0175 - } - }, - "La": { - "Ka": { - "energy (keV)": 33.4419, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 37.8012, - "weight": 0.15 - }, - "La": { - "energy (keV)": 4.651, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 5.0421, - "weight": 0.42631 - }, - "Lb2": { - "energy (keV)": 5.3838, - "weight": 0.19579 - }, - "Lb3": { - "energy (keV)": 5.1429, - "weight": 0.1341 - }, - "Lb4": { - "energy (keV)": 5.0619, - "weight": 0.0872 - }, - "Lg1": { - "energy (keV)": 5.7917, - "weight": 0.07656 - }, - "Lg3": { - "energy (keV)": 6.0749, - "weight": 0.0329 - }, - "Ll": { - "energy (keV)": 4.1214, - "weight": 0.0432 - }, - "Ln": { - "energy (keV)": 4.5293, - "weight": 0.015 - }, - "M2N4": { - "energy (keV)": 1.1055, - "weight": 0.022 - }, - "Ma": { - "energy (keV)": 0.8173, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 0.8162, - "weight": 0.9 - }, - "Mg": { - "energy (keV)": 1.0245, - "weight": 0.4 - }, - "Mz": { - "energy (keV)": 0.6403, - "weight": 0.06 - } - }, - "Li": {}, - "Lu": { - "Ka": { - "energy (keV)": 54.0697, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 61.2902, - "weight": 0.15 - }, - "La": { - "energy (keV)": 7.6556, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 8.7092, - "weight": 0.46975 - }, - "Lb2": { - "energy (keV)": 9.0491, - "weight": 0.20359 - }, - "Lb3": { - "energy (keV)": 8.8468, - "weight": 0.13099 - }, - "Lb4": { - "energy (keV)": 8.6069, - "weight": 0.0996 - }, - "Lg1": { - "energy (keV)": 10.1438, - "weight": 0.08968 - }, - "Lg3": { - "energy (keV)": 10.5111, - "weight": 0.0342 - }, - "Ll": { - "energy (keV)": 6.7529, - "weight": 0.05009 - }, - "Ln": { - "energy (keV)": 7.8574, - "weight": 0.016 - }, - "M2N4": { - "energy (keV)": 2.0587, - "weight": 0.01 - }, - "Ma": { - "energy (keV)": 1.5816, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 1.6325, - "weight": 0.59443 - }, - "Mg": { - "energy (keV)": 1.8286, - "weight": 0.08505 - }, - "Mz": { - "energy (keV)": 1.2292, - "weight": 0.06 - } - }, - "Mg": { - "Ka": { - "energy (keV)": 1.2536, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 1.305, - "weight": 0.01 - } - }, - "Mn": { - "Ka": { - "energy (keV)": 5.8987, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 6.4904, - "weight": 0.1252 - }, - "La": { - "energy (keV)": 0.63316, - "weight": 1.0 - }, - "Lb3": { - "energy (keV)": 0.7204, - "weight": 0.0263 - }, - "Ll": { - "energy (keV)": 0.5564, - "weight": 0.3898 - }, - "Ln": { - "energy (keV)": 0.5675, - "weight": 0.1898 - } - }, - "Mo": { - "Ka": { - "energy (keV)": 17.4793, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 19.6072, - "weight": 0.15 - }, - "La": { - "energy (keV)": 2.2932, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 2.3948, - "weight": 0.32736 - }, - "Lb2": { - "energy (keV)": 2.5184, - "weight": 0.04509 - }, - "Lb3": { - "energy (keV)": 2.4732, - "weight": 0.06299 - }, - "Lg1": { - "energy (keV)": 2.6233, - "weight": 0.01335 - }, - "Lg3": { - "energy (keV)": 2.8307, - "weight": 0.0105 - }, - "Ll": { - "energy (keV)": 2.0156, - "weight": 0.0415 - }, - "Ln": { - "energy (keV)": 2.1205, - "weight": 0.0128 - } - }, - "N": { - "Ka": { - "energy (keV)": 0.3924, - "weight": 1.0 - } - }, - "Na": { - "Ka": { - "energy (keV)": 1.041, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 1.0721, - "weight": 0.01 - } - }, - "Nb": { - "Ka": { - "energy (keV)": 16.6151, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 18.6226, - "weight": 0.15 - }, - "La": { - "energy (keV)": 2.1659, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 2.2573, - "weight": 0.32519 - }, - "Lb2": { - "energy (keV)": 2.3705, - "weight": 0.03299 - }, - "Lb3": { - "energy (keV)": 2.3347, - "weight": 0.06429 - }, - "Lg1": { - "energy (keV)": 2.4615, - "weight": 0.00975 - }, - "Lg3": { - "energy (keV)": 2.6638, - "weight": 0.0103 - }, - "Ll": { - "energy (keV)": 1.9021, - "weight": 0.04169 - }, - "Ln": { - "energy (keV)": 1.9963, - "weight": 0.0129 - } - }, - "Nd": { - "Ka": { - "energy (keV)": 37.361, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 42.2715, - "weight": 0.15 - }, - "La": { - "energy (keV)": 5.2302, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 5.722, - "weight": 0.42672 - }, - "Lb2": { - "energy (keV)": 6.0904, - "weight": 0.1957 - }, - "Lb3": { - "energy (keV)": 5.8286, - "weight": 0.12869 - }, - "Lb4": { - "energy (keV)": 5.7232, - "weight": 0.0858 - }, - "Lg1": { - "energy (keV)": 6.604, - "weight": 0.07712 - }, - "Lg3": { - "energy (keV)": 6.9014, - "weight": 0.0318 - }, - "Ll": { - "energy (keV)": 4.6326, - "weight": 0.04429 - }, - "Ln": { - "energy (keV)": 5.1462, - "weight": 0.01469 - }, - "M2N4": { - "energy (keV)": 1.2853, - "weight": 0.052 - }, - "Ma": { - "energy (keV)": 0.9402, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 0.9965, - "weight": 0.99 - }, - "Mg": { - "energy (keV)": 1.1799, - "weight": 0.625 - }, - "Mz": { - "energy (keV)": 0.7531, - "weight": 0.069 - } - }, - "Ne": { - "Ka": { - "energy (keV)": 0.8486, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 0.8669, - "weight": 0.01 - } - }, - "Ni": { - "Ka": { - "energy (keV)": 7.4781, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 8.2647, - "weight": 0.1277 - }, - "La": { - "energy (keV)": 0.8511, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 0.8683, - "weight": 0.1677 - }, - "Lb3": { - "energy (keV)": 0.94, - "weight": 0.00199 - }, - "Ll": { - "energy (keV)": 0.7429, - "weight": 0.14133 - }, - "Ln": { - "energy (keV)": 0.7601, - "weight": 0.09693 - } - }, - "Np": {}, - "O": { - "Ka": { - "energy (keV)": 0.5249, - "weight": 1.0 - } - }, - "Os": { - "Ka": { - "energy (keV)": 62.9999, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 71.4136, - "weight": 0.15 - }, - "La": { - "energy (keV)": 8.9108, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 10.3542, - "weight": 0.43207 - }, - "Lb2": { - "energy (keV)": 10.5981, - "weight": 0.2146 - }, - "Lb3": { - "energy (keV)": 10.5108, - "weight": 0.1024 - }, - "Lb4": { - "energy (keV)": 10.1758, - "weight": 0.08369 - }, - "Lg1": { - "energy (keV)": 12.0956, - "weight": 0.08768 - }, - "Lg3": { - "energy (keV)": 12.4998, - "weight": 0.028 - }, - "Ll": { - "energy (keV)": 7.8224, - "weight": 0.05389 - }, - "Ln": { - "energy (keV)": 9.3365, - "weight": 0.01479 - }, - "M2N4": { - "energy (keV)": 2.5028, - "weight": 0.02901 - }, - "M3O4": { - "energy (keV)": 2.45015, - "weight": 0.005 - }, - "M3O5": { - "energy (keV)": 2.45117, - "weight": 0.01 - }, - "Ma": { - "energy (keV)": 1.9138, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 1.9845, - "weight": 0.59443 - }, - "Mg": { - "energy (keV)": 2.1844, - "weight": 0.08505 - }, - "Mz": { - "energy (keV)": 1.4919, - "weight": 0.01344 - } - }, - "P": { - "Ka": { - "energy (keV)": 2.0133, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 2.13916, - "weight": 0.0498 - } - }, - "Pa": { - "Ka": { - "energy (keV)": 95.8679, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 108.4272, - "weight": 0.15 - }, - "La": { - "energy (keV)": 13.2913, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 16.7025, - "weight": 0.4 - }, - "Lb2": { - "energy (keV)": 16.0249, - "weight": 0.236 - }, - "Lb3": { - "energy (keV)": 16.9308, - "weight": 0.06 - }, - "Lb4": { - "energy (keV)": 16.1037, - "weight": 0.04 - }, - "Lg1": { - "energy (keV)": 19.5703, - "weight": 0.08 - }, - "Lg3": { - "energy (keV)": 20.0979, - "weight": 0.017 - }, - "Ll": { - "energy (keV)": 11.3662, - "weight": 0.0682 - }, - "Ln": { - "energy (keV)": 14.9468, - "weight": 0.0126 - }, - "M2N4": { - "energy (keV)": 4.2575, - "weight": 0.00674 - }, - "M3O4": { - "energy (keV)": 4.07712, - "weight": 0.01 - }, - "M3O5": { - "energy (keV)": 4.08456, - "weight": 0.01 - }, - "Ma": { - "energy (keV)": 3.0823, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 3.24, - "weight": 0.64124 - }, - "Mg": { - "energy (keV)": 3.4656, - "weight": 0.33505 - }, - "Mz": { - "energy (keV)": 2.4351, - "weight": 0.03512 - } - }, - "Pb": { - "Ka": { - "energy (keV)": 74.9693, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 84.9381, - "weight": 0.15 - }, - "La": { - "energy (keV)": 10.5512, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 12.6144, - "weight": 0.3836 - }, - "Lb2": { - "energy (keV)": 12.6223, - "weight": 0.2244 - }, - "Lb3": { - "energy (keV)": 12.7944, - "weight": 0.06049 - }, - "Lb4": { - "energy (keV)": 12.3066, - "weight": 0.055 - }, - "Lg1": { - "energy (keV)": 14.7648, - "weight": 0.08256 - }, - "Lg3": { - "energy (keV)": 15.2163, - "weight": 0.017 - }, - "Ll": { - "energy (keV)": 9.1845, - "weight": 0.0586 - }, - "Ln": { - "energy (keV)": 11.3493, - "weight": 0.0132 - }, - "M2N4": { - "energy (keV)": 3.119, - "weight": 0.00863 - }, - "M3O4": { - "energy (keV)": 3.0446, - "weight": 0.01 - }, - "M3O5": { - "energy (keV)": 3.0472, - "weight": 0.01 - }, - "Ma": { - "energy (keV)": 2.3459, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 2.4427, - "weight": 0.64124 - }, - "Mg": { - "energy (keV)": 2.6535, - "weight": 0.21845 - }, - "Mz": { - "energy (keV)": 1.8395, - "weight": 0.0058 - } - }, - "Pd": { - "Ka": { - "energy (keV)": 21.177, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 23.8188, - "weight": 0.15 - }, - "La": { - "energy (keV)": 2.8386, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 2.9903, - "weight": 0.34375 - }, - "Lb2": { - "energy (keV)": 3.16828, - "weight": 0.10349 - }, - "Lb3": { - "energy (keV)": 3.0728, - "weight": 0.0682 - }, - "Lb4": { - "energy (keV)": 3.0452, - "weight": 0.0407 - }, - "Lg1": { - "energy (keV)": 3.32485, - "weight": 0.03256 - }, - "Lg3": { - "energy (keV)": 3.5532, - "weight": 0.0125 - }, - "Ll": { - "energy (keV)": 2.5034, - "weight": 0.0412 - }, - "Ln": { - "energy (keV)": 2.6604, - "weight": 0.0129 - } - }, - "Pm": { - "Ka": { - "energy (keV)": 38.7247, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 43.8271, - "weight": 0.15 - }, - "La": { - "energy (keV)": 5.4324, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 5.9613, - "weight": 0.4308 - }, - "Lb2": { - "energy (keV)": 6.3389, - "weight": 0.196 - }, - "Lb3": { - "energy (keV)": 6.071, - "weight": 0.1247 - }, - "Lb4": { - "energy (keV)": 5.9565, - "weight": 0.07799 - }, - "Lg1": { - "energy (keV)": 6.8924, - "weight": 0.0784 - }, - "Lg3": { - "energy (keV)": 7.1919, - "weight": 0.0311 - }, - "Ll": { - "energy (keV)": 4.8128, - "weight": 0.0448 - }, - "Ln": { - "energy (keV)": 5.3663, - "weight": 0.01479 - }, - "M2N4": { - "energy (keV)": 1.351, - "weight": 0.028 - }, - "Ma": { - "energy (keV)": 0.9894, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 1.0475, - "weight": 0.89 - }, - "Mg": { - "energy (keV)": 1.2365, - "weight": 0.4 - }, - "Mz": { - "energy (keV)": 0.7909, - "weight": 0.068 - } - }, - "Po": { - "Ka": { - "energy (keV)": 79.2912, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 89.8031, - "weight": 0.15 - }, - "La": { - "energy (keV)": 11.1308, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 13.4463, - "weight": 0.38536 - }, - "Lb2": { - "energy (keV)": 13.3404, - "weight": 0.2289 - }, - "Lb3": { - "energy (keV)": 13.6374, - "weight": 0.0603 - }, - "Lb4": { - "energy (keV)": 13.0852, - "weight": 0.05709 - }, - "Lg1": { - "energy (keV)": 15.7441, - "weight": 0.08 - }, - "Lg3": { - "energy (keV)": 16.2343, - "weight": 0.017 - }, - "Ll": { - "energy (keV)": 9.6644, - "weight": 0.0607 - }, - "Ln": { - "energy (keV)": 12.0949, - "weight": 0.0133 - }, - "M2N4": { - "energy (keV)": 3.3539, - "weight": 0.00863 - }, - "Mb": { - "energy (keV)": 2.62266, - "weight": 0.64124 - }, - "Mg": { - "energy (keV)": 2.8285, - "weight": 0.21845 - }, - "Mz": { - "energy (keV)": 1.978, - "weight": 0.00354 - } - }, - "Pr": { - "Ka": { - "energy (keV)": 36.0263, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 40.7484, - "weight": 0.15 - }, - "La": { - "energy (keV)": 5.0333, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 5.4893, - "weight": 0.42872 - }, - "Lb2": { - "energy (keV)": 5.8511, - "weight": 0.19519 - }, - "Lb3": { - "energy (keV)": 5.5926, - "weight": 0.13089 - }, - "Lb4": { - "energy (keV)": 5.4974, - "weight": 0.0864 - }, - "Lg1": { - "energy (keV)": 6.3272, - "weight": 0.07687 - }, - "Lg3": { - "energy (keV)": 6.6172, - "weight": 0.0321 - }, - "Ll": { - "energy (keV)": 4.4533, - "weight": 0.044 - }, - "Ln": { - "energy (keV)": 4.9294, - "weight": 0.01489 - }, - "M2N4": { - "energy (keV)": 1.2242, - "weight": 0.055 - }, - "Ma": { - "energy (keV)": 0.8936, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 0.9476, - "weight": 0.85 - }, - "Mg": { - "energy (keV)": 1.129, - "weight": 0.6 - }, - "Mz": { - "energy (keV)": 0.7134, - "weight": 0.068 - } - }, - "Pt": { - "Ka": { - "energy (keV)": 66.8311, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 75.7494, - "weight": 0.15 - }, - "La": { - "energy (keV)": 9.4421, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 11.0707, - "weight": 0.4088 - }, - "Lb2": { - "energy (keV)": 11.2504, - "weight": 0.21829 - }, - "Lb3": { - "energy (keV)": 11.2345, - "weight": 0.0783 - }, - "Lb4": { - "energy (keV)": 10.8534, - "weight": 0.0662 - }, - "Lg1": { - "energy (keV)": 12.9418, - "weight": 0.08448 - }, - "Lg3": { - "energy (keV)": 13.3609, - "weight": 0.0218 - }, - "Ll": { - "energy (keV)": 8.2677, - "weight": 0.0554 - }, - "Ln": { - "energy (keV)": 9.9766, - "weight": 0.01399 - }, - "M2N4": { - "energy (keV)": 2.6957, - "weight": 0.02901 - }, - "M3O4": { - "energy (keV)": 2.63796, - "weight": 0.005 - }, - "M3O5": { - "energy (keV)": 2.63927, - "weight": 0.01 - }, - "Ma": { - "energy (keV)": 2.0505, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 2.1276, - "weight": 0.59443 - }, - "Mg": { - "energy (keV)": 2.3321, - "weight": 0.08505 - }, - "Mz": { - "energy (keV)": 1.6026, - "weight": 0.01344 - } - }, - "Pu": {}, - "Ra": { - "Ka": { - "energy (keV)": 88.4776, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 100.1302, - "weight": 0.15 - }, - "La": { - "energy (keV)": 12.3395, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 15.2359, - "weight": 0.4 - }, - "Lb2": { - "energy (keV)": 14.8417, - "weight": 0.23579 - }, - "Lb3": { - "energy (keV)": 15.4449, - "weight": 0.06 - }, - "Lb4": { - "energy (keV)": 14.7472, - "weight": 0.06209 - }, - "Lg1": { - "energy (keV)": 17.8484, - "weight": 0.08 - }, - "Lg3": { - "energy (keV)": 18.3576, - "weight": 0.017 - }, - "Ll": { - "energy (keV)": 10.6224, - "weight": 0.06429 - }, - "Ln": { - "energy (keV)": 13.6623, - "weight": 0.0133 - }, - "M2N4": { - "energy (keV)": 3.8536, - "weight": 0.00674 - }, - "Mb": { - "energy (keV)": 2.9495, - "weight": 0.64124 - }, - "Mg": { - "energy (keV)": 3.1891, - "weight": 0.33505 - }, - "Mz": { - "energy (keV)": 2.2258, - "weight": 0.03512 - } - }, - "Rb": { - "Ka": { - "energy (keV)": 13.3953, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 14.9612, - "weight": 0.1558 - }, - "La": { - "energy (keV)": 1.6941, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 1.7521, - "weight": 0.39095 - }, - "Lb3": { - "energy (keV)": 1.8266, - "weight": 0.04709 - }, - "Lg3": { - "energy (keV)": 2.0651, - "weight": 0.0058 - }, - "Ll": { - "energy (keV)": 1.4823, - "weight": 0.0441 - }, - "Ln": { - "energy (keV)": 1.5418, - "weight": 0.01709 - } - }, - "Re": { - "Ka": { - "energy (keV)": 61.1411, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 69.3091, - "weight": 0.15 - }, - "La": { - "energy (keV)": 8.6524, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 10.0098, - "weight": 0.4408 - }, - "Lb2": { - "energy (keV)": 10.2751, - "weight": 0.21219 - }, - "Lb3": { - "energy (keV)": 10.1594, - "weight": 0.1222 - }, - "Lb4": { - "energy (keV)": 9.8451, - "weight": 0.09869 - }, - "Lg1": { - "energy (keV)": 11.685, - "weight": 0.08864 - }, - "Lg3": { - "energy (keV)": 12.0823, - "weight": 0.0331 - }, - "Ll": { - "energy (keV)": 7.6036, - "weight": 0.05299 - }, - "Ln": { - "energy (keV)": 9.027, - "weight": 0.0151 - }, - "M2N4": { - "energy (keV)": 2.4079, - "weight": 0.01 - }, - "M3O4": { - "energy (keV)": 2.36124, - "weight": 0.001 - }, - "M3O5": { - "energy (keV)": 2.36209, - "weight": 0.01 - }, - "Ma": { - "energy (keV)": 1.8423, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 1.9083, - "weight": 0.59443 - }, - "Mg": { - "energy (keV)": 2.1071, - "weight": 0.08505 - }, - "Mz": { - "energy (keV)": 1.4385, - "weight": 0.01344 - } - }, - "Rh": { - "Ka": { - "energy (keV)": 20.2161, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 22.7237, - "weight": 0.15 - }, - "La": { - "energy (keV)": 2.6968, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 2.8344, - "weight": 0.33463 - }, - "Lb2": { - "energy (keV)": 3.0013, - "weight": 0.08539 - }, - "Lb3": { - "energy (keV)": 2.9157, - "weight": 0.06669 - }, - "Lb4": { - "energy (keV)": 2.8909, - "weight": 0.0395 - }, - "Lg1": { - "energy (keV)": 3.1436, - "weight": 0.02623 - }, - "Lg3": { - "energy (keV)": 3.364, - "weight": 0.0121 - }, - "Ll": { - "energy (keV)": 2.3767, - "weight": 0.0411 - }, - "Ln": { - "energy (keV)": 2.519, - "weight": 0.0126 - } - }, - "Rn": { - "Ka": { - "energy (keV)": 83.7846, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 94.866, - "weight": 0.15 - }, - "La": { - "energy (keV)": 11.727, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 14.3156, - "weight": 0.38463 - }, - "Lb2": { - "energy (keV)": 14.0824, - "weight": 0.2325 - }, - "Lb3": { - "energy (keV)": 14.511, - "weight": 0.0607 - }, - "Lb4": { - "energy (keV)": 13.89, - "weight": 0.06 - }, - "Lg1": { - "energy (keV)": 16.7705, - "weight": 0.08 - }, - "Lg3": { - "energy (keV)": 17.281, - "weight": 0.017 - }, - "Ll": { - "energy (keV)": 10.1374, - "weight": 0.0625 - }, - "Ln": { - "energy (keV)": 12.8551, - "weight": 0.0134 - }, - "M2N4": { - "energy (keV)": 3.5924, - "weight": 0.00863 - }, - "Mb": { - "energy (keV)": 2.80187, - "weight": 0.64124 - }, - "Mg": { - "energy (keV)": 3.001, - "weight": 0.21845 - }, - "Mz": { - "energy (keV)": 2.1244, - "weight": 0.0058 - } - }, - "Ru": { - "Ka": { - "energy (keV)": 19.2793, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 21.6566, - "weight": 0.15 - }, - "La": { - "energy (keV)": 2.5585, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 2.6833, - "weight": 0.33039 - }, - "Lb2": { - "energy (keV)": 2.8359, - "weight": 0.07259 - }, - "Lb3": { - "energy (keV)": 2.7634, - "weight": 0.0654 - }, - "Lg1": { - "energy (keV)": 2.9649, - "weight": 0.02176 - }, - "Lg3": { - "energy (keV)": 3.1809, - "weight": 0.0115 - }, - "Ll": { - "energy (keV)": 2.2529, - "weight": 0.0411 - }, - "Ln": { - "energy (keV)": 2.3819, - "weight": 0.0126 - } - }, - "S": { - "Ka": { - "energy (keV)": 2.3072, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 2.46427, - "weight": 0.06525 - } - }, - "Sb": { - "Ka": { - "energy (keV)": 26.359, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 29.7256, - "weight": 0.15 - }, - "La": { - "energy (keV)": 3.6047, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 3.8435, - "weight": 0.4276 - }, - "Lb2": { - "energy (keV)": 4.1008, - "weight": 0.1556 - }, - "Lb3": { - "energy (keV)": 3.9327, - "weight": 0.15099 - }, - "Lb4": { - "energy (keV)": 3.8864, - "weight": 0.0932 - }, - "Lg1": { - "energy (keV)": 4.349, - "weight": 0.06064 - }, - "Lg3": { - "energy (keV)": 4.5999, - "weight": 0.0321 - }, - "Ll": { - "energy (keV)": 3.1885, - "weight": 0.0419 - }, - "Ln": { - "energy (keV)": 3.4367, - "weight": 0.0155 - } - }, - "Sc": { - "Ka": { - "energy (keV)": 4.0906, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 4.4605, - "weight": 0.12839 - }, - "La": { - "energy (keV)": 0.4022, - "weight": 0.308 - }, - "Lb3": { - "energy (keV)": 0.4681, - "weight": 0.037 - }, - "Ll": { - "energy (keV)": 0.3484, - "weight": 1.0 - }, - "Ln": { - "energy (keV)": 0.3529, - "weight": 0.488 - } - }, - "Se": { - "Ka": { - "energy (keV)": 11.222, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 12.4959, - "weight": 0.1505 - }, - "La": { - "energy (keV)": 1.3791, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 1.4195, - "weight": 0.38848 - }, - "Lb3": { - "energy (keV)": 1.492, - "weight": 0.047 - }, - "Ll": { - "energy (keV)": 1.2043, - "weight": 0.04759 - }, - "Ln": { - "energy (keV)": 1.2447, - "weight": 0.0187 - } - }, - "Si": { - "Ka": { - "energy (keV)": 1.7397, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 1.8389, - "weight": 0.02779 - } - }, - "Sm": { - "Ka": { - "energy (keV)": 40.118, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 45.4144, - "weight": 0.15 - }, - "La": { - "energy (keV)": 5.636, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 6.2058, - "weight": 0.43512 - }, - "Lb2": { - "energy (keV)": 6.5872, - "weight": 0.19769 - }, - "Lb3": { - "energy (keV)": 6.317, - "weight": 0.12669 - }, - "Lb4": { - "energy (keV)": 6.1961, - "weight": 0.08689 - }, - "Lg1": { - "energy (keV)": 7.1828, - "weight": 0.07951 - }, - "Lg3": { - "energy (keV)": 7.4894, - "weight": 0.0318 - }, - "Ll": { - "energy (keV)": 4.9934, - "weight": 0.04519 - }, - "Ln": { - "energy (keV)": 5.589, - "weight": 0.01489 - }, - "M2N4": { - "energy (keV)": 1.4117, - "weight": 0.012 - }, - "Ma": { - "energy (keV)": 1.0428, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 1.1005, - "weight": 0.88 - }, - "Mg": { - "energy (keV)": 1.2908, - "weight": 0.26 - }, - "Mz": { - "energy (keV)": 0.8328, - "weight": 0.06 - } - }, - "Sn": { - "Ka": { - "energy (keV)": 25.2713, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 28.4857, - "weight": 0.15 - }, - "La": { - "energy (keV)": 3.444, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 3.6628, - "weight": 0.43456 - }, - "Lb2": { - "energy (keV)": 3.9049, - "weight": 0.14689 - }, - "Lb3": { - "energy (keV)": 3.7503, - "weight": 0.1547 - }, - "Lb4": { - "energy (keV)": 3.7083, - "weight": 0.0948 - }, - "Lg1": { - "energy (keV)": 4.1322, - "weight": 0.058 - }, - "Lg3": { - "energy (keV)": 4.3761, - "weight": 0.0321 - }, - "Ll": { - "energy (keV)": 3.045, - "weight": 0.0416 - }, - "Ln": { - "energy (keV)": 3.2723, - "weight": 0.0158 - } - }, - "Sr": { - "Ka": { - "energy (keV)": 14.165, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 15.8355, - "weight": 0.15 - }, - "La": { - "energy (keV)": 1.8065, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 1.8718, - "weight": 0.37975 - }, - "Lb3": { - "energy (keV)": 1.9472, - "weight": 0.047 - }, - "Lg3": { - "energy (keV)": 2.1964, - "weight": 0.0065 - }, - "Ll": { - "energy (keV)": 1.5821, - "weight": 0.04309 - }, - "Ln": { - "energy (keV)": 1.6493, - "weight": 0.01669 - } - }, - "Ta": { - "Ka": { - "energy (keV)": 57.5353, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 65.2224, - "weight": 0.15 - }, - "La": { - "energy (keV)": 8.146, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 9.3429, - "weight": 0.46248 - }, - "Lb2": { - "energy (keV)": 9.6518, - "weight": 0.2076 - }, - "Lb3": { - "energy (keV)": 9.4875, - "weight": 0.1333 - }, - "Lb4": { - "energy (keV)": 9.2128, - "weight": 0.10449 - }, - "Lg1": { - "energy (keV)": 10.8948, - "weight": 0.09071 - }, - "Lg3": { - "energy (keV)": 11.277, - "weight": 0.0354 - }, - "Ll": { - "energy (keV)": 7.1731, - "weight": 0.0515 - }, - "Ln": { - "energy (keV)": 8.4281, - "weight": 0.0158 - }, - "M2N4": { - "energy (keV)": 2.2274, - "weight": 0.01 - }, - "M3O4": { - "energy (keV)": 2.1883, - "weight": 0.0001 - }, - "M3O5": { - "energy (keV)": 2.194, - "weight": 0.01 - }, - "Ma": { - "energy (keV)": 1.7101, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 1.7682, - "weight": 0.59443 - }, - "Mg": { - "energy (keV)": 1.9647, - "weight": 0.08505 - }, - "Mz": { - "energy (keV)": 1.3306, - "weight": 0.01344 - } - }, - "Tb": { - "Ka": { - "energy (keV)": 44.4817, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 50.3844, - "weight": 0.15 - }, - "La": { - "energy (keV)": 6.2728, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 6.9766, - "weight": 0.44104 - }, - "Lb2": { - "energy (keV)": 7.367, - "weight": 0.19929 - }, - "Lb3": { - "energy (keV)": 7.0967, - "weight": 0.124 - }, - "Lb4": { - "energy (keV)": 6.9403, - "weight": 0.0874 - }, - "Lg1": { - "energy (keV)": 8.1046, - "weight": 0.08168 - }, - "Lg3": { - "energy (keV)": 8.423, - "weight": 0.0315 - }, - "Ll": { - "energy (keV)": 5.5465, - "weight": 0.0465 - }, - "Ln": { - "energy (keV)": 6.2841, - "weight": 0.01479 - }, - "M2N4": { - "energy (keV)": 1.6207, - "weight": 0.014 - }, - "Ma": { - "energy (keV)": 1.2326, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 1.2656, - "weight": 0.78 - }, - "Mg": { - "energy (keV)": 1.4643, - "weight": 0.2615 - }, - "Mz": { - "energy (keV)": 0.9562, - "weight": 0.06 - } - }, - "Tc": { - "Ka": { - "energy (keV)": 18.3671, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 20.619, - "weight": 0.15 - }, - "La": { - "energy (keV)": 2.424, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 2.5368, - "weight": 0.32951 - }, - "Lb2": { - "energy (keV)": 2.67017, - "weight": 0.05839 - }, - "Lb3": { - "energy (keV)": 2.6175, - "weight": 0.0644 - }, - "Lg1": { - "energy (keV)": 2.78619, - "weight": 0.01744 - }, - "Lg3": { - "energy (keV)": 3.0036, - "weight": 0.0111 - }, - "Ll": { - "energy (keV)": 2.1293, - "weight": 0.0412 - }, - "Ln": { - "energy (keV)": 2.2456, - "weight": 0.0127 - } - }, - "Te": { - "Ka": { - "energy (keV)": 27.4724, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 30.9951, - "weight": 0.15 - }, - "La": { - "energy (keV)": 3.7693, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 4.0295, - "weight": 0.43183 - }, - "Lb2": { - "energy (keV)": 4.3016, - "weight": 0.16269 - }, - "Lb3": { - "energy (keV)": 4.1205, - "weight": 0.1458 - }, - "Lb4": { - "energy (keV)": 4.0695, - "weight": 0.0906 - }, - "Lg1": { - "energy (keV)": 4.5722, - "weight": 0.06375 - }, - "Lg3": { - "energy (keV)": 4.829, - "weight": 0.0317 - }, - "Ll": { - "energy (keV)": 3.3354, - "weight": 0.0419 - }, - "Ln": { - "energy (keV)": 3.606, - "weight": 0.0154 - } - }, - "Th": { - "Ka": { - "energy (keV)": 93.3507, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 105.6049, - "weight": 0.15 - }, - "La": { - "energy (keV)": 12.9683, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 16.2024, - "weight": 0.4 - }, - "Lb2": { - "energy (keV)": 15.6239, - "weight": 0.236 - }, - "Lb3": { - "energy (keV)": 16.426, - "weight": 0.06 - }, - "Lb4": { - "energy (keV)": 15.6417, - "weight": 0.05 - }, - "Lg1": { - "energy (keV)": 18.9791, - "weight": 0.08 - }, - "Lg3": { - "energy (keV)": 19.5048, - "weight": 0.017 - }, - "Ll": { - "energy (keV)": 11.118, - "weight": 0.06709 - }, - "Ln": { - "energy (keV)": 14.5109, - "weight": 0.0134 - }, - "M2N4": { - "energy (keV)": 4.1163, - "weight": 0.00674 - }, - "M3O4": { - "energy (keV)": 3.9518, - "weight": 0.01 - }, - "M3O5": { - "energy (keV)": 3.9582, - "weight": 0.01 - }, - "Ma": { - "energy (keV)": 2.9968, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 3.1464, - "weight": 0.64124 - }, - "Mg": { - "energy (keV)": 3.3697, - "weight": 0.33505 - }, - "Mz": { - "energy (keV)": 2.3647, - "weight": 0.03512 - } - }, - "Ti": { - "Ka": { - "energy (keV)": 4.5109, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 4.9318, - "weight": 0.11673 - }, - "La": { - "energy (keV)": 0.4555, - "weight": 0.694 - }, - "Lb3": { - "energy (keV)": 0.5291, - "weight": 0.166 - }, - "Ll": { - "energy (keV)": 0.3952, - "weight": 1.0 - }, - "Ln": { - "energy (keV)": 0.4012, - "weight": 0.491 - } - }, - "Tl": { - "Ka": { - "energy (keV)": 72.8729, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 82.5738, - "weight": 0.15 - }, - "La": { - "energy (keV)": 10.2682, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 12.2128, - "weight": 0.39112 - }, - "Lb2": { - "energy (keV)": 12.2713, - "weight": 0.22289 - }, - "Lb3": { - "energy (keV)": 12.3901, - "weight": 0.0607 - }, - "Lb4": { - "energy (keV)": 11.931, - "weight": 0.05419 - }, - "Lg1": { - "energy (keV)": 14.2913, - "weight": 0.08304 - }, - "Lg3": { - "energy (keV)": 14.7377, - "weight": 0.0175 - }, - "Ll": { - "energy (keV)": 8.9534, - "weight": 0.0578 - }, - "Ln": { - "energy (keV)": 10.9938, - "weight": 0.0134 - }, - "M2N4": { - "energy (keV)": 3.0091, - "weight": 0.00863 - }, - "M3O4": { - "energy (keV)": 2.9413, - "weight": 0.005 - }, - "M3O5": { - "energy (keV)": 2.9435, - "weight": 0.01 - }, - "Ma": { - "energy (keV)": 2.2708, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 2.3623, - "weight": 0.64124 - }, - "Mg": { - "energy (keV)": 2.5704, - "weight": 0.21845 - }, - "Mz": { - "energy (keV)": 1.7803, - "weight": 0.0058 - } - }, - "Tm": { - "Ka": { - "energy (keV)": 50.7416, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 57.5051, - "weight": 0.15 - }, - "La": { - "energy (keV)": 7.1803, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 8.1023, - "weight": 0.45831 - }, - "Lb2": { - "energy (keV)": 8.4684, - "weight": 0.20059 - }, - "Lb3": { - "energy (keV)": 8.2312, - "weight": 0.1273 - }, - "Lb4": { - "energy (keV)": 8.0259, - "weight": 0.09449 - }, - "Lg1": { - "energy (keV)": 9.4373, - "weight": 0.08615 - }, - "Lg3": { - "energy (keV)": 9.7791, - "weight": 0.0329 - }, - "Ll": { - "energy (keV)": 6.3412, - "weight": 0.04889 - }, - "Ln": { - "energy (keV)": 7.3101, - "weight": 0.0156 - }, - "M2N4": { - "energy (keV)": 1.9102, - "weight": 0.01 - }, - "Ma": { - "energy (keV)": 1.4624, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 1.5093, - "weight": 0.59443 - }, - "Mg": { - "energy (keV)": 1.7049, - "weight": 0.08505 - }, - "Mz": { - "energy (keV)": 1.1311, - "weight": 0.06 - } - }, - "U": { - "Ka": { - "energy (keV)": 98.4397, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 111.3026, - "weight": 0.15 - }, - "La": { - "energy (keV)": 13.6146, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 17.22, - "weight": 0.4 - }, - "Lb2": { - "energy (keV)": 16.4286, - "weight": 0.236 - }, - "Lb3": { - "energy (keV)": 17.454, - "weight": 0.06 - }, - "Lb4": { - "energy (keV)": 16.5752, - "weight": 0.04 - }, - "Lg1": { - "energy (keV)": 20.1672, - "weight": 0.08 - }, - "Lg3": { - "energy (keV)": 20.7125, - "weight": 0.017 - }, - "Ll": { - "energy (keV)": 11.6183, - "weight": 0.069 - }, - "Ln": { - "energy (keV)": 15.3996, - "weight": 0.01199 - }, - "M2N4": { - "energy (keV)": 4.4018, - "weight": 0.00674 - }, - "M3O4": { - "energy (keV)": 4.1984, - "weight": 0.01 - }, - "M3O5": { - "energy (keV)": 4.2071, - "weight": 0.01 - }, - "Ma": { - "energy (keV)": 3.1708, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 3.3363, - "weight": 0.6086 - }, - "Mg": { - "energy (keV)": 3.5657, - "weight": 0.33505 - }, - "Mz": { - "energy (keV)": 2.5068, - "weight": 0.03512 - } - }, - "V": { - "Ka": { - "energy (keV)": 4.9522, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 5.4273, - "weight": 0.1225 - }, - "La": { - "energy (keV)": 0.5129, - "weight": 1.0 - }, - "Lb3": { - "energy (keV)": 0.5904, - "weight": 0.0154 - }, - "Ll": { - "energy (keV)": 0.4464, - "weight": 0.5745 - }, - "Ln": { - "energy (keV)": 0.454, - "weight": 0.2805 - } - }, - "W": { - "Ka": { - "energy (keV)": 59.3182, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 67.244, - "weight": 0.15 - }, - "La": { - "energy (keV)": 8.3976, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 9.6724, - "weight": 0.3679 - }, - "Lb2": { - "energy (keV)": 9.9614, - "weight": 0.21385 - }, - "Lb3": { - "energy (keV)": 9.8188, - "weight": 0.07077 - }, - "Lb4": { - "energy (keV)": 9.5249, - "weight": 0.05649 - }, - "Lg1": { - "energy (keV)": 11.2852, - "weight": 0.05658 - }, - "Lg3": { - "energy (keV)": 11.6745, - "weight": 0.0362 - }, - "Ll": { - "energy (keV)": 7.3872, - "weight": 0.04169 - }, - "Ln": { - "energy (keV)": 8.7244, - "weight": 0.01155 - }, - "M2N4": { - "energy (keV)": 2.3161, - "weight": 0.01 - }, - "M3O4": { - "energy (keV)": 2.2749, - "weight": 0.001 - }, - "M3O5": { - "energy (keV)": 2.281, - "weight": 0.01 - }, - "Ma": { - "energy (keV)": 1.7756, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 1.8351, - "weight": 0.59443 - }, - "Mg": { - "energy (keV)": 2.0356, - "weight": 0.08505 - }, - "Mz": { - "energy (keV)": 1.3839, - "weight": 0.01344 - } - }, - "Xe": { - "Ka": { - "energy (keV)": 29.7792, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 33.6244, - "weight": 0.15 - }, - "La": { - "energy (keV)": 4.1099, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 4.4183, - "weight": 0.42248 - }, - "Lb2": { - "energy (keV)": 4.7182, - "weight": 0.17699 - }, - "Lb3": { - "energy (keV)": 4.5158, - "weight": 0.14119 - }, - "Lb4": { - "energy (keV)": 4.4538, - "weight": 0.08929 - }, - "Lg1": { - "energy (keV)": 5.0397, - "weight": 0.06848 - }, - "Lg3": { - "energy (keV)": 5.3061, - "weight": 0.0323 - }, - "Ll": { - "energy (keV)": 3.6376, - "weight": 0.0424 - }, - "Ln": { - "energy (keV)": 3.9591, - "weight": 0.015 - } - }, - "Y": { - "Ka": { - "energy (keV)": 14.9584, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 16.7381, - "weight": 0.15 - }, - "La": { - "energy (keV)": 1.9226, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 1.9959, - "weight": 0.39127 - }, - "Lb2": { - "energy (keV)": 2.08, - "weight": 0.00739 - }, - "Lb3": { - "energy (keV)": 2.0722, - "weight": 0.05059 - }, - "Lg1": { - "energy (keV)": 2.1555, - "weight": 0.00264 - }, - "Lg3": { - "energy (keV)": 2.3469, - "weight": 0.0075 - }, - "Ll": { - "energy (keV)": 1.6864, - "weight": 0.0428 - }, - "Ln": { - "energy (keV)": 1.7619, - "weight": 0.0162 - } - }, - "Yb": { - "Ka": { - "energy (keV)": 52.3887, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 59.3825, - "weight": 0.15 - }, - "La": { - "energy (keV)": 7.4158, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 8.4019, - "weight": 0.46224 - }, - "Lb2": { - "energy (keV)": 8.7587, - "weight": 0.2017 - }, - "Lb3": { - "energy (keV)": 8.5366, - "weight": 0.12789 - }, - "Lb4": { - "energy (keV)": 8.3134, - "weight": 0.09589 - }, - "Lg1": { - "energy (keV)": 9.7801, - "weight": 0.08728 - }, - "Lg3": { - "energy (keV)": 10.1429, - "weight": 0.0331 - }, - "Ll": { - "energy (keV)": 6.5455, - "weight": 0.0494 - }, - "Ln": { - "energy (keV)": 7.5801, - "weight": 0.0157 - }, - "M2N4": { - "energy (keV)": 1.9749, - "weight": 0.01 - }, - "Ma": { - "energy (keV)": 1.5215, - "weight": 1.0 - }, - "Mb": { - "energy (keV)": 1.57, - "weight": 0.59443 - }, - "Mg": { - "energy (keV)": 1.7649, - "weight": 0.08505 - }, - "Mz": { - "energy (keV)": 1.1843, - "weight": 0.06 - } - }, - "Zn": { - "Ka": { - "energy (keV)": 8.6389, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 9.572, - "weight": 0.12605 - }, - "La": { - "energy (keV)": 1.0116, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 1.0347, - "weight": 0.1679 - }, - "Lb3": { - "energy (keV)": 1.107, - "weight": 0.002 - }, - "Ll": { - "energy (keV)": 0.8838, - "weight": 0.0603 - }, - "Ln": { - "energy (keV)": 0.9069, - "weight": 0.0368 - } - }, - "Zr": { - "Ka": { - "energy (keV)": 15.7753, - "weight": 1.0 - }, - "Kb": { - "energy (keV)": 17.6671, - "weight": 0.15 - }, - "La": { - "energy (keV)": 2.0423, - "weight": 1.0 - }, - "Lb1": { - "energy (keV)": 2.1243, - "weight": 0.37912 - }, - "Lb2": { - "energy (keV)": 2.2223, - "weight": 0.0177 - }, - "Lb3": { - "energy (keV)": 2.2011, - "weight": 0.05219 - }, - "Lg1": { - "energy (keV)": 2.30268, - "weight": 0.006 - }, - "Lg3": { - "energy (keV)": 2.5029, - "weight": 0.0082 - }, - "Ll": { - "energy (keV)": 1.792, - "weight": 0.04209 - }, - "Ln": { - "energy (keV)": 1.8764, - "weight": 0.0153 - } - } - }, - "metadata": { - "last_updated": "2025-06-04", - "notes": "Am, Li, Np and Pu lines are missing", - "source": "Chantler, C.T., Olsen, K., Dragoset, R.A., Kishore, A.R., Kotochigova, S.A., and Zucker, D.S. (2005), X-Ray Form Factor, Attenuation and Scattering Tables (version 2.1). https://dx.doi.org/10.18434/T4HS32", - "units": "keV", - "version": "1.0" - } -} From 746d5cafa645e362a8df7e7f03bec7d49dedc8c0 Mon Sep 17 00:00:00 2001 From: smribet Date: Mon, 23 Feb 2026 08:16:09 -0800 Subject: [PATCH 053/136] patch of fitting --- src/quantem/spectroscopy/dataset3deds.py | 156 +++++++++++++++++- .../spectroscopy/spectroscopy_models.py | 109 +++++++++++- 2 files changed, 248 insertions(+), 17 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 5619bd50..4a2c6987 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -1106,6 +1106,7 @@ def _fit_mean_model_pytorch( default_lr_adam, default_lr_lbfgs, verbose=False, + independent_shell_concentrations=False, ): target = spectrum_raw spectrum_offset = torch.tensor(0.0, dtype=spectrum_raw.dtype, device=spectrum_raw.device) @@ -1116,7 +1117,12 @@ def _fit_mean_model_pytorch( target = (spectrum_raw - spectrum_offset) / spectrum_scale background = PolynomialBackground(energy_axis, degree=polynomial_background_degree) - peaks = GaussianPeaks(energy_axis, peak_width=peak_width, elements_to_fit=elements_to_fit) + peaks = GaussianPeaks( + energy_axis, + peak_width=peak_width, + elements_to_fit=elements_to_fit, + independent_shell_concentrations=independent_shell_concentrations, + ) model = EDSModel(peaks, background, energy_axis=energy_axis) model = model.to(device=energy_axis.device, dtype=energy_axis.dtype) if len(model.peak_model.element_names) == 0: @@ -1193,21 +1199,153 @@ def fit_spectrum_mean_pytorch( polynomial_background_degree=3, optimizer="lbfgs", device=None, + independent_shell_concentrations=True, ): - return self.fit_spectrum_pytorch( - energy_range=energy_range, + if not independent_shell_concentrations: + return self.fit_spectrum_pytorch( + energy_range=energy_range, + elements_to_fit=elements_to_fit, + peak_width=peak_width, + num_iters=num_iters, + lr=lr, + polynomial_background_degree=polynomial_background_degree, + optimizer=optimizer, + loss="mse", + fit_mean_only=True, + show_plot=True, + device=device, + ) + + optimizer_name = str(optimizer).lower() + if optimizer_name not in {"adam", "lbfgs"}: + raise ValueError("optimizer must be 'lbfgs' or 'adam'") + + if device is None: + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + else: + device = torch.device(device) + if device.type == "cuda" and not torch.cuda.is_available(): + raise ValueError("CUDA device requested but torch.cuda.is_available() is False.") + + energy_axis_np = np.arange(self.shape[0]) * self.sampling[0] + self.origin[0] + energy_axis = torch.tensor(energy_axis_np, dtype=torch.float32, device=device) + spectra = torch.tensor(self.array, dtype=torch.float32, device=device) + + if energy_range is not None: + ind = (energy_axis >= energy_range[0]) & (energy_axis <= energy_range[1]) + energy_axis = energy_axis[ind] + spectra = spectra[ind] + else: + energy_range = [float(energy_axis.min().item()), float(energy_axis.max().item())] + + print("fitting spectrum globally") + spectrum_raw = spectra.sum((-1, -2)) + mean_fit = self._fit_mean_model_pytorch( + energy_axis=energy_axis, + spectrum_raw=spectrum_raw, elements_to_fit=elements_to_fit, peak_width=peak_width, + polynomial_background_degree=polynomial_background_degree, num_iters=num_iters, + optimizer=optimizer_name, lr=lr, - polynomial_background_degree=polynomial_background_degree, - optimizer=optimizer, - loss="mse", - fit_mean_only=True, - show_plot=True, - device=device, + loss_name="mse", + normalize_target=True, + default_lr_adam=1e-3, + default_lr_lbfgs=1.0, + verbose=True, + independent_shell_concentrations=True, + ) + + model = mean_fit["model"] + loss_history = mean_fit["loss_history"] + spectrum_offset = mean_fit["spectrum_offset"] + spectrum_scale = mean_fit["spectrum_scale"] + with torch.no_grad(): + final_pred = mean_fit["final_pred_raw"].cpu().numpy() + shell_concs = ( + nn.functional.softplus(model.peak_model.concentrations).detach().cpu().numpy() + ) + shell_names = list(model.peak_model.shell_group_names) + shell_element_indices = ( + model.peak_model.shell_group_element_indices.detach().cpu().numpy() + ) + concs = np.zeros(len(model.peak_model.element_names), dtype=np.float32) + np.add.at(concs, shell_element_indices, shell_concs) + final_fwhm = ( + torch.nn.functional.softplus(model.peak_model.peak_width_by_peak) + .detach() + .cpu() + .numpy() + ) + background_fit = ( + (model.background_model().detach() * spectrum_scale + spectrum_offset) + .cpu() + .numpy() + ) + + print( + f"\nFinal: width median={np.median(final_fwhm):.3f} keV, " + f"min={final_fwhm.min():.3f}, max={final_fwhm.max():.3f}" ) + top_n = max(10, len(elements_to_fit) if elements_to_fit is not None else 0) + sorted_indices = np.argsort(concs)[::-1] + print("\nTop elements:") + for i, idx in enumerate(sorted_indices[:top_n], 1): + elem = model.peak_model.element_names[idx] + conc = concs[idx] + print(f"{i:2d}. {elem:2s}: {conc:.3f}") + + shell_top_n = max(10, min(len(shell_names), top_n)) + shell_sorted_indices = np.argsort(shell_concs)[::-1] + print("\nTop edge groups:") + for i, idx in enumerate(shell_sorted_indices[:shell_top_n], 1): + shell_name = shell_names[idx] + shell_conc = shell_concs[idx] + print(f"{i:2d}. {shell_name:>6s}: {shell_conc:.3f}") + + energy_axis_plot = energy_axis.detach().cpu().numpy() + spectrum_raw_plot = spectrum_raw.detach().cpu().numpy() + fig, ax = plt.subplots(2, 1, figsize=(10, 6)) + ax[0].plot(np.arange(loss_history.shape[0]), loss_history, color="k") + ax[0].set_title("loss") + ax[0].set_xlabel("iterations") + ax[0].set_ylabel("loss") + ax[0].set_yscale("log") + + ax[1].plot(energy_axis_plot, spectrum_raw_plot, "k-", label="Data", linewidth=1) + ax[1].plot(energy_axis_plot, final_pred, "r-", label="Fit", linewidth=2) + ax[1].plot( + energy_axis_plot, + background_fit, + "b--", + label="Background", + linewidth=1.5, + ) + ax[1].set_xlim(energy_range[0], energy_range[1]) + ax[1].legend() + ax[1].set_title("fit spectrum") + ax[1].set_xlabel("Energy (keV)") + ax[1].set_ylabel("Counts") + plt.tight_layout() + plt.show() + + return { + "loss_history": loss_history, + "fitted_spectrum": final_pred, + "input_spectrum": spectrum_raw_plot, + "background_spectrum": background_fit, + "concentrations": concs, + "element_names": model.peak_model.element_names, + "edge_concentrations": shell_concs, + "edge_names": shell_names, + "edge_element_indices": shell_element_indices, + "peak_widths": final_fwhm, + "energy_axis": energy_axis_plot, + "fit_range": energy_range, + } + def fit_spectrum_pytorch( self, energy_range=None, diff --git a/src/quantem/spectroscopy/spectroscopy_models.py b/src/quantem/spectroscopy/spectroscopy_models.py index fbdb2449..2d5e9b22 100644 --- a/src/quantem/spectroscopy/spectroscopy_models.py +++ b/src/quantem/spectroscopy/spectroscopy_models.py @@ -111,11 +111,18 @@ def forward(self): class GaussianPeaks(nn.Module): """Generate Gaussian peaks from peak library""" - def __init__(self, energy_axis, peak_width, elements_to_fit=None): + def __init__( + self, + energy_axis, + peak_width, + elements_to_fit=None, + independent_shell_concentrations=False, + ): super().__init__() current_dir = Path(__file__).parent data = load_xray_lines_database(current_dir / "x_ray_lines.csv") + self.independent_shell_concentrations = bool(independent_shell_concentrations) energy_axis_tensor = ( energy_axis.float() @@ -135,15 +142,21 @@ def __init__(self, energy_axis, peak_width, elements_to_fit=None): if len(lines) > 0: energies = [] weights = [] + line_names = [] for line_name, line_data in lines.items(): energy = line_data["energy (keV)"] if self.energy_min - 0.5 <= energy <= self.energy_max + 0.5: energies.append(energy) weights.append(line_data["weight"]) + line_names.append(line_name) if len(energies) > 0: - all_element_data[elem] = {"energies": energies, "weights": weights} + all_element_data[elem] = { + "energies": energies, + "weights": weights, + "line_names": line_names, + } # Filter to specific elements if elements_to_fit is not None: @@ -161,14 +174,50 @@ def __init__(self, energy_axis, peak_width, elements_to_fit=None): all_peak_energies = [] all_peak_weights = [] all_peak_element_indices = [] + all_peak_shell_group_indices = [] + shell_group_lookup = {} + shell_group_names = [] + shell_group_element_indices = [] + shell_group_shell_labels = [] for elem_idx, elem in enumerate(self.element_names): energies = self.element_data[elem]["energies"] - weights = self.element_data[elem]["weights"] + weights = np.asarray(self.element_data[elem]["weights"], dtype=np.float32) + line_names = self.element_data[elem]["line_names"] + + if self.independent_shell_concentrations: + shell_labels = [self._line_shell_label(line_name) for line_name in line_names] + normalized_weights = np.zeros_like(weights) + for shell_label in set(shell_labels): + shell_indices = [ + i for i, label in enumerate(shell_labels) if label == shell_label + ] + shell_weights = np.clip(weights[shell_indices], a_min=0.0, a_max=None) + shell_sum = float(np.sum(shell_weights)) + if shell_sum <= 0.0: + shell_weights = np.ones(len(shell_indices), dtype=np.float32) / float( + len(shell_indices) + ) + else: + shell_weights = shell_weights / shell_sum + normalized_weights[shell_indices] = shell_weights + weights_to_use = normalized_weights + else: + shell_labels = [None] * len(line_names) + weights_to_use = weights all_peak_energies.extend(energies) - all_peak_weights.extend(weights) + all_peak_weights.extend(float(weight) for weight in weights_to_use) all_peak_element_indices.extend([elem_idx] * len(energies)) + if self.independent_shell_concentrations: + for shell_label in shell_labels: + key = (elem_idx, shell_label) + if key not in shell_group_lookup: + shell_group_lookup[key] = len(shell_group_names) + shell_group_names.append(f"{elem} {shell_label}") + shell_group_element_indices.append(elem_idx) + shell_group_shell_labels.append(shell_label) + all_peak_shell_group_indices.append(shell_group_lookup[key]) # Store as tensors for fast computation self.register_buffer( @@ -195,6 +244,29 @@ def __init__(self, energy_axis, peak_width, elements_to_fit=None): device=self.energy_axis.device, ), ) + if self.independent_shell_concentrations: + self.register_buffer( + "peak_shell_group_indices", + torch.tensor( + all_peak_shell_group_indices, + dtype=torch.long, + device=self.energy_axis.device, + ), + ) + self.register_buffer( + "shell_group_element_indices", + torch.tensor( + shell_group_element_indices, + dtype=torch.long, + device=self.energy_axis.device, + ), + ) + self.shell_group_names = shell_group_names + self.shell_group_shell_labels = shell_group_shell_labels + else: + self.shell_group_names = [] + self.shell_group_shell_labels = [] + self.n_peaks = len(all_peak_energies) init_fwhm = torch.tensor( peak_width, @@ -210,17 +282,35 @@ def __init__(self, energy_axis, peak_width, elements_to_fit=None): ) ) - print(f"Fitting {n_elements} elements with {self.n_peaks} total peaks") + if self.independent_shell_concentrations: + n_shell_groups = len(shell_group_names) + print( + f"Fitting {n_elements} elements with {self.n_peaks} total peaks " + f"across {n_shell_groups} edge groups" + ) + else: + print(f"Fitting {n_elements} elements with {self.n_peaks} total peaks") # Learnable parameters + concentration_size = ( + len(shell_group_names) if self.independent_shell_concentrations else n_elements + ) self.concentrations = nn.Parameter( torch.ones( - n_elements, + concentration_size, dtype=self.energy_axis.dtype, device=self.energy_axis.device, ) ) + @staticmethod + def _line_shell_label(line_name: str) -> str: + text = str(line_name).strip() + for char in text: + if char.isalpha(): + return char.upper() + return "Other" + def forward(self): """Vectorized forward pass""" centers = self.peak_energies.unsqueeze(1) @@ -240,9 +330,12 @@ def forward(self): ) all_peaks = all_peaks * self.energy_step / (sqrt_2pi * sigma) - peak_concentrations = nn.functional.softplus( - self.concentrations[self.peak_element_indices] + concentration_lookup = ( + self.peak_shell_group_indices + if self.independent_shell_concentrations + else self.peak_element_indices ) + peak_concentrations = nn.functional.softplus(self.concentrations[concentration_lookup]) weighted_peaks = all_peaks * (peak_concentrations * self.peak_weights).unsqueeze(1) spectrum = weighted_peaks.sum(dim=0) From 88e9cace97f70fce2028cf5a191f8850ddaf22ed Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Thu, 26 Feb 2026 15:03:43 -0800 Subject: [PATCH 054/136] 1d spectra attached to 3d spectroscopy datasets now saved as dataset1d objects --- src/quantem/spectroscopy/dataset3dspectroscopy.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index cca403c4..5c5a7284 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -399,7 +399,13 @@ def add_spectrum_to_data(self, spectrum, energy_axis): """ Store processed spectra in the 3D spectroscopy dataset structure, in a 1D array of 2D arrays. By default, calculate_mean_spectrum will """ - two_d_spectrum = np.vstack((spectrum, energy_axis)) + from quantem.core.datastructures.dataset1d import Dataset1d + + two_d_spectrum = Dataset1d.from_array( + array=spectrum, origin=energy_axis[0], sampling=self.sampling[0], units=self.units[0] + ) + + # two_d_spectrum = np.vstack((spectrum, energy_axis)) if self.attached_spectra is not None: self.attached_spectra.append(two_d_spectrum) From 8a6325a8b5fdd3e7c9e605382c6e9d35818bb68b Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Thu, 26 Feb 2026 15:05:12 -0800 Subject: [PATCH 055/136] cleanup --- src/quantem/spectroscopy/dataset3dspectroscopy.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 5c5a7284..f3460684 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -405,8 +405,6 @@ def add_spectrum_to_data(self, spectrum, energy_axis): array=spectrum, origin=energy_axis[0], sampling=self.sampling[0], units=self.units[0] ) - # two_d_spectrum = np.vstack((spectrum, energy_axis)) - if self.attached_spectra is not None: self.attached_spectra.append(two_d_spectrum) else: From af5ec6e0e77cd2d37c31ae141334296a4881cfe2 Mon Sep 17 00:00:00 2001 From: smribet Date: Sun, 1 Mar 2026 16:19:55 -0800 Subject: [PATCH 056/136] simplifying --- src/quantem/spectroscopy/dataset3deds.py | 65 +++++---- .../spectroscopy/spectroscopy_models.py | 135 ++++++++---------- 2 files changed, 99 insertions(+), 101 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 4a2c6987..9b912807 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -1106,7 +1106,6 @@ def _fit_mean_model_pytorch( default_lr_adam, default_lr_lbfgs, verbose=False, - independent_shell_concentrations=False, ): target = spectrum_raw spectrum_offset = torch.tensor(0.0, dtype=spectrum_raw.dtype, device=spectrum_raw.device) @@ -1116,21 +1115,20 @@ def _fit_mean_model_pytorch( spectrum_scale = torch.clamp(spectrum_raw.max() - spectrum_offset, min=1e-8) target = (spectrum_raw - spectrum_offset) / spectrum_scale - background = PolynomialBackground(energy_axis, degree=polynomial_background_degree) + background = PolynomialBackground( + energy_axis, + degree=polynomial_background_degree, + ) peaks = GaussianPeaks( energy_axis, peak_width=peak_width, elements_to_fit=elements_to_fit, - independent_shell_concentrations=independent_shell_concentrations, ) model = EDSModel(peaks, background, energy_axis=energy_axis) model = model.to(device=energy_axis.device, dtype=energy_axis.dtype) if len(model.peak_model.element_names) == 0: raise ValueError("No elements found in the selected energy range/elements_to_fit.") - with torch.no_grad(): - model.peak_model.concentrations.fill_(1.0) - optimizer_name = optimizer.lower() if optimizer_name == "adam": if lr is None: @@ -1199,23 +1197,7 @@ def fit_spectrum_mean_pytorch( polynomial_background_degree=3, optimizer="lbfgs", device=None, - independent_shell_concentrations=True, ): - if not independent_shell_concentrations: - return self.fit_spectrum_pytorch( - energy_range=energy_range, - elements_to_fit=elements_to_fit, - peak_width=peak_width, - num_iters=num_iters, - lr=lr, - polynomial_background_degree=polynomial_background_degree, - optimizer=optimizer, - loss="mse", - fit_mean_only=True, - show_plot=True, - device=device, - ) - optimizer_name = str(optimizer).lower() if optimizer_name not in {"adam", "lbfgs"}: raise ValueError("optimizer must be 'lbfgs' or 'adam'") @@ -1254,7 +1236,6 @@ def fit_spectrum_mean_pytorch( default_lr_adam=1e-3, default_lr_lbfgs=1.0, verbose=True, - independent_shell_concentrations=True, ) model = mean_fit["model"] @@ -1369,6 +1350,7 @@ def fit_spectrum_pytorch( show_plot=True, lr_global=None, lr_local=None, + lr_background_local=None, device=None, constrain_background=True, ): @@ -1394,6 +1376,9 @@ def fit_spectrum_pytorch( is the learning rate used for that fit. lr_local : float, optional Learning rate for the position-by-position stage (3D mode). + lr_background_local : float, optional + Deprecated. Local background now uses the same learning rate as + other local parameters. polynomial_background_degree : int, optional Degree of per-pixel polynomial background. optimizer : str | None, optional @@ -1430,7 +1415,6 @@ def fit_spectrum_pytorch( constrain_background : bool, optional If True (3D mode), regularize local backgrounds using the global fit as a prior and soft physical constraints with built-in weights. - Returns ------- dict @@ -1542,9 +1526,14 @@ def _resolve_stage_settings( spectrum_scale = mean_fit["spectrum_scale"] with torch.no_grad(): final_pred = mean_fit["final_pred_raw"].cpu().numpy() - concs = ( + shell_concs = ( nn.functional.softplus(model.peak_model.concentrations).detach().cpu().numpy() ) + shell_element_indices = ( + model.peak_model.shell_group_element_indices.detach().cpu().numpy() + ) + concs = np.zeros(len(model.peak_model.element_names), dtype=np.float32) + np.add.at(concs, shell_element_indices, shell_concs) final_fwhm = ( torch.nn.functional.softplus(model.peak_model.peak_width_by_peak) .detach() @@ -1604,6 +1593,9 @@ def _resolve_stage_settings( "background_spectrum": background_fit, "concentrations": concs, "element_names": model.peak_model.element_names, + "edge_concentrations": shell_concs, + "edge_names": list(model.peak_model.shell_group_names), + "edge_element_indices": shell_element_indices, "peak_widths": final_fwhm, "energy_axis": energy_axis.detach().cpu().numpy(), "fit_range": energy_range, @@ -1644,13 +1636,21 @@ def _resolve_stage_settings( global_offset = global_fit["spectrum_offset"].detach() global_fitted_spectrum = global_fit["final_pred_raw"].detach().cpu().numpy() + n_elements = len(global_model.peak_model.element_names) with torch.no_grad(): # If the global stage fit a normalized target, convert amplitude-like # parameters back to raw-count scale for local initialization. - global_conc = ( + global_conc_shell = ( nn.functional.softplus(global_model.peak_model.concentrations).detach() * global_scale ) + shell_element_indices = global_model.peak_model.shell_group_element_indices + global_conc = torch.zeros( + n_elements, + dtype=global_conc_shell.dtype, + device=global_conc_shell.device, + ) + global_conc.index_add_(0, shell_element_indices, global_conc_shell) global_bg_coeffs = global_model.background_model.coeffs.detach() * global_scale if global_bg_coeffs.numel() > 0: global_bg_coeffs = global_bg_coeffs.clone() @@ -1658,7 +1658,6 @@ def _resolve_stage_settings( global_peak_width_params = global_model.peak_model.peak_width_by_peak.detach().clone() # Stage 2: vectorized per-pixel fit with shared peak shapes. - n_elements = len(global_model.peak_model.element_names) peak_energies = global_model.peak_model.peak_energies peak_weights = global_model.peak_model.peak_weights peak_element_indices = global_model.peak_model.peak_element_indices @@ -1712,8 +1711,18 @@ def _resolve_stage_settings( else (0.05 if effective_optimizer_local == "adam" else 1.0) ) + if lr_background_local is not None and verbose: + print( + "lr_background_local is deprecated and ignored; using lr_local for " + "background coefficients." + ) + if effective_optimizer_local == "adam": - local_opt = torch.optim.Adam(trainable_params, lr=local_lr) + adam_param_groups = [{"params": [conc_logits], "lr": local_lr}] + adam_param_groups.append({"params": [bg_coeffs], "lr": local_lr}) + if not freeze_peak_width: + adam_param_groups.append({"params": [peak_width_params], "lr": local_lr}) + local_opt = torch.optim.Adam(adam_param_groups) else: local_opt = torch.optim.LBFGS( trainable_params, diff --git a/src/quantem/spectroscopy/spectroscopy_models.py b/src/quantem/spectroscopy/spectroscopy_models.py index 2d5e9b22..8657150d 100644 --- a/src/quantem/spectroscopy/spectroscopy_models.py +++ b/src/quantem/spectroscopy/spectroscopy_models.py @@ -116,13 +116,11 @@ def __init__( energy_axis, peak_width, elements_to_fit=None, - independent_shell_concentrations=False, ): super().__init__() current_dir = Path(__file__).parent data = load_xray_lines_database(current_dir / "x_ray_lines.csv") - self.independent_shell_concentrations = bool(independent_shell_concentrations) energy_axis_tensor = ( energy_axis.float() @@ -185,39 +183,32 @@ def __init__( weights = np.asarray(self.element_data[elem]["weights"], dtype=np.float32) line_names = self.element_data[elem]["line_names"] - if self.independent_shell_concentrations: - shell_labels = [self._line_shell_label(line_name) for line_name in line_names] - normalized_weights = np.zeros_like(weights) - for shell_label in set(shell_labels): - shell_indices = [ - i for i, label in enumerate(shell_labels) if label == shell_label - ] - shell_weights = np.clip(weights[shell_indices], a_min=0.0, a_max=None) - shell_sum = float(np.sum(shell_weights)) - if shell_sum <= 0.0: - shell_weights = np.ones(len(shell_indices), dtype=np.float32) / float( - len(shell_indices) - ) - else: - shell_weights = shell_weights / shell_sum - normalized_weights[shell_indices] = shell_weights - weights_to_use = normalized_weights - else: - shell_labels = [None] * len(line_names) - weights_to_use = weights + shell_labels = [self._line_shell_label(line_name) for line_name in line_names] + normalized_weights = np.zeros_like(weights) + for shell_label in set(shell_labels): + shell_indices = [i for i, label in enumerate(shell_labels) if label == shell_label] + shell_weights = np.clip(weights[shell_indices], a_min=0.0, a_max=None) + shell_sum = float(np.sum(shell_weights)) + if shell_sum <= 0.0: + shell_weights = np.ones(len(shell_indices), dtype=np.float32) / float( + len(shell_indices) + ) + else: + shell_weights = shell_weights / shell_sum + normalized_weights[shell_indices] = shell_weights + weights_to_use = normalized_weights all_peak_energies.extend(energies) all_peak_weights.extend(float(weight) for weight in weights_to_use) all_peak_element_indices.extend([elem_idx] * len(energies)) - if self.independent_shell_concentrations: - for shell_label in shell_labels: - key = (elem_idx, shell_label) - if key not in shell_group_lookup: - shell_group_lookup[key] = len(shell_group_names) - shell_group_names.append(f"{elem} {shell_label}") - shell_group_element_indices.append(elem_idx) - shell_group_shell_labels.append(shell_label) - all_peak_shell_group_indices.append(shell_group_lookup[key]) + for shell_label in shell_labels: + key = (elem_idx, shell_label) + if key not in shell_group_lookup: + shell_group_lookup[key] = len(shell_group_names) + shell_group_names.append(f"{elem} {shell_label}") + shell_group_element_indices.append(elem_idx) + shell_group_shell_labels.append(shell_label) + all_peak_shell_group_indices.append(shell_group_lookup[key]) # Store as tensors for fast computation self.register_buffer( @@ -244,28 +235,24 @@ def __init__( device=self.energy_axis.device, ), ) - if self.independent_shell_concentrations: - self.register_buffer( - "peak_shell_group_indices", - torch.tensor( - all_peak_shell_group_indices, - dtype=torch.long, - device=self.energy_axis.device, - ), - ) - self.register_buffer( - "shell_group_element_indices", - torch.tensor( - shell_group_element_indices, - dtype=torch.long, - device=self.energy_axis.device, - ), - ) - self.shell_group_names = shell_group_names - self.shell_group_shell_labels = shell_group_shell_labels - else: - self.shell_group_names = [] - self.shell_group_shell_labels = [] + self.register_buffer( + "peak_shell_group_indices", + torch.tensor( + all_peak_shell_group_indices, + dtype=torch.long, + device=self.energy_axis.device, + ), + ) + self.register_buffer( + "shell_group_element_indices", + torch.tensor( + shell_group_element_indices, + dtype=torch.long, + device=self.energy_axis.device, + ), + ) + self.shell_group_names = shell_group_names + self.shell_group_shell_labels = shell_group_shell_labels self.n_peaks = len(all_peak_energies) init_fwhm = torch.tensor( @@ -282,26 +269,29 @@ def __init__( ) ) - if self.independent_shell_concentrations: - n_shell_groups = len(shell_group_names) - print( - f"Fitting {n_elements} elements with {self.n_peaks} total peaks " - f"across {n_shell_groups} edge groups" - ) - else: - print(f"Fitting {n_elements} elements with {self.n_peaks} total peaks") + n_shell_groups = len(shell_group_names) + print( + f"Fitting {n_elements} elements with {self.n_peaks} total peaks " + f"across {n_shell_groups} edge groups" + ) # Learnable parameters - concentration_size = ( - len(shell_group_names) if self.independent_shell_concentrations else n_elements - ) - self.concentrations = nn.Parameter( - torch.ones( + concentration_size = len(shell_group_names) + if concentration_size > 0: + init_concentration = torch.ones( concentration_size, dtype=self.energy_axis.dtype, device=self.energy_axis.device, ) - ) + concentration_init_logits = inverse_softplus(init_concentration) + else: + concentration_init_logits = torch.ones( + concentration_size, + dtype=self.energy_axis.dtype, + device=self.energy_axis.device, + ) + + self.concentrations = nn.Parameter(concentration_init_logits) @staticmethod def _line_shell_label(line_name: str) -> str: @@ -330,11 +320,7 @@ def forward(self): ) all_peaks = all_peaks * self.energy_step / (sqrt_2pi * sigma) - concentration_lookup = ( - self.peak_shell_group_indices - if self.independent_shell_concentrations - else self.peak_element_indices - ) + concentration_lookup = self.peak_shell_group_indices peak_concentrations = nn.functional.softplus(self.concentrations[concentration_lookup]) weighted_peaks = all_peaks * (peak_concentrations * self.peak_weights).unsqueeze(1) @@ -346,7 +332,7 @@ def forward(self): class PolynomialBackground(nn.Module): """Polynomial background model""" - def __init__(self, energy_axis, degree=3): + def __init__(self, energy_axis, degree=3, positive_output=False): super().__init__() energy_axis_tensor = ( energy_axis.float() @@ -355,6 +341,7 @@ def __init__(self, energy_axis, degree=3): ) self.register_buffer("energy_axis", energy_axis_tensor) self.degree = degree + self.positive_output = bool(positive_output) # Normalize energy axis to [0, 1] for numerical stability energy_norm = (self.energy_axis - self.energy_axis.min()) / ( @@ -375,6 +362,8 @@ def forward(self): background = torch.zeros_like(self.energy_axis) for i, coeff in enumerate(self.coeffs): background += coeff * (self.energy_norm**i) + if self.positive_output: + background = nn.functional.softplus(background) return background From 5dfbb7d2315f26c1031e4f29d2f80d29f08ce50e Mon Sep 17 00:00:00 2001 From: smribet Date: Sun, 1 Mar 2026 17:15:22 -0800 Subject: [PATCH 057/136] a lot simplified --- src/quantem/spectroscopy/dataset3deds.py | 178 +++--------------- .../spectroscopy/spectroscopy_models.py | 22 +-- 2 files changed, 30 insertions(+), 170 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 9b912807..9c3fb82c 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -1124,7 +1124,7 @@ def _fit_mean_model_pytorch( peak_width=peak_width, elements_to_fit=elements_to_fit, ) - model = EDSModel(peaks, background, energy_axis=energy_axis) + model = EDSModel(peaks, background) model = model.to(device=energy_axis.device, dtype=energy_axis.dtype) if len(model.peak_model.element_names) == 0: raise ValueError("No elements found in the selected energy range/elements_to_fit.") @@ -1140,7 +1140,6 @@ def _fit_mean_model_pytorch( optimizer_obj = torch.optim.LBFGS( model.parameters(), lr=lr, - max_iter=1, line_search_fn="strong_wolfe", ) else: @@ -1334,14 +1333,11 @@ def fit_spectrum_pytorch( peak_width=0.1, num_iters=300, num_iters_global=200, - lr=None, polynomial_background_degree=3, - optimizer=None, - loss=None, - optimizer_global=None, - optimizer_local=None, + optimizer_global="lbfgs", + optimizer_local="lbfgs", loss_global=None, - loss_local=None, + loss_local="poisson", freeze_peak_width=True, spatial_lambda=0.0, min_total_counts=0.0, @@ -1350,130 +1346,33 @@ def fit_spectrum_pytorch( show_plot=True, lr_global=None, lr_local=None, - lr_background_local=None, device=None, constrain_background=True, ): - """Fit EDS spectra with one entrypoint for mean-only or full-cube fitting. - - Parameters - ---------- - energy_range : list[float] | tuple[float, float] | None - Energy range [emin, emax] to include in the fit. - elements_to_fit : list[str] | None - Element symbols to fit. If None, all available elements in range are used. - peak_width : float, optional - Initial peak FWHM in keV. - num_iters : int, optional - Number of optimization iterations for per-pixel fitting. - num_iters_global : int, optional - Number of mean-spectrum iterations used to initialize local fitting (3D mode). - lr : float, optional - Backward-compatible shared learning rate fallback. Used for global/local - fitting only when lr_global/lr_local are not provided. - lr_global : float, optional - Learning rate for the global mean-spectrum stage. In mean-only mode, this - is the learning rate used for that fit. - lr_local : float, optional - Learning rate for the position-by-position stage (3D mode). - lr_background_local : float, optional - Deprecated. Local background now uses the same learning rate as - other local parameters. - polynomial_background_degree : int, optional - Degree of per-pixel polynomial background. - optimizer : str | None, optional - Backward-compatible optimizer selector. In mean-only mode it controls - the global stage. In 3D mode it controls the local stage. - optimizer_global : str | None, optional - Global/mean-stage optimizer, "adam" or "lbfgs". In 3D mode, defaults - to "lbfgs" unless explicitly set. - optimizer_local : str | None, optional - Local position-by-position optimizer, "adam" or "lbfgs". In 3D mode, - defaults to optimizer if provided, otherwise "lbfgs". - loss : str | None, optional - Backward-compatible shared data term, "poisson" or "mse". If provided, - it applies to both stages unless stage-specific losses are set. - loss_global : str | None, optional - Global/mean-stage data term, "poisson" or "mse". - loss_local : str | None, optional - Local position-by-position data term, "poisson" or "mse". - freeze_peak_width : bool, optional - If True, lock peak widths after global fit (3D mode). - spatial_lambda : float, optional - Weight for spatial smoothness on abundance maps (3D mode). - min_total_counts : float, optional - Ignore pixels with summed counts below this threshold in data loss (3D mode). - verbose : bool, optional - Print progress updates. - fit_mean_only : bool, optional - If True, fit only the summed spectrum over (x, y). - show_plot : bool, optional - Plot fit diagnostics in mean-only mode. - device : str | torch.device | None, optional - Compute device to run fitting on. If None, uses CUDA when available, - otherwise CPU. - constrain_background : bool, optional - If True (3D mode), regularize local backgrounds using the global fit as - a prior and soft physical constraints with built-in weights. - Returns - ------- - dict - Mean-only mode keys include concentrations, fit, and diagnostics. - 3D mode keys include abundance maps and fit diagnostics. - """ + """Fit EDS spectra on either the mean spectrum or the full cube.""" def _normalize_choice(name, param_name, allowed_values): - if name is None: - return None - name_norm = name.lower() + name_norm = str(name).lower() if name_norm not in allowed_values: allowed_display = "', '".join(sorted(allowed_values)) raise ValueError(f"{param_name} must be '{allowed_display}'") return name_norm - def _resolve_stage_settings( - fit_mean_only_mode, - optimizer_default_global, - loss_default_global, - loss_default_local, - ): - if fit_mean_only_mode: - return ( - optimizer_global_name or optimizer_name or optimizer_default_global, - None, - loss_global_name or loss_name or loss_default_global, - None, - ) - # Preserve historical behavior: global stage defaults to LBFGS. - return ( - optimizer_global_name or optimizer_default_global, - optimizer_local_name or optimizer_name or "lbfgs", - loss_global_name or loss_name or loss_default_global, - loss_local_name or loss_name or loss_default_local, - ) - - optimizer_name = _normalize_choice(optimizer, "optimizer", {"adam", "lbfgs"}) - optimizer_global_name = _normalize_choice( + effective_optimizer_global = _normalize_choice( optimizer_global, "optimizer_global", {"adam", "lbfgs"} ) - optimizer_local_name = _normalize_choice( + effective_optimizer_local = _normalize_choice( optimizer_local, "optimizer_local", {"adam", "lbfgs"} ) - - loss_name = _normalize_choice(loss, "loss", {"poisson", "mse"}) - loss_global_name = _normalize_choice(loss_global, "loss_global", {"poisson", "mse"}) - loss_local_name = _normalize_choice(loss_local, "loss_local", {"poisson", "mse"}) - - ( - effective_optimizer_global, - effective_optimizer_local, - effective_loss_global, - effective_loss_local, - ) = _resolve_stage_settings( - fit_mean_only_mode=fit_mean_only, - optimizer_default_global="lbfgs", - loss_default_global="mse" if fit_mean_only else "poisson", - loss_default_local="poisson", + effective_loss_global = ( + _normalize_choice(loss_global, "loss_global", {"poisson", "mse"}) + if loss_global is not None + else ("mse" if fit_mean_only else "poisson") + ) + effective_loss_local = ( + _normalize_choice(loss_local, "loss_local", {"poisson", "mse"}) + if not fit_mean_only + else None ) if spatial_lambda < 0: @@ -1486,12 +1385,12 @@ def _resolve_stage_settings( if device.type == "cuda" and not torch.cuda.is_available(): raise ValueError("CUDA device requested but torch.cuda.is_available() is False.") - effective_lr_global = lr if lr_global is None else lr_global - effective_lr_local = lr if lr_local is None else lr_local + effective_lr_global = lr_global + effective_lr_local = lr_local energy_axis_np = np.arange(self.shape[0]) * self.sampling[0] + self.origin[0] energy_axis = torch.tensor(energy_axis_np, dtype=torch.float32, device=device) - spectra = torch.tensor(self.array, dtype=torch.float32, device=device) # (E, Y, X) + spectra = torch.tensor(self.array, dtype=torch.float32, device=device) if energy_range is not None: ind = (energy_axis >= energy_range[0]) & (energy_axis <= energy_range[1]) @@ -1612,7 +1511,6 @@ def _resolve_stage_settings( mean_spectrum = spectra_flat[valid_pixel_mask].mean(dim=0) - # Stage 1: global mean-spectrum fit to initialize per-pixel parameters. if verbose: print("fitting spectrum globally") global_fit = self._fit_mean_model_pytorch( @@ -1638,8 +1536,6 @@ def _resolve_stage_settings( n_elements = len(global_model.peak_model.element_names) with torch.no_grad(): - # If the global stage fit a normalized target, convert amplitude-like - # parameters back to raw-count scale for local initialization. global_conc_shell = ( nn.functional.softplus(global_model.peak_model.concentrations).detach() * global_scale @@ -1657,7 +1553,6 @@ def _resolve_stage_settings( global_bg_coeffs[0] = global_bg_coeffs[0] + global_offset global_peak_width_params = global_model.peak_model.peak_width_by_peak.detach().clone() - # Stage 2: vectorized per-pixel fit with shared peak shapes. peak_energies = global_model.peak_model.peak_energies peak_weights = global_model.peak_model.peak_weights peak_element_indices = global_model.peak_model.peak_element_indices @@ -1669,13 +1564,10 @@ def _resolve_stage_settings( mean_total = torch.clamp(mean_spectrum.sum(), min=1e-8) pixel_scales = (total_counts / mean_total).unsqueeze(1) - # Avoid near-zero concentration initialization that can cause vanishing - # softplus gradients in local optimization (especially on normalized data). conc_init = torch.clamp( global_conc.unsqueeze(0) * pixel_scales, min=1e-3, ) - # Small random perturbation helps break symmetry across pixels. conc_init = torch.clamp( conc_init * (1.0 + 0.02 * torch.randn_like(conc_init)), min=1e-3, @@ -1711,12 +1603,6 @@ def _resolve_stage_settings( else (0.05 if effective_optimizer_local == "adam" else 1.0) ) - if lr_background_local is not None and verbose: - print( - "lr_background_local is deprecated and ignored; using lr_local for " - "background coefficients." - ) - if effective_optimizer_local == "adam": adam_param_groups = [{"params": [conc_logits], "lr": local_lr}] adam_param_groups.append({"params": [bg_coeffs], "lr": local_lr}) @@ -1727,7 +1613,6 @@ def _resolve_stage_settings( local_opt = torch.optim.LBFGS( trainable_params, lr=local_lr, - max_iter=1, line_search_fn="strong_wolfe", history_size=10, ) @@ -1749,21 +1634,11 @@ def _forward_model(): ) ) conc = nn.functional.softplus(conc_logits) # (P, n_elements) - peaks_pred = conc @ basis.t() # (P, E) - bg_raw = bg_coeffs @ background_basis # (P, E) - # Keep local background parameterization consistent with global initialization. - bg_pred = bg_raw + peaks_pred = conc @ basis.t() + bg_pred = bg_coeffs @ background_basis predicted = torch.clamp(peaks_pred + bg_pred, min=1e-8, max=1e8) return predicted, conc, bg_pred - def _prepare_local_loss_inputs(pred_local): - pred_eval = pred_local[valid_pixel_mask] - target_eval = spectra_flat[valid_pixel_mask] - local_scale = torch.clamp(global_scale, min=1e-8) - pred_eval = pred_eval / local_scale - target_eval = target_eval / local_scale - return pred_eval, target_eval - def _background_regularization(bg_local): if not constrain_background: return bg_local.new_tensor(0.0) @@ -1802,7 +1677,9 @@ def _background_regularization(bg_local): return reg_loss def _local_loss(pred_local, conc_local, bg_local): - pred_eval, target_eval = _prepare_local_loss_inputs(pred_local) + local_scale = torch.clamp(global_scale, min=1e-8) + pred_eval = pred_local[valid_pixel_mask] / local_scale + target_eval = spectra_flat[valid_pixel_mask] / local_scale loss_data = eds_data_loss( pred_eval, @@ -1849,14 +1726,9 @@ def _local_closure(): with torch.no_grad(): pred_final, conc_final, bg_final = _forward_model() - - # Keep global/local/data comparison on equal footing by averaging - # over the same valid-pixel mask used in the global stage. mean_input_spectrum = spectra_flat[valid_pixel_mask].mean(dim=0).cpu().numpy() mean_fitted_spectrum = pred_final[valid_pixel_mask].mean(dim=0).cpu().numpy() mean_background_spectrum = bg_final[valid_pixel_mask].mean(dim=0).cpu().numpy() - - # Also provide all-pixel aggregates for diagnostics. mean_input_spectrum_all = spectra_flat.mean(dim=0).cpu().numpy() mean_fitted_spectrum_all = pred_final.mean(dim=0).cpu().numpy() mean_background_spectrum_all = bg_final.mean(dim=0).cpu().numpy() diff --git a/src/quantem/spectroscopy/spectroscopy_models.py b/src/quantem/spectroscopy/spectroscopy_models.py index 8657150d..076b5372 100644 --- a/src/quantem/spectroscopy/spectroscopy_models.py +++ b/src/quantem/spectroscopy/spectroscopy_models.py @@ -94,9 +94,9 @@ def abundance_smoothness_l2(abundance_maps: torch.Tensor) -> torch.Tensor: class EDSModel(nn.Module): - """Complete EDS forward model with optional fit range""" + """EDS spectrum model = peaks + optional background.""" - def __init__(self, peak_model, background_model=None, fit_range=None, energy_axis=None): + def __init__(self, peak_model, background_model=None): super().__init__() self.peak_model = peak_model self.background_model = background_model @@ -109,7 +109,7 @@ def forward(self): class GaussianPeaks(nn.Module): - """Generate Gaussian peaks from peak library""" + """Generate Gaussian peak spectra from X-ray line data.""" def __init__( self, @@ -130,11 +130,8 @@ def __init__( self.register_buffer("energy_axis", energy_axis_tensor) self.energy_min = self.energy_axis.min().item() self.energy_max = self.energy_axis.max().item() - - # Calculate energy step for later use self.energy_step = (self.energy_axis[1] - self.energy_axis[0]).item() - # Parse and filter elements all_element_data = {} for elem, lines in data.items(): if len(lines) > 0: @@ -156,7 +153,6 @@ def __init__( "line_names": line_names, } - # Filter to specific elements if elements_to_fit is not None: self.element_data = {} for elem in elements_to_fit: @@ -168,7 +164,6 @@ def __init__( self.element_names = list(self.element_data.keys()) n_elements = len(self.element_names) - # Pre-compute all peak positions and weights as tensors all_peak_energies = [] all_peak_weights = [] all_peak_element_indices = [] @@ -210,7 +205,6 @@ def __init__( shell_group_shell_labels.append(shell_label) all_peak_shell_group_indices.append(shell_group_lookup[key]) - # Store as tensors for fast computation self.register_buffer( "peak_energies", torch.tensor( @@ -275,7 +269,6 @@ def __init__( f"across {n_shell_groups} edge groups" ) - # Learnable parameters concentration_size = len(shell_group_names) if concentration_size > 0: init_concentration = torch.ones( @@ -302,11 +295,10 @@ def _line_shell_label(line_name: str) -> str: return "Other" def forward(self): - """Vectorized forward pass""" centers = self.peak_energies.unsqueeze(1) energies = self.energy_axis.unsqueeze(0) - fwhm = nn.functional.softplus(self.peak_width_by_peak) # (n_peaks,) + fwhm = nn.functional.softplus(self.peak_width_by_peak) sigma = (fwhm / 2.355).unsqueeze(1) all_peaks = torch.exp(-0.5 * ((energies - centers) / sigma) ** 2) @@ -332,7 +324,7 @@ def forward(self): class PolynomialBackground(nn.Module): """Polynomial background model""" - def __init__(self, energy_axis, degree=3, positive_output=False): + def __init__(self, energy_axis, degree=3): super().__init__() energy_axis_tensor = ( energy_axis.float() @@ -341,9 +333,7 @@ def __init__(self, energy_axis, degree=3, positive_output=False): ) self.register_buffer("energy_axis", energy_axis_tensor) self.degree = degree - self.positive_output = bool(positive_output) - # Normalize energy axis to [0, 1] for numerical stability energy_norm = (self.energy_axis - self.energy_axis.min()) / ( self.energy_axis.max() - self.energy_axis.min() ) @@ -362,8 +352,6 @@ def forward(self): background = torch.zeros_like(self.energy_axis) for i, coeff in enumerate(self.coeffs): background += coeff * (self.energy_norm**i) - if self.positive_output: - background = nn.functional.softplus(background) return background From c8f586508f1afabf1598ebdef00e804c43b6b544 Mon Sep 17 00:00:00 2001 From: smribet Date: Wed, 4 Mar 2026 11:10:38 -0800 Subject: [PATCH 058/136] doc strings --- src/quantem/spectroscopy/dataset3deds.py | 163 ++++++++++++++++------- 1 file changed, 118 insertions(+), 45 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 9c3fb82c..9489c2eb 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -1347,9 +1347,100 @@ def fit_spectrum_pytorch( lr_global=None, lr_local=None, device=None, - constrain_background=True, + constrain_background=0.1, ): - """Fit EDS spectra on either the mean spectrum or the full cube.""" + """Fit EDS spectra using a PyTorch model. + + Supports two workflows: + - Mean-only fitting (`fit_mean_only=True`): fit a single spectrum formed by + summing over all spatial pixels. + - Global + local fitting (`fit_mean_only=False`): fit a global mean model, + then refine concentrations/background per pixel across the full cube. + + Parameters + ---------- + energy_range : sequence[float] | None, optional + Two-element energy interval ``[emin, emax]`` in keV used for fitting. + If ``None``, the full energy axis is used. + elements_to_fit : sequence[str] | None, optional + Element symbols (or model-supported element labels) to include in the + fit. If ``None``, all supported elements from the model are considered. + peak_width : float, optional + Initial peak width (FWHM-like parameter in keV) for model peaks. + num_iters : int, optional + Number of optimization iterations for mean-only mode, or local + per-pixel refinement iterations in full-cube mode. + num_iters_global : int, optional + Number of iterations for the global/mean stage in full-cube mode. + polynomial_background_degree : int, optional + Degree of polynomial background basis. + optimizer_global : {"adam", "lbfgs"}, optional + Optimizer for the global/mean stage. + optimizer_local : {"adam", "lbfgs"}, optional + Optimizer for per-pixel local fitting. + loss_global : {"poisson", "mse"} | None, optional + Global-stage data term. If ``None``, defaults to ``"mse"`` for + mean-only mode and ``"poisson"`` otherwise. + loss_local : {"poisson", "mse"}, optional + Local-stage data term (ignored when ``fit_mean_only=True``). + freeze_peak_width : bool, optional + If ``True``, keep peak widths fixed during local fitting. + spatial_lambda : float, optional + L2 spatial smoothness weight applied to abundance maps during local + fitting. Must be non-negative. + min_total_counts : float, optional + Minimum per-pixel integrated counts required for a pixel to + participate in local fitting. + verbose : bool, optional + If ``True``, print optimization progress. + fit_mean_only : bool, optional + If ``True``, run only the mean-spectrum fit and skip per-pixel + refinement. + show_plot : bool, optional + If ``True``, display diagnostic plots. + lr_global : float | None, optional + Learning rate for the global optimizer. If ``None``, an optimizer- + specific default is used. + lr_local : float | None, optional + Learning rate for the local optimizer. If ``None``, an optimizer- + specific default is used. + device : str | torch.device | None, optional + Torch device for fitting (for example ``"cpu"`` or ``"cuda"``). + If ``None``, uses CUDA when available, otherwise CPU. + constrain_background : float, optional + Background prior weight used in local fitting to keep per-pixel + background coefficients close to the globally optimized background. + Set to ``0`` to disable. This is only used when + ``fit_mean_only=False``. + + Returns + ------- + dict + Fit results. Contents depend on the selected mode. + + Mean-only mode (``fit_mean_only=True``) returns keys: + ``loss_history``, ``fitted_spectrum``, ``input_spectrum``, + ``background_spectrum``, ``concentrations``, ``element_names``, + ``edge_concentrations``, ``edge_names``, ``edge_element_indices``, + ``peak_widths``, ``energy_axis``, ``fit_range``. + + Full-cube mode (``fit_mean_only=False``) returns keys: + ``abundance_maps``, ``element_names``, ``peak_widths``, + ``loss_history``, ``global_loss_history``, ``valid_pixel_mask``, + ``energy_axis``, ``input_spectrum``, ``fitted_spectrum``, + ``background_spectrum``, ``input_spectrum_all_pixels``, + ``fitted_spectrum_all_pixels``, ``background_spectrum_all_pixels``, + ``fit_range``. + + Raises + ------ + TypeError + If ``constrain_background`` is not numeric (for example ``bool``). + ValueError + If optimizer/loss names are invalid, ``spatial_lambda < 0``, CUDA is + requested but unavailable, ``constrain_background < 0``, or no pixels + satisfy ``min_total_counts``. + """ def _normalize_choice(name, param_name, allowed_values): name_norm = str(name).lower() @@ -1378,6 +1469,15 @@ def _normalize_choice(name, param_name, allowed_values): if spatial_lambda < 0: raise ValueError("spatial_lambda must be >= 0") + if isinstance(constrain_background, bool): + raise TypeError("constrain_background must be a non-negative float.") + try: + background_prior_lambda = float(constrain_background) + except (TypeError, ValueError) as exc: + raise TypeError("constrain_background must be a non-negative float.") from exc + if background_prior_lambda < 0: + raise ValueError("constrain_background must be >= 0") + if device is None: device = torch.device("cuda" if torch.cuda.is_available() else "cpu") else: @@ -1639,44 +1739,17 @@ def _forward_model(): predicted = torch.clamp(peaks_pred + bg_pred, min=1e-8, max=1e8) return predicted, conc, bg_pred - def _background_regularization(bg_local): - if not constrain_background: - return bg_local.new_tensor(0.0) - - prior_lambda = 0.1 - nonneg_lambda = 0.5 - monotonic_lambda = 0.05 - smoothness_lambda = 0.01 - - reg_loss = bg_local.new_tensor(0.0) - local_scale = torch.clamp(global_scale, min=1e-8) - - if prior_lambda > 0: - coeff_init_eval = bg_coeffs_init[valid_pixel_mask] - coeff_eval = bg_coeffs[valid_pixel_mask] - coeff_scale = torch.clamp(coeff_init_eval.abs().mean(), min=1e-8) - reg_prior = ((coeff_eval - coeff_init_eval) / coeff_scale).pow(2).mean() - reg_loss = reg_loss + prior_lambda * reg_prior - - bg_eval = bg_local[valid_pixel_mask] / local_scale - - if nonneg_lambda > 0: - reg_nonneg = torch.relu(-bg_eval).pow(2).mean() - reg_loss = reg_loss + nonneg_lambda * reg_nonneg - - if monotonic_lambda > 0 and bg_eval.shape[1] > 1: - slope = bg_eval[:, 1:] - bg_eval[:, :-1] - reg_monotonic = torch.relu(slope).pow(2).mean() - reg_loss = reg_loss + monotonic_lambda * reg_monotonic - - if smoothness_lambda > 0 and bg_eval.shape[1] > 2: - curvature = bg_eval[:, 2:] - 2.0 * bg_eval[:, 1:-1] + bg_eval[:, :-2] - reg_smooth = curvature.pow(2).mean() - reg_loss = reg_loss + smoothness_lambda * reg_smooth + def _background_regularization(): + if background_prior_lambda <= 0: + return bg_coeffs.new_tensor(0.0) - return reg_loss + coeff_init_eval = bg_coeffs_init[valid_pixel_mask] + coeff_eval = bg_coeffs[valid_pixel_mask] + coeff_scale = torch.clamp(coeff_init_eval.abs().mean(), min=1e-8) + reg_prior = ((coeff_eval - coeff_init_eval) / coeff_scale).pow(2).mean() + return background_prior_lambda * reg_prior - def _local_loss(pred_local, conc_local, bg_local): + def _local_loss(pred_local, conc_local): local_scale = torch.clamp(global_scale, min=1e-8) pred_eval = pred_local[valid_pixel_mask] / local_scale target_eval = spectra_flat[valid_pixel_mask] / local_scale @@ -1686,7 +1759,7 @@ def _local_loss(pred_local, conc_local, bg_local): target_eval, loss=effective_loss_local, ) - loss_total = loss_data + _background_regularization(bg_local) + loss_total = loss_data + _background_regularization() if spatial_lambda <= 0: return loss_total @@ -1703,20 +1776,20 @@ def _local_loss(pred_local, conc_local, bg_local): def _local_closure(): local_opt.zero_grad() - pred_local, conc_local, bg_local = _forward_model() - loss_total = _local_loss(pred_local, conc_local, bg_local) + pred_local, conc_local, _bg_local = _forward_model() + loss_total = _local_loss(pred_local, conc_local) loss_total.backward() return loss_total loss_value = local_opt.step(_local_closure) if not torch.is_tensor(loss_value): with torch.no_grad(): - pred_local, conc_local, bg_local = _forward_model() - loss_value = _local_loss(pred_local, conc_local, bg_local) + pred_local, conc_local, _bg_local = _forward_model() + loss_value = _local_loss(pred_local, conc_local) else: local_opt.zero_grad() - pred_local, conc_local, bg_local = _forward_model() - loss_value = _local_loss(pred_local, conc_local, bg_local) + pred_local, conc_local, _bg_local = _forward_model() + loss_value = _local_loss(pred_local, conc_local) loss_value.backward() local_opt.step() From 0c17bd432fa4102d562340e9d1adffad3eed90b0 Mon Sep 17 00:00:00 2001 From: smribet Date: Wed, 4 Mar 2026 13:45:49 -0800 Subject: [PATCH 059/136] atomics weights update from 2017 IUPAC https://iupac.qmul.ac.uk/AtWt/index.html --- src/quantem/spectroscopy/atomic_weights.csv | 118 +++++++++++++++++ src/quantem/spectroscopy/atomic_weights.json | 122 ------------------ .../spectroscopy/dataset3dspectroscopy.py | 37 ++++-- 3 files changed, 145 insertions(+), 132 deletions(-) create mode 100644 src/quantem/spectroscopy/atomic_weights.csv delete mode 100644 src/quantem/spectroscopy/atomic_weights.json diff --git a/src/quantem/spectroscopy/atomic_weights.csv b/src/quantem/spectroscopy/atomic_weights.csv new file mode 100644 index 00000000..3a952649 --- /dev/null +++ b/src/quantem/spectroscopy/atomic_weights.csv @@ -0,0 +1,118 @@ +H,1.01 +He,4.00 +Li,6.94 +Be,9.01 +B,10.81 +C,12.01 +N,14.01 +O,16.00 +F,19.00 +Ne,20.18 +Na,22.99 +Mg,24.31 +Al,26.98 +Si,28.09 +P,30.97 +S,32.06 +Cl,35.45 +Ar,39.95 +K,39.10 +Ca,40.08 +Sc,44.96 +Ti,47.87 +V,50.94 +Cr,52.00 +Mn,54.94 +Fe,55.85 +Co,58.93 +Ni,58.69 +Cu,63.55 +Zn,65.38 +Ga,69.72 +Ge,72.63 +As,74.92 +Se,78.97 +Br,79.90 +Kr,83.80 +Rb,85.47 +Sr,87.62 +Y,88.91 +Zr,91.22 +Nb,92.91 +Mo,95.95 +Tc,97.00 +Ru,101.07 +Rh,102.91 +Pd,106.42 +Ag,107.87 +Cd,112.41 +In,114.82 +Sn,118.71 +Sb,121.76 +Te,127.60 +I,126.90 +Xe,131.29 +Cs,132.91 +Ba,137.33 +La,138.91 +Ce,140.12 +Pr,140.91 +Nd,144.24 +Pm,145.00 +Sm,150.36 +Eu,151.96 +Gd,157.25 +Tb,158.93 +Dy,162.50 +Ho,164.93 +Er,167.26 +Tm,168.93 +Yb,173.05 +Lu,174.97 +Hf,178.49 +Ta,180.95 +W,183.84 +Re,186.21 +Os,190.23 +Ir,192.22 +Pt,195.08 +Au,196.97 +Hg,200.59 +Tl,204.38 +Pb,207.20 +Bi,208.98 +Po,209.00 +At,210.00 +Rn,222.00 +Fr,223.00 +Ra,226.00 +Ac,227.00 +Th,232.04 +Pa,231.04 +U,238.03 +Np,237.00 +Pu,244.00 +Am,243.00 +Cm,247.00 +Bk,247.00 +Cf,251.00 +Es,252.00 +Fm,257.00 +Md,258.00 +No,259.00 +Lr,262.00 +Rf,267.00 +Db,270.00 +Sg,269.00 +Bh,270.00 +Hs,270.00 +Mt,278.00 +Ds,281.00 +Rg,281.00 +Cn,285.00 +Nh,286.00 +Fl,289.00 +Mc,289.00 +Lv,293.00 +Ts,293.00 +Og,294.00 \ No newline at end of file diff --git a/src/quantem/spectroscopy/atomic_weights.json b/src/quantem/spectroscopy/atomic_weights.json deleted file mode 100644 index 3d99a969..00000000 --- a/src/quantem/spectroscopy/atomic_weights.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "atomic_weights": { - "H": 1.008, - "He": 4.0026, - "Li": 6.94, - "Be": 9.0122, - "B": 10.81, - "C": 12.011, - "N": 14.007, - "O": 15.999, - "F": 18.998, - "Ne": 20.18, - "Na": 22.99, - "Mg": 24.305, - "Al": 26.982, - "Si": 28.085, - "P": 30.974, - "S": 32.06, - "Cl": 35.45, - "Ar": 39.948, - "K": 39.098, - "Ca": 40.078, - "Sc": 44.956, - "Ti": 47.867, - "V": 50.942, - "Cr": 51.996, - "Mn": 54.938, - "Fe": 55.845, - "Co": 58.933, - "Ni": 58.693, - "Cu": 63.546, - "Zn": 65.38, - "Ga": 69.723, - "Ge": 72.63, - "As": 74.922, - "Se": 78.971, - "Br": 79.904, - "Kr": 83.798, - "Rb": 85.468, - "Sr": 87.62, - "Y": 88.906, - "Zr": 91.224, - "Nb": 92.906, - "Mo": 95.95, - "Tc": 98.0, - "Ru": 101.07, - "Rh": 102.905, - "Pd": 106.42, - "Ag": 107.868, - "Cd": 112.414, - "In": 114.818, - "Sn": 118.71, - "Sb": 121.76, - "Te": 127.6, - "I": 126.904, - "Xe": 131.293, - "Cs": 132.905, - "Ba": 137.327, - "La": 138.905, - "Ce": 140.116, - "Pr": 140.908, - "Nd": 144.242, - "Pm": 145.0, - "Sm": 150.36, - "Eu": 151.964, - "Gd": 157.25, - "Tb": 158.925, - "Dy": 162.5, - "Ho": 164.93, - "Er": 167.259, - "Tm": 168.934, - "Yb": 173.045, - "Lu": 174.967, - "Hf": 178.49, - "Ta": 180.948, - "W": 183.84, - "Re": 186.207, - "Os": 190.23, - "Ir": 192.217, - "Pt": 195.084, - "Au": 196.967, - "Hg": 200.592, - "Tl": 204.38, - "Pb": 207.2, - "Bi": 208.98, - "Po": 209.0, - "At": 210.0, - "Rn": 222.0, - "Fr": 223.0, - "Ra": 226.0, - "Ac": 227.0, - "Th": 232.038, - "Pa": 231.036, - "U": 238.029, - "Np": 237.0, - "Pu": 244.0, - "Am": 243.0, - "Cm": 247.0, - "Bk": 247.0, - "Cf": 251.0, - "Es": 252.0, - "Fm": 257.0, - "Md": 258.0, - "No": 259.0, - "Lr": 266.0, - "Rf": 267.0, - "Db": 268.0, - "Sg": 269.0, - "Bh": 270.0, - "Hs": 269.0, - "Mt": 278.0, - "Ds": 281.0, - "Rg": 282.0, - "Cn": 285.0, - "Nh": 286.0, - "Fl": 289.0, - "Mc": 290.0, - "Lv": 293.0, - "Ts": 294.0, - "Og": 294.0 - } -} diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index f3460684..3645ad55 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -1,3 +1,4 @@ +import csv import json import os from typing import Any, Optional @@ -17,7 +18,7 @@ class Dataset3dspectroscopy(Dataset3d): element_info = None element_info_path = "x_ray_lines.csv" atomic_weights = None - atomic_weights_path = "atomic_weights.json" + atomic_weights_path = "atomic_weights.csv" dataset_type = "EDS" def __init__( @@ -72,20 +73,36 @@ def load_element_info( @classmethod def load_atomic_weights(cls): - """Load atomic weights table from JSON once per class.""" + """Load atomic weights table from CSV once per class.""" if cls.atomic_weights is not None: return base_dir = os.path.dirname(os.path.abspath(__file__)) full_path = os.path.join(base_dir, cls.atomic_weights_path) - with open(full_path, "r") as f: - data = json.load(f) - - if isinstance(data, dict) and "atomic_weights" in data: - data = data["atomic_weights"] - - if not isinstance(data, dict): - raise ValueError("atomic_weights.json must contain a JSON object") + data = {} + with open(full_path, "r", newline="") as f: + reader = csv.reader(f) + for row_index, row in enumerate(reader, start=1): + if not row: + continue + if len(row) < 2: + raise ValueError( + f"{cls.atomic_weights_path} row {row_index} must contain element symbol and weight" + ) + symbol = str(row[0]).strip() + weight_raw = str(row[1]).strip() + if not symbol: + continue + try: + weight = float(weight_raw) + except ValueError as exc: + raise ValueError( + f"{cls.atomic_weights_path} row {row_index} has invalid weight: {weight_raw!r}" + ) from exc + data[symbol] = weight + + if not data: + raise ValueError(f"{cls.atomic_weights_path} did not contain any atomic weights") cls.atomic_weights = data From 3c1990a350a452e14dd9f0ee6260c1fd6711b92c Mon Sep 17 00:00:00 2001 From: smribet Date: Thu, 5 Mar 2026 05:47:57 -0800 Subject: [PATCH 060/136] restructure --- src/quantem/spectroscopy/dataset3deds.py | 420 ++++++++++++++++++ .../spectroscopy/dataset3dspectroscopy.py | 127 ++++-- 2 files changed, 505 insertions(+), 42 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 9489c2eb..e06fdbf1 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -77,6 +77,426 @@ def __init__( self._virtual_images = {} self.dataset_type = "EDS" + def quantify_composition( + self, roi=None, elements=None, k_factors=None, method="cliff_lorimer", mask=None + ): + """ + Quantify elemental composition from EDS spectrum using Cliff-Lorimer approach. + + The Cliff-Lorimer equation relates atomic fractions to X-ray intensities: + CA/CB = kAB * (IA/IB) + + Parameters + ---------- + roi : list or tuple, optional + Region of interest as [y, x, dy, dx]. If None, uses full image. + elements : list, required + List of element symbols to quantify (e.g., ['Pt', 'Co']). + k_factors : dict or array-like, required + K-factors for the quantified elements. + - dict format: {'Pt': 1.0, 'Co': 1.23} + - array/list format: [1.0, 1.23] mapped in the same order as ``elements`` + - per-shell dict format: + {'Pt': {'K': 0, 'L': 1.12, 'M': 0}, 'Co': {'K': 1.23, 'L': 0, 'M': 0}} + where 0 means shell unavailable. + method : str, optional + Quantification method. Currently supports 'cliff_lorimer'. + mask : array, optional + Boolean mask for energy channel selection. + + Returns + ------- + dict : Composition results containing: + - 'atomic_percent': dict of element -> atomic % + - 'weight_percent': dict of element -> weight % + - 'intensities': dict of element -> integrated intensity + - 'k_factors': dict of k-factors used + + Examples + -------- + # With dictionary k-factors + k_factors = {'Pt': 1.0, 'Co': 1.23} + comp = dataset.quantify_composition(elements=['Pt', 'Co'], k_factors=k_factors) + + # With array-like k-factors (same order as elements) + comp = dataset.quantify_composition(elements=['Pt', 'Co'], k_factors=[1.0, 1.23]) + + # With per-shell k-factors (0 means unavailable shell) + shell_kf = { + 'Pt': {'K': 0, 'L': 1.12, 'M': 0}, + 'Co': {'K': 1.23, 'L': 0, 'M': 0}, + } + comp = dataset.quantify_composition(elements=['Pt', 'Co'], k_factors=shell_kf) + + # Access results + print(f"Pt: {comp['atomic_percent']['Pt']:.1f} at%") + print(f"Co: {comp['atomic_percent']['Co']:.1f} at%") + """ + + # Input validation + if elements is None or len(elements) < 2: + raise ValueError("At least 2 elements required for quantification") + + # Load element info if not available + if type(self).element_info is None: + type(self).load_element_info() + + # Extract spectrum from ROI + spectrum_data = self._extract_spectrum_for_quantification(roi, mask) + spec = spectrum_data["spectrum"] + E = spectrum_data["energy"] + + # Determine max usable energy from the actual dataset + max_energy = float(E.max()) if len(E) > 0 else 20.0 + + # Determine shell for each element and validate/normalize k-factors + if k_factors is None: + raise ValueError("Must provide k_factors as a dict or array-like") + + element_shells = self._determine_element_shells(elements, max_energy) + k_factors = self._normalize_k_factors(elements, k_factors, element_shells) + + # Get X-ray line intensities for each element using the correct shell + intensities = {} + for element in elements: + shell = element_shells.get(element, "K") # Default to K if not determined + intensity = self._integrate_element_intensity(element, spec, E, shell) + intensities[element] = intensity + + # Apply Cliff-Lorimer quantification + if method == "cliff_lorimer": + results = self._cliff_lorimer_quantification( + elements, intensities, k_factors, method, roi + ) + else: + raise ValueError(f"Unknown quantification method: {method}") + + return results + + def _extract_spectrum_for_quantification(self, roi, mask): + """Extract spectrum data for quantification (similar to show_mean_spectrum).""" + # Parse ROI (reuse logic from show_mean_spectrum) + if roi is None: + y, x, dy, dx = 0, 0, int(self.shape[1]), int(self.shape[2]) + elif len(roi) == 2: + y, x, dy, dx = int(roi[0]), int(roi[1]), 1, 1 + elif len(roi) == 4: + y_val, x_val, dy_val, dx_val = roi + y = 0 if y_val is None else int(y_val) + x = 0 if x_val is None else int(x_val) + dy = int(self.shape[1]) - y if dy_val is None else int(dy_val) + dx = int(self.shape[2]) - x if dx_val is None else int(dx_val) + else: + raise ValueError("roi must be None, [y, x], or [y, x, dy, dx]") + + # Energy axis + dE = float(self.sampling[0]) + E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 + E = E0 + dE * np.arange(self.shape[0]) + + # Extract spectrum with mask handling + if mask is not None: + mask = np.asarray(mask, dtype=bool) + if mask.shape != (self.shape[0],): + raise ValueError( + f"Mask shape {mask.shape} doesn't match energy axis ({self.shape[0]},)" + ) + arr = np.asarray(self.array, dtype=float)[mask, :, :] + spec = arr.sum(axis=(1, 2)) if arr.shape[0] > 0 else np.zeros(0) + E = E[mask] + else: + spec = np.empty(self.shape[0], dtype=float) + for k in range(self.shape[0]): + img = np.asarray(self.array[k], dtype=float) + roi_data = img[y : y + dy, x : x + dx] + if roi_data.size == 0: + raise ValueError("ROI is empty") + spec[k] = roi_data.mean() + + return {"spectrum": spec, "energy": E} + + def _integrate_element_intensity(self, element, spectrum, energy, shell="K"): + """Integrate X-ray intensity for a specific element using characteristic lines from the specified shell. + + Parameters + ---------- + element : str + Element symbol + spectrum : array + Spectrum intensities + energy : array + Energy axis in keV + shell : str + X-ray shell to use: 'K', 'L', or 'M' + """ + all_info = type(self).element_info + if element not in all_info: + raise ValueError(f"Element {element} not found in database") + + total_intensity = 0.0 + element_lines = all_info[element] + + # Filter lines by the specified shell (K, L, or M) + # For K-shell: Ka, Kb lines + # For L-shell: La, Lb, Lg lines + # For M-shell: Ma, Mb lines + shell_lines = [] + for line_name, info in element_lines.items(): + line_energy = info["energy (keV)"] + line_weight = info["weight"] + + # Check if line belongs to the specified shell + if shell == "K" and ("Ka" in line_name or "Kb" in line_name): + shell_lines.append((line_weight, line_energy, line_name)) + elif shell == "L" and ("La" in line_name or "Lb" in line_name or "Lg" in line_name): + shell_lines.append((line_weight, line_energy, line_name)) + elif shell == "M" and ("Ma" in line_name or "Mb" in line_name): + shell_lines.append((line_weight, line_energy, line_name)) + + # Sort by weight (highest first) and ignore lines beyond detector range + shell_lines = [(w, e, n) for w, e, n in shell_lines if e <= 12.0] + shell_lines.sort(reverse=True) + + # Use top 3 most intense lines from the specified shell for integration + for weight, line_energy, line_name in shell_lines[:3]: + if weight > 0.1: # Only significant lines + # Find integration window around the line + # Use +/- 0.1 keV window or adaptive based on energy resolution + window_width = max(0.1, line_energy * 0.01) # 1% of energy or 0.1 keV minimum + + # Find energy indices for integration + energy_mask = (energy >= line_energy - window_width) & ( + energy <= line_energy + window_width + ) + + if np.any(energy_mask): + # Simple background subtraction: use linear interpolation at edges + line_spectrum = spectrum[energy_mask] + if len(line_spectrum) > 2: + # Background level from edges of integration window + bg_level = (line_spectrum[0] + line_spectrum[-1]) / 2 + # Integrate above background, weighted by line intensity + net_intensity = np.sum(line_spectrum - bg_level) * weight + total_intensity += max(0, net_intensity) # No negative intensities + + return total_intensity + + def _determine_element_shells(self, elements, max_energy): + """Determine the appropriate X-ray shell (K, L, or M) for each element based on available lines. + + Parameters + ---------- + elements : list + List of element symbols + max_energy : float + Maximum energy in keV from the dataset + """ + all_info = type(self).element_info + element_shells = {} + + for element in elements: + if element not in all_info: + element_shells[element] = "K" # Default + continue + + element_lines = all_info[element] + + # Check which X-ray series is present AND within usable energy range + has_usable_k_lines = any( + ("Ka" in line or "Kb" in line) and info["energy (keV)"] <= max_energy + for line, info in element_lines.items() + ) + has_usable_l_lines = any( + ("La" in line or "Lb" in line or "Lg" in line) + and info["energy (keV)"] <= max_energy + for line, info in element_lines.items() + ) + has_usable_m_lines = any( + ("Ma" in line or "Mb" in line) and info["energy (keV)"] <= max_energy + for line, info in element_lines.items() + ) + + # Prioritize K-lines, then L-lines, then M-lines (only if within usable range) + if has_usable_k_lines: + element_shells[element] = "K" + elif has_usable_l_lines: + element_shells[element] = "L" + elif has_usable_m_lines: + element_shells[element] = "M" + else: + element_shells[element] = "K" # Default fallback + + return element_shells + + def _normalize_k_factors(self, elements, k_factors, element_shells=None): + """Normalize k-factors input to a dict keyed by element symbol. + + Supports: + - scalar dict per element, e.g. {'Pt': 1.0, 'Co': 1.23} + - array-like values aligned with ``elements`` order + - per-shell dict per element, e.g. {'Pt': {'K': 0, 'L': 1.1, 'M': 0}} + where non-positive values are treated as unavailable shell entries. + """ + shell_order = ("K", "L", "M") + if element_shells is None: + element_shells = {} + + def _to_positive_float_or_none(value): + try: + parsed = float(value) + except (TypeError, ValueError): + return None + if not np.isfinite(parsed) or parsed <= 0: + return None + return parsed + + def _extract_shell_value(elem, shell_values): + preferred_shell = str(element_shells.get(elem, "K")).upper() + candidate_order = [preferred_shell] + [s for s in shell_order if s != preferred_shell] + + normalized_shell_values = {} + for shell in shell_order: + raw_value = shell_values.get(shell) + if raw_value is None: + raw_value = shell_values.get(shell.lower()) + normalized_shell_values[shell] = _to_positive_float_or_none(raw_value) + + for shell in candidate_order: + value = normalized_shell_values.get(shell) + if value is not None: + return value + + raise ValueError(f"k_factors['{elem}'] has no usable positive shell value in K/L/M") + + if isinstance(k_factors, dict): + missing = [elem for elem in elements if elem not in k_factors] + if missing: + raise ValueError(f"k_factors is missing elements: {missing}") + + normalized = {} + for elem in elements: + raw_entry = k_factors[elem] + + if isinstance(raw_entry, dict): + value = _extract_shell_value(elem, raw_entry) + else: + try: + value = float(raw_entry) + except (TypeError, ValueError): + raise TypeError( + f"k_factors['{elem}'] must be numeric or a dict with K/L/M entries" + ) + if not np.isfinite(value) or value <= 0: + raise ValueError(f"k_factors['{elem}'] must be a positive finite number") + + normalized[elem] = value + return normalized + + if isinstance(k_factors, (str, bytes)): + raise TypeError("k_factors must be a dict or array-like of numeric values") + + try: + values = list(k_factors) + except TypeError: + raise TypeError("k_factors must be a dict or array-like of numeric values") + + if len(values) != len(elements): + raise ValueError( + "Array-like k_factors length must match elements length " + f"({len(values)} != {len(elements)})" + ) + + normalized = {} + for elem, raw_value in zip(elements, values): + try: + value = float(raw_value) + except (TypeError, ValueError): + raise TypeError(f"k_factors value for '{elem}' must be numeric") + if not np.isfinite(value) or value <= 0: + raise ValueError(f"k_factors value for '{elem}' must be a positive finite number") + normalized[elem] = value + + return normalized + + def _cliff_lorimer_quantification(self, elements, intensities, k_factors, method, roi): + """Apply Cliff-Lorimer quantification method.""" + # Cliff-Lorimer equation: CA/CB = kAB * (IA/IB) + # For multiple elements: CA = kA * IA / SUM(ki * Ii) + + # Calculate weighted intensities + weighted_sum = 0.0 + weighted_intensities = {} + + for element in elements: + weighted_intensity = k_factors[element] * intensities[element] + weighted_intensities[element] = weighted_intensity + weighted_sum += weighted_intensity + + # Calculate atomic percentages + atomic_percent = {} + for element in elements: + if weighted_sum > 0: + atomic_percent[element] = (weighted_intensities[element] / weighted_sum) * 100.0 + else: + atomic_percent[element] = 0.0 + + # Calculate weight percentages (requires atomic weights) + if type(self).atomic_weights is None: + type(self).load_atomic_weights() + atomic_weights = type(self).atomic_weights or {} + + missing_weights = [element for element in elements if element not in atomic_weights] + if missing_weights: + raise ValueError( + f"Atomic weights not found for elements: {missing_weights}. " + "Use valid element symbols (e.g., 'Fe', 'Au', 'Te')." + ) + + # Convert atomic % to weight % + weight_sum = 0.0 + for element in elements: + atomic_wt = atomic_weights[element] + weight_sum += (atomic_percent[element] / 100.0) * atomic_wt + + weight_percent = {} + for element in elements: + if weight_sum > 0: + atomic_wt = atomic_weights[element] + weight_percent[element] = ( + (atomic_percent[element] / 100.0) * atomic_wt / weight_sum + ) * 100.0 + else: + weight_percent[element] = 0.0 + + # Print summary in Cliff-Lorimer format + print("\n=== Quantification (Cliff-Lorimer) ===") + print(f"ROI: {'Full image' if roi is None else roi}") + print(f"Elements: {', '.join(elements)}") + + print("\nRaw Intensities:") + for elem in elements: + print(f" {elem}: {intensities[elem]:.2f}") + + print("\nk-factors:") + for elem in elements: + print(f" {elem}: {k_factors[elem]:.2f}") + + print("\nAtomic %:") + for elem in elements: + print(f" {elem}: {atomic_percent[elem]:.1f} at%") + + print("\nWeight %:") + for elem in elements: + print(f" {elem}: {weight_percent[elem]:.1f} wt%") + + return { + "atomic_percent": atomic_percent, + "weight_percent": weight_percent, + "intensities": intensities, + "k_factors": k_factors, + "method": "cliff_lorimer", + } + def peak_autoid( self, roi=None, diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 3645ad55..45158fe9 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -106,6 +106,71 @@ def load_atomic_weights(cls): cls.atomic_weights = data + @staticmethod + def _resolve_spectral_source(source: str, dataset_type: str) -> str: + source_norm = source.strip().lower() + if source_norm not in {"auto", "xray", "eels"}: + raise ValueError("source must be one of: 'auto', 'xray', 'eels'") + if source_norm == "auto": + return "eels" if dataset_type.strip().lower() == "eels" else "xray" + return source_norm + + @staticmethod + def _build_spectral_table( + element: str, source: str, rows: list[tuple[str, float, float]], precision: int + ) -> str: + unit = "eV" if source == "eels" else "keV" + title = f"{element} {'EELS edges' if source == 'eels' else 'X-ray lines'}" + + display_rows = [ + ( + feature, + f"{energy:.{precision}f}", + "" if not np.isfinite(weight) else f"{weight:.{precision}f}", + ) + for feature, energy, weight in rows + ] + show_weight = any(weight for _, _, weight in display_rows) + + feature_w = max(len("Feature"), *(len(feature) for feature, _, _ in display_rows)) + energy_header = f"Energy ({unit})" + energy_w = max(len(energy_header), *(len(energy) for _, energy, _ in display_rows)) + + columns = [("Feature", feature_w, "<"), (energy_header, energy_w, ">")] + if show_weight: + weight_w = max(len("Weight"), *(len(weight) for _, _, weight in display_rows)) + columns.append(("Weight", weight_w, ">")) + + header = " ".join(f"{name:{align}{width}}" for name, width, align in columns) + lines = [title, header, "-" * len(header)] + for feature, energy, weight in display_rows: + values = [feature, energy] + if show_weight: + values.append(weight) + line = " ".join( + f"{value:{align}{width}}" for value, (_, width, align) in zip(values, columns) + ) + lines.append(line) + return "\n".join(lines) + + def _print_spectral_table( + self, + element: str, + source: str, + sort_by: str = "energy", + ascending: bool = True, + precision: int = 4, + ) -> str: + table = self.format_spectral_features_table( + element=element, + source=source, + sort_by=sort_by, + ascending=ascending, + precision=precision, + ) + print(table) + return table + def format_spectral_features_table( self, element: str, @@ -126,13 +191,9 @@ def format_spectral_features_table( msg += f" Available examples: {', '.join(available[:10])}" raise ValueError(msg) - source_norm = source.strip().lower() - if source_norm not in {"auto", "xray", "eels"}: - raise ValueError("source must be one of: 'auto', 'xray', 'eels'") - - if source_norm == "auto": - class_type = str(getattr(type(self), "dataset_type", "")).strip().lower() - source_norm = "eels" if class_type == "eels" else "xray" + source_norm = type(self)._resolve_spectral_source( + source=source, dataset_type=str(getattr(type(self), "dataset_type", "")) + ) energy_keys = ( ("energy (eV)", "onset (eV)", "edge (eV)", "energy") @@ -176,35 +237,9 @@ def format_spectral_features_table( if sort_index is None: raise ValueError("sort_by must be one of: feature/line/edge, energy, weight/strength") rows.sort(key=lambda r: r[sort_index], reverse=not ascending) - - unit = "eV" if source_norm == "eels" else "keV" - show_weight = any(np.isfinite(r[2]) for r in rows) - feature_w = max(len("Feature"), *(len(r[0]) for r in rows)) - energy_vals = [f"{r[1]:.{precision}f}" for r in rows] - energy_w = max(len(f"Energy ({unit})"), *(len(v) for v in energy_vals)) - - if show_weight: - weight_vals = [f"{r[2]:.{precision}f}" if np.isfinite(r[2]) else "" for r in rows] - weight_w = max(len("Weight"), *(len(v) for v in weight_vals)) - header = f"{'Feature':<{feature_w}} {'Energy (' + unit + ')':>{energy_w}} {'Weight':>{weight_w}}" - lines = [ - f"{element} {'EELS edges' if source_norm == 'eels' else 'X-ray lines'}", - header, - "-" * len(header), - ] - for (feature, _, _), e, w in zip(rows, energy_vals, weight_vals): - lines.append(f"{feature:<{feature_w}} {e:>{energy_w}} {w:>{weight_w}}") - else: - header = f"{'Feature':<{feature_w}} {'Energy (' + unit + ')':>{energy_w}}" - lines = [ - f"{element} {'EELS edges' if source_norm == 'eels' else 'X-ray lines'}", - header, - "-" * len(header), - ] - for (feature, _, _), e in zip(rows, energy_vals): - lines.append(f"{feature:<{feature_w}} {e:>{energy_w}}") - - return "\n".join(lines) + return type(self)._build_spectral_table( + element=element, source=source_norm, rows=rows, precision=precision + ) def format_xray_lines_table( self, @@ -246,9 +281,13 @@ def print_xray_lines( precision: int = 4, ) -> str: """Print and return a formatted table of X-ray lines for one element.""" - table = self.format_xray_lines_table(element, sort_by, ascending, precision) - print(table) - return table + return self._print_spectral_table( + element=element, + source="xray", + sort_by=sort_by, + ascending=ascending, + precision=precision, + ) def print_eels_edges( self, @@ -258,9 +297,13 @@ def print_eels_edges( precision: int = 4, ) -> str: """Print and return a formatted table of EELS edges for one element.""" - table = self.format_eels_edges_table(element, sort_by, ascending, precision) - print(table) - return table + return self._print_spectral_table( + element=element, + source="eels", + sort_by=sort_by, + ascending=ascending, + precision=precision, + ) def add_elements_to_model(self, elements): """ From a9b0766168def94d975516adadc039a47cccce86 Mon Sep 17 00:00:00 2001 From: smribet Date: Thu, 5 Mar 2026 16:14:02 -0800 Subject: [PATCH 061/136] remove old code --- .../spectroscopy/dataset3dspectroscopy.py | 560 ------------------ 1 file changed, 560 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 45158fe9..3d40c3a5 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -679,566 +679,6 @@ def _plot_pca_results( plt.tight_layout() plt.show() - # QUANTIFICATION ----------------------------------------------- - - def quantify_composition( - self, roi=None, elements=None, k_factors=None, method="cliff_lorimer", mask=None - ): - """ - Quantify elemental composition from EDS spectrum using Cliff-Lorimer approach. - - The Cliff-Lorimer equation relates atomic fractions to X-ray intensities: - CA/CB = kAB * (IA/IB) - - Parameters - ---------- - roi : list or tuple, optional - Region of interest as [y, x, dy, dx]. If None, uses full image. - elements : list, required - List of element symbols to quantify (e.g., ['Pt', 'Co']). - k_factors : dict or array-like, required - K-factors for the quantified elements. - - dict format: {'Pt': 1.0, 'Co': 1.23} - - array/list format: [1.0, 1.23] mapped in the same order as ``elements`` - - per-shell dict format: - {'Pt': {'K': 0, 'L': 1.12, 'M': 0}, 'Co': {'K': 1.23, 'L': 0, 'M': 0}} - where 0 means shell unavailable. - method : str, optional - Quantification method. Currently supports 'cliff_lorimer'. - mask : array, optional - Boolean mask for energy channel selection. - - Returns - ------- - dict : Composition results containing: - - 'atomic_percent': dict of element -> atomic % - - 'weight_percent': dict of element -> weight % - - 'intensities': dict of element -> integrated intensity - - 'k_factors': dict of k-factors used - - Examples - -------- - # With dictionary k-factors - k_factors = {'Pt': 1.0, 'Co': 1.23} - comp = dataset.quantify_composition(elements=['Pt', 'Co'], k_factors=k_factors) - - # With array-like k-factors (same order as elements) - comp = dataset.quantify_composition(elements=['Pt', 'Co'], k_factors=[1.0, 1.23]) - - # With per-shell k-factors (0 means unavailable shell) - shell_kf = { - 'Pt': {'K': 0, 'L': 1.12, 'M': 0}, - 'Co': {'K': 1.23, 'L': 0, 'M': 0}, - } - comp = dataset.quantify_composition(elements=['Pt', 'Co'], k_factors=shell_kf) - - # Access results - print(f"Pt: {comp['atomic_percent']['Pt']:.1f} at%") - print(f"Co: {comp['atomic_percent']['Co']:.1f} at%") - """ - - # Input validation - if elements is None or len(elements) < 2: - raise ValueError("At least 2 elements required for quantification") - - # Load element info if not available - if type(self).element_info is None: - type(self).load_element_info() - - # Extract spectrum from ROI - spectrum_data = self._extract_spectrum_for_quantification(roi, mask) - spec = spectrum_data["spectrum"] - E = spectrum_data["energy"] - - # Determine max usable energy from the actual dataset - max_energy = float(E.max()) if len(E) > 0 else 20.0 - - # Determine shell for each element and validate/normalize k-factors - if k_factors is None: - raise ValueError("Must provide k_factors as a dict or array-like") - - element_shells = self._determine_element_shells(elements, max_energy) - k_factors = self._normalize_k_factors(elements, k_factors, element_shells) - - # Get X-ray line intensities for each element using the correct shell - intensities = {} - for element in elements: - shell = element_shells.get(element, "K") # Default to K if not determined - intensity = self._integrate_element_intensity(element, spec, E, shell) - intensities[element] = intensity - - # Apply Cliff-Lorimer quantification - if method == "cliff_lorimer": - results = self._cliff_lorimer_quantification( - elements, intensities, k_factors, method, roi - ) - else: - raise ValueError(f"Unknown quantification method: {method}") - - return results - - def _extract_spectrum_for_quantification(self, roi, mask): - """Extract spectrum data for quantification (similar to show_mean_spectrum).""" - # Parse ROI (reuse logic from show_mean_spectrum) - if roi is None: - y, x, dy, dx = 0, 0, int(self.shape[1]), int(self.shape[2]) - elif len(roi) == 2: - y, x, dy, dx = int(roi[0]), int(roi[1]), 1, 1 - elif len(roi) == 4: - y_val, x_val, dy_val, dx_val = roi - y = 0 if y_val is None else int(y_val) - x = 0 if x_val is None else int(x_val) - dy = int(self.shape[1]) - y if dy_val is None else int(dy_val) - dx = int(self.shape[2]) - x if dx_val is None else int(dx_val) - else: - raise ValueError("roi must be None, [y, x], or [y, x, dy, dx]") - - # Energy axis - dE = float(self.sampling[0]) - E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 - E = E0 + dE * np.arange(self.shape[0]) - - # Extract spectrum with mask handling - if mask is not None: - mask = np.asarray(mask, dtype=bool) - if mask.shape != (self.shape[0],): - raise ValueError( - f"Mask shape {mask.shape} doesn't match energy axis ({self.shape[0]},)" - ) - arr = np.asarray(self.array, dtype=float)[mask, :, :] - spec = arr.sum(axis=(1, 2)) if arr.shape[0] > 0 else np.zeros(0) - E = E[mask] - else: - spec = np.empty(self.shape[0], dtype=float) - for k in range(self.shape[0]): - img = np.asarray(self.array[k], dtype=float) - roi_data = img[y : y + dy, x : x + dx] - if roi_data.size == 0: - raise ValueError("ROI is empty") - spec[k] = roi_data.mean() - - return {"spectrum": spec, "energy": E} - - def _integrate_element_intensity(self, element, spectrum, energy, shell="K"): - """Integrate X-ray intensity for a specific element using characteristic lines from the specified shell. - - Parameters - ---------- - element : str - Element symbol - spectrum : array - Spectrum intensities - energy : array - Energy axis in keV - shell : str - X-ray shell to use: 'K', 'L', or 'M' - """ - all_info = type(self).element_info - if element not in all_info: - raise ValueError(f"Element {element} not found in database") - - total_intensity = 0.0 - element_lines = all_info[element] - - # Filter lines by the specified shell (K, L, or M) - # For K-shell: Ka, Kb lines - # For L-shell: La, Lb, Lg lines - # For M-shell: Ma, Mb lines - shell_lines = [] - for line_name, info in element_lines.items(): - line_energy = info["energy (keV)"] - line_weight = info["weight"] - - # Check if line belongs to the specified shell - if shell == "K" and ("Ka" in line_name or "Kb" in line_name): - shell_lines.append((line_weight, line_energy, line_name)) - elif shell == "L" and ("La" in line_name or "Lb" in line_name or "Lg" in line_name): - shell_lines.append((line_weight, line_energy, line_name)) - elif shell == "M" and ("Ma" in line_name or "Mb" in line_name): - shell_lines.append((line_weight, line_energy, line_name)) - - # Sort by weight (highest first) and ignore lines beyond detector range - shell_lines = [(w, e, n) for w, e, n in shell_lines if e <= 12.0] - shell_lines.sort(reverse=True) - - # Use top 3 most intense lines from the specified shell for integration - for weight, line_energy, line_name in shell_lines[:3]: - if weight > 0.1: # Only significant lines - # Find integration window around the line - # Use +/- 0.1 keV window or adaptive based on energy resolution - window_width = max(0.1, line_energy * 0.01) # 1% of energy or 0.1 keV minimum - - # Find energy indices for integration - energy_mask = (energy >= line_energy - window_width) & ( - energy <= line_energy + window_width - ) - - if np.any(energy_mask): - # Simple background subtraction: use linear interpolation at edges - line_spectrum = spectrum[energy_mask] - if len(line_spectrum) > 2: - # Background level from edges of integration window - bg_level = (line_spectrum[0] + line_spectrum[-1]) / 2 - # Integrate above background, weighted by line intensity - net_intensity = np.sum(line_spectrum - bg_level) * weight - total_intensity += max(0, net_intensity) # No negative intensities - - return total_intensity - - def _determine_element_shells(self, elements, max_energy): - """Determine the appropriate X-ray shell (K, L, or M) for each element based on available lines. - - Parameters - ---------- - elements : list - List of element symbols - max_energy : float - Maximum energy in keV from the dataset - """ - all_info = type(self).element_info - element_shells = {} - - for element in elements: - if element not in all_info: - element_shells[element] = "K" # Default - continue - - element_lines = all_info[element] - - # Check which X-ray series is present AND within usable energy range - has_usable_k_lines = any( - ("Ka" in line or "Kb" in line) and info["energy (keV)"] <= max_energy - for line, info in element_lines.items() - ) - has_usable_l_lines = any( - ("La" in line or "Lb" in line or "Lg" in line) - and info["energy (keV)"] <= max_energy - for line, info in element_lines.items() - ) - has_usable_m_lines = any( - ("Ma" in line or "Mb" in line) and info["energy (keV)"] <= max_energy - for line, info in element_lines.items() - ) - - # Prioritize K-lines, then L-lines, then M-lines (only if within usable range) - if has_usable_k_lines: - element_shells[element] = "K" - elif has_usable_l_lines: - element_shells[element] = "L" - elif has_usable_m_lines: - element_shells[element] = "M" - else: - element_shells[element] = "K" # Default fallback - - return element_shells - - def _normalize_k_factors(self, elements, k_factors, element_shells=None): - """Normalize k-factors input to a dict keyed by element symbol. - - Supports: - - scalar dict per element, e.g. {'Pt': 1.0, 'Co': 1.23} - - array-like values aligned with ``elements`` order - - per-shell dict per element, e.g. {'Pt': {'K': 0, 'L': 1.1, 'M': 0}} - where non-positive values are treated as unavailable shell entries. - """ - shell_order = ("K", "L", "M") - if element_shells is None: - element_shells = {} - - def _to_positive_float_or_none(value): - try: - parsed = float(value) - except (TypeError, ValueError): - return None - if not np.isfinite(parsed) or parsed <= 0: - return None - return parsed - - def _extract_shell_value(elem, shell_values): - preferred_shell = str(element_shells.get(elem, "K")).upper() - candidate_order = [preferred_shell] + [s for s in shell_order if s != preferred_shell] - - normalized_shell_values = {} - for shell in shell_order: - raw_value = shell_values.get(shell) - if raw_value is None: - raw_value = shell_values.get(shell.lower()) - normalized_shell_values[shell] = _to_positive_float_or_none(raw_value) - - for shell in candidate_order: - value = normalized_shell_values.get(shell) - if value is not None: - return value - - raise ValueError(f"k_factors['{elem}'] has no usable positive shell value in K/L/M") - - if isinstance(k_factors, dict): - missing = [elem for elem in elements if elem not in k_factors] - if missing: - raise ValueError(f"k_factors is missing elements: {missing}") - - normalized = {} - for elem in elements: - raw_entry = k_factors[elem] - - if isinstance(raw_entry, dict): - value = _extract_shell_value(elem, raw_entry) - else: - try: - value = float(raw_entry) - except (TypeError, ValueError): - raise TypeError( - f"k_factors['{elem}'] must be numeric or a dict with K/L/M entries" - ) - if not np.isfinite(value) or value <= 0: - raise ValueError(f"k_factors['{elem}'] must be a positive finite number") - - normalized[elem] = value - return normalized - - if isinstance(k_factors, (str, bytes)): - raise TypeError("k_factors must be a dict or array-like of numeric values") - - try: - values = list(k_factors) - except TypeError: - raise TypeError("k_factors must be a dict or array-like of numeric values") - - if len(values) != len(elements): - raise ValueError( - "Array-like k_factors length must match elements length " - f"({len(values)} != {len(elements)})" - ) - - normalized = {} - for elem, raw_value in zip(elements, values): - try: - value = float(raw_value) - except (TypeError, ValueError): - raise TypeError(f"k_factors value for '{elem}' must be numeric") - if not np.isfinite(value) or value <= 0: - raise ValueError(f"k_factors value for '{elem}' must be a positive finite number") - normalized[elem] = value - - return normalized - - def _cliff_lorimer_quantification(self, elements, intensities, k_factors, method, roi): - """Apply Cliff-Lorimer quantification method.""" - # Cliff-Lorimer equation: CA/CB = kAB * (IA/IB) - # For multiple elements: CA = kA * IA / SUM(ki * Ii) - - # Calculate weighted intensities - weighted_sum = 0.0 - weighted_intensities = {} - - for element in elements: - weighted_intensity = k_factors[element] * intensities[element] - weighted_intensities[element] = weighted_intensity - weighted_sum += weighted_intensity - - # Calculate atomic percentages - atomic_percent = {} - for element in elements: - if weighted_sum > 0: - atomic_percent[element] = (weighted_intensities[element] / weighted_sum) * 100.0 - else: - atomic_percent[element] = 0.0 - - # Calculate weight percentages (requires atomic weights) - if type(self).atomic_weights is None: - type(self).load_atomic_weights() - atomic_weights = type(self).atomic_weights or {} - - missing_weights = [element for element in elements if element not in atomic_weights] - if missing_weights: - raise ValueError( - f"Atomic weights not found for elements: {missing_weights}. " - "Use valid element symbols (e.g., 'Fe', 'Au', 'Te')." - ) - - # Convert atomic % to weight % - weight_sum = 0.0 - for element in elements: - atomic_wt = atomic_weights[element] - weight_sum += (atomic_percent[element] / 100.0) * atomic_wt - - weight_percent = {} - for element in elements: - if weight_sum > 0: - atomic_wt = atomic_weights[element] - weight_percent[element] = ( - (atomic_percent[element] / 100.0) * atomic_wt / weight_sum - ) * 100.0 - else: - weight_percent[element] = 0.0 - - # Print summary in Cliff-Lorimer format - print("\n=== Quantification (Cliff-Lorimer) ===") - print(f"ROI: {'Full image' if roi is None else roi}") - print(f"Elements: {', '.join(elements)}") - - print("\nRaw Intensities:") - for elem in elements: - print(f" {elem}: {intensities[elem]:.2f}") - - print("\nk-factors:") - for elem in elements: - print(f" {elem}: {k_factors[elem]:.2f}") - - print("\nAtomic %:") - for elem in elements: - print(f" {elem}: {atomic_percent[elem]:.1f} at%") - - print("\nWeight %:") - for elem in elements: - print(f" {elem}: {weight_percent[elem]:.1f} wt%") - - return { - "atomic_percent": atomic_percent, - "weight_percent": weight_percent, - "intensities": intensities, - "k_factors": k_factors, - "method": "cliff_lorimer", - } - - def _find_best_element_combinations(self, peak_energies, peak_intensities, tolerance=0.15): - """ - Find the best combination of elements that explains the detected peaks using a cost function. - - Parameters: - peak_energies : array-like - Detected peak positions in keV - peak_intensities : array-like - Detected peak intensities - tolerance : float, default 0.15 - Energy tolerance for peak matching in keV - - Returns: - set : Set of element symbols that best explain the detected peaks - """ - from itertools import combinations - - # Get element database - all_info = type(self).element_info - if all_info is None: - return set() - - # Consider combinations of 1-4 elements (reasonable for most samples) - best_elements = set() - best_score = float("inf") - - # Get commonly analyzed elements (general EDS candidates) - general_elements = [ - "Fe", - "Pt", - "Cu", - "C", - "O", - "Ni", - "Co", - "Al", - "Si", - "Ti", - "Cr", - "Mn", - "Au", - "Ag", - "Zn", - "Ca", - "K", - "Na", - "Mg", - ] - available_elements = [el for el in general_elements if el in all_info] - - # Test combinations of different sizes - top_combinations = [] # Store combinations for analysis - for num_elements in range(1, min(5, len(available_elements) + 1)): - for element_combo in combinations(available_elements, num_elements): - score = self._calculate_element_combo_score( - element_combo, peak_energies, peak_intensities, all_info, tolerance - ) - - top_combinations.append((score, element_combo)) - - if score < best_score: - best_score = score - best_elements = set(element_combo) - return best_elements - - def _calculate_element_combo_score( - self, element_combo, peak_energies, peak_intensities, all_info, tolerance - ): - """ - Calculate a cost function score for a given combination of elements. - Lower scores are better. - - Strategy: Prioritize explaining ALL major peaks with the FEWEST elements. - Only accept combinations that explain most peaks with high-weight lines. - """ - score = 0.0 - explained_peaks = {} # peak_idx -> (matched_distance, line_weight, element) - - # For each detected peak, find the BEST match in the element combination - for i, (peak_energy, peak_intensity) in enumerate(zip(peak_energies, peak_intensities)): - best_match_distance = float("inf") - best_line_weight = 0.0 - best_element = None - found_match = False - - # Check all elements in the combination - for element in element_combo: - if element in all_info: - for line_name, line_info in all_info[element].items(): - line_energy = line_info["energy (keV)"] - line_weight = line_info.get("weight", 0.5) - distance = abs(peak_energy - line_energy) - - # Only consider lines with significant weight (major lines only) - if line_weight > 0.2 and distance <= tolerance: - # Update best match if this line is better - if distance < best_match_distance or ( - distance == best_match_distance and line_weight > best_line_weight - ): - best_match_distance = distance - best_line_weight = line_weight - best_element = element - found_match = True - - if found_match: - explained_peaks[i] = (best_match_distance, best_line_weight, best_element) - # Penalty for distance (prefer closer matches) - score += best_match_distance * 10.0 - # Bonus for high-weight lines (major lines score much better) - score -= best_line_weight * 3.0 - else: - # HEAVY penalty for unexplained peaks - this is the key constraint - score += 50.0 - - # Primary objective: explain ALL detected peaks - unexplained_peaks = len(peak_energies) - len(explained_peaks) - if unexplained_peaks > 0: - score += unexplained_peaks * 100.0 # Very high penalty for unexplained peaks - - # Secondary objective: prefer simpler explanations (fewer elements) - score += len(element_combo) * 5.0 - - # Tertiary objective: prefer explanations with multiple peaks per element - # This avoids one-off false matches and encourages coherent solutions - peaks_per_element = {} - for peak_idx, (dist, weight, elem) in explained_peaks.items(): - if elem not in peaks_per_element: - peaks_per_element[elem] = [] - peaks_per_element[elem].append((dist, weight)) - - # Bonus if each element explains multiple peaks (coherence - more likely correct) - for elem, matches in peaks_per_element.items(): - if len(matches) > 1: - # Elements with 2+ peak matches are much more likely correct - score -= len(matches) * 2.0 - - return score - def calculate_mean_spectrum( self, roi=None, energy_range=None, ignore_range=None, mask=None, attach_mean_spectrum=True ): From 8bf94e02abbac21d7e31619f0827072b15b576c5 Mon Sep 17 00:00:00 2001 From: smribet Date: Fri, 6 Mar 2026 05:13:44 -0800 Subject: [PATCH 062/136] lookup energy lines --- src/quantem/spectroscopy/dataset3deds.py | 123 +++++++ .../spectroscopy/dataset3dspectroscopy.py | 339 ++++-------------- 2 files changed, 186 insertions(+), 276 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index e06fdbf1..e0ed6614 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -1,4 +1,5 @@ import builtins +import re from typing import Any import matplotlib.pyplot as plt @@ -77,6 +78,128 @@ def __init__( self._virtual_images = {} self.dataset_type = "EDS" + def lookup_x_ray_lines( + self, spec: str | list[str] | tuple[str, ...] | set[str] + ) -> tuple[np.ndarray, np.ndarray, list[str]]: + """Lookup EDS X-ray lines for element, shell, or specific line specifiers. + + Parameters + ---------- + spec : str or sequence of str + Supported examples: + - ``"Au"``: all Au lines + - ``"AuK"`` or ``"Au K"``: all Au K-shell lines + - ``"AuKa1"`` or ``"Au Ka1"``: specific line + - ``"AuKa"``: line family prefix (e.g., Ka1, Ka2, Ka1,2) + + Returns + ------- + tuple + ``(energies_keV, weights, line_labels)``, where: + - ``energies_keV`` is a 1D numpy array of line energies in keV + - ``weights`` is a 1D numpy array of corresponding line weights + - ``line_labels`` is a list like ``["AuKa1", "AuKa2", ...]`` + """ + if type(self).element_info is None: + type(self).load_element_info() + + all_info = type(self).element_info or {} + if len(all_info) == 0: + raise ValueError("X-ray lines database is empty") + + if isinstance(spec, str): + specs = [spec] + elif isinstance(spec, (list, tuple, set)): + specs = [str(item) for item in spec] + else: + raise TypeError("spec must be a string or a sequence of strings") + + def _norm_token(text: str) -> str: + return re.sub(r"[^a-z0-9]", "", str(text).lower()) + + ordered_elements = sorted( + (str(key) for key in all_info.keys()), key=lambda k: (-len(k), k) + ) + rows = [] + + for raw_spec in specs: + compact_spec = re.sub(r"[\s_-]+", "", str(raw_spec).strip()) + if not compact_spec: + continue + + element_key = next( + (key for key in ordered_elements if compact_spec.lower().startswith(key.lower())), + None, + ) + if element_key is None: + raise ValueError(f"Could not resolve element from specifier '{raw_spec}'") + + line_suffix = compact_spec[len(element_key) :] + lines_info = all_info.get(element_key, {}) + if not isinstance(lines_info, dict) or len(lines_info) == 0: + raise ValueError(f"No X-ray lines found for element '{element_key}'") + + selected_rows = [] + if line_suffix == "": + selected_rows = list(lines_info.items()) + else: + suffix_norm = _norm_token(line_suffix) + if not suffix_norm: + raise ValueError(f"Could not parse line/edge token from '{raw_spec}'") + + exact_matches = [] + prefix_matches = [] + for line_name, line_info in lines_info.items(): + line_norm = _norm_token(str(line_name).split("__", 1)[0]) + if line_norm == suffix_norm: + exact_matches.append((line_name, line_info)) + if line_norm.startswith(suffix_norm): + prefix_matches.append((line_name, line_info)) + + selected_rows = exact_matches if len(exact_matches) > 0 else prefix_matches + if len(selected_rows) == 0: + raise ValueError( + f"No X-ray lines matched specifier '{raw_spec}' for element '{element_key}'" + ) + + for line_name, line_info in selected_rows: + if not isinstance(line_info, dict): + continue + + energy_raw = line_info.get("energy (keV)", line_info.get("energy")) + try: + energy = float(energy_raw) + except (TypeError, ValueError): + continue + + try: + weight = float(line_info.get("weight", 0.0)) + except (TypeError, ValueError): + weight = 0.0 + + canonical_line = str(line_name).split("__", 1)[0] + rows.append((f"{element_key}{canonical_line}", energy, weight)) + + if len(rows) == 0: + raise ValueError(f"No X-ray lines matched specifier(s): {specs}") + + # Keep stable output order by sorting on energy, then stronger lines, then label. + rows = sorted(rows, key=lambda item: (item[1], -item[2], item[0])) + + unique_rows = [] + seen = set() + for label, energy, weight in rows: + key = (label, round(float(energy), 12), round(float(weight), 12)) + if key in seen: + continue + seen.add(key) + unique_rows.append((label, energy, weight)) + + energies = np.asarray([row[1] for row in unique_rows], dtype=float) + weights = np.asarray([row[2] for row in unique_rows], dtype=float) + labels = [row[0] for row in unique_rows] + return energies, weights, labels + def quantify_composition( self, roi=None, elements=None, k_factors=None, method="cliff_lorimer", mask=None ): diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 3d40c3a5..8b1f5a24 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -48,27 +48,23 @@ def __init__( # loads elemental information @classmethod - def load_element_info( - cls, - ): + def load_element_info(cls): """Load element database for EDS (X-ray lines) or EELS (binding energies).""" - class_type = str(getattr(cls, "dataset_type", "")).strip().lower() - if class_type == "eels": - path = "eels_binding_energies.json" - elif class_type == "eds": - path = getattr(cls, "element_info_path", "x_ray_lines.csv") - else: - path = getattr(cls, "element_info_path", "x_ray_lines.csv") - if cls.element_info is not None: - # don't reload if already loaded return - base_dir = os.path.dirname(os.path.abspath(__file__)) - full_path = os.path.join(base_dir, path) - if str(path).lower().endswith(".csv"): + + class_type = str(getattr(cls, "dataset_type", "")).strip().lower() + path = ( + "eels_binding_energies.json" + if class_type == "eels" + else getattr(cls, "element_info_path", "x_ray_lines.csv") + ) + full_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), path) + + if path.lower().endswith(".csv"): cls.element_info = load_xray_lines_database(full_path) else: - with open(full_path, "r") as f: + with open(full_path, "r", encoding="utf-8") as f: cls.element_info = json.load(f)["elements"] @classmethod @@ -77,8 +73,9 @@ def load_atomic_weights(cls): if cls.atomic_weights is not None: return - base_dir = os.path.dirname(os.path.abspath(__file__)) - full_path = os.path.join(base_dir, cls.atomic_weights_path) + full_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), cls.atomic_weights_path + ) data = {} with open(full_path, "r", newline="") as f: reader = csv.reader(f) @@ -107,203 +104,41 @@ def load_atomic_weights(cls): cls.atomic_weights = data @staticmethod - def _resolve_spectral_source(source: str, dataset_type: str) -> str: - source_norm = source.strip().lower() - if source_norm not in {"auto", "xray", "eels"}: - raise ValueError("source must be one of: 'auto', 'xray', 'eels'") - if source_norm == "auto": - return "eels" if dataset_type.strip().lower() == "eels" else "xray" - return source_norm + def _normalize_element_specs(specs): + if isinstance(specs, str): + return [s.strip() for s in specs.split(",") if s.strip()] + if isinstance(specs, (list, tuple, set)): + out = [] + for spec in specs: + out.extend([s.strip() for s in str(spec).split(",") if s.strip()]) + return out + raise TypeError("elements must be a string or a sequence of strings") @staticmethod - def _build_spectral_table( - element: str, source: str, rows: list[tuple[str, float, float]], precision: int - ) -> str: - unit = "eV" if source == "eels" else "keV" - title = f"{element} {'EELS edges' if source == 'eels' else 'X-ray lines'}" - - display_rows = [ - ( - feature, - f"{energy:.{precision}f}", - "" if not np.isfinite(weight) else f"{weight:.{precision}f}", - ) - for feature, energy, weight in rows - ] - show_weight = any(weight for _, _, weight in display_rows) - - feature_w = max(len("Feature"), *(len(feature) for feature, _, _ in display_rows)) - energy_header = f"Energy ({unit})" - energy_w = max(len(energy_header), *(len(energy) for _, energy, _ in display_rows)) - - columns = [("Feature", feature_w, "<"), (energy_header, energy_w, ">")] - if show_weight: - weight_w = max(len("Weight"), *(len(weight) for _, _, weight in display_rows)) - columns.append(("Weight", weight_w, ">")) - - header = " ".join(f"{name:{align}{width}}" for name, width, align in columns) - lines = [title, header, "-" * len(header)] - for feature, energy, weight in display_rows: - values = [feature, energy] - if show_weight: - values.append(weight) - line = " ".join( - f"{value:{align}{width}}" for value, (_, width, align) in zip(values, columns) - ) - lines.append(line) - return "\n".join(lines) - - def _print_spectral_table( - self, - element: str, - source: str, - sort_by: str = "energy", - ascending: bool = True, - precision: int = 4, - ) -> str: - table = self.format_spectral_features_table( - element=element, - source=source, - sort_by=sort_by, - ascending=ascending, - precision=precision, - ) - print(table) - return table - - def format_spectral_features_table( - self, - element: str, - source: str = "auto", - sort_by: str = "energy", - ascending: bool = True, - precision: int = 4, - ) -> str: - """Format X-ray lines or EELS edges for one element as a simple text table.""" - if type(self).element_info is None: - type(self).load_element_info() - - all_info = type(self).element_info or {} - if element not in all_info: - available = sorted(all_info.keys()) - msg = f"Element '{element}' not found." - if available: - msg += f" Available examples: {', '.join(available[:10])}" - raise ValueError(msg) - - source_norm = type(self)._resolve_spectral_source( - source=source, dataset_type=str(getattr(type(self), "dataset_type", "")) - ) - - energy_keys = ( - ("energy (eV)", "onset (eV)", "edge (eV)", "energy") - if source_norm == "eels" - else ("energy (keV)", "energy_keV", "energy") - ) - rows = [] - for feature_name, info in all_info[element].items(): - if isinstance(info, dict): - energy_raw = next( - (info.get(k) for k in energy_keys if info.get(k) is not None), None - ) - weight_raw = info.get("weight", info.get("strength")) - else: - energy_raw = info - weight_raw = None + def _resolve_element_key(all_info, token): + token_norm = str(token).strip().lower() + return next((key for key in all_info if str(key).lower() == token_norm), None) - try: - energy = float(energy_raw) - except (TypeError, ValueError): - continue - - try: - weight = float(weight_raw) if weight_raw is not None else np.nan - except (TypeError, ValueError): - weight = np.nan - - rows.append((str(feature_name), energy, weight)) - - if not rows: - return f"{element}: no spectral features found." - - sort_index = { - "feature": 0, - "line": 0, - "edge": 0, - "energy": 1, - "weight": 2, - "strength": 2, - }.get(sort_by.strip().lower()) - if sort_index is None: - raise ValueError("sort_by must be one of: feature/line/edge, energy, weight/strength") - rows.sort(key=lambda r: r[sort_index], reverse=not ascending) - return type(self)._build_spectral_table( - element=element, source=source_norm, rows=rows, precision=precision - ) - - def format_xray_lines_table( - self, - element: str, - sort_by: str = "energy", - ascending: bool = True, - precision: int = 4, - ) -> str: - """Backward-compatible wrapper for X-ray lines.""" - return self.format_spectral_features_table( - element=element, - source="xray", - sort_by=sort_by, - ascending=ascending, - precision=precision, - ) - - def format_eels_edges_table( - self, - element: str, - sort_by: str = "energy", - ascending: bool = True, - precision: int = 4, - ) -> str: - """Format EELS edge entries for one element.""" - return self.format_spectral_features_table( - element=element, - source="eels", - sort_by=sort_by, - ascending=ascending, - precision=precision, - ) + @staticmethod + def _line_matches_selectors(line_name, selectors): + if not selectors: + return True + line_norm = str(line_name).strip().lower() + return any(line_norm == sel or line_norm.startswith(sel) for sel in selectors) - def print_xray_lines( - self, - element: str, - sort_by: str = "energy", - ascending: bool = True, - precision: int = 4, - ) -> str: - """Print and return a formatted table of X-ray lines for one element.""" - return self._print_spectral_table( - element=element, - source="xray", - sort_by=sort_by, - ascending=ascending, - precision=precision, - ) + @classmethod + def _select_lines(cls, line_dict, selectors): + if not isinstance(line_dict, dict): + return {} + if not selectors: + return dict(line_dict) - def print_eels_edges( - self, - element: str, - sort_by: str = "energy", - ascending: bool = True, - precision: int = 4, - ) -> str: - """Print and return a formatted table of EELS edges for one element.""" - return self._print_spectral_table( - element=element, - source="eels", - sort_by=sort_by, - ascending=ascending, - precision=precision, - ) + selector_norm = [str(sel).strip().lower() for sel in selectors if str(sel).strip()] + return { + line_name: line_info + for line_name, line_info in line_dict.items() + if cls._line_matches_selectors(line_name, selector_norm) + } def add_elements_to_model(self, elements): """ @@ -321,60 +156,29 @@ def add_elements_to_model(self, elements): if type(self).element_info is None: type(self).load_element_info() - def _normalize_specs(specs): - if isinstance(specs, str): - return [s.strip() for s in specs.split(",") if s.strip()] - if isinstance(specs, (list, tuple, set)): - out = [] - for spec in specs: - out.extend([s.strip() for s in str(spec).split(",") if s.strip()]) - return out - raise TypeError("elements must be a string or a sequence of strings") - - def _resolve_element_key(all_info, token): - token_norm = str(token).strip().lower() - for key in all_info.keys(): - if str(key).lower() == token_norm: - return key - return None - - def _select_lines(line_dict, selectors): - if not isinstance(line_dict, dict): - return {} - if selectors is None or len(selectors) == 0: - return dict(line_dict) - - selector_norm = [str(sel).strip().lower() for sel in selectors if str(sel).strip()] - selected = {} - for line_name, line_info in line_dict.items(): - line_norm = str(line_name).strip().lower() - if any(line_norm == sel or line_norm.startswith(sel) for sel in selector_norm): - selected[line_name] = line_info - return selected - all_info = type(self).element_info if all_info is None: return - specs = _normalize_specs(elements) + specs = type(self)._normalize_element_specs(elements) if self.model_elements is None: self.model_elements = {} for spec in specs: tokens = str(spec).split() - if len(tokens) == 0: + if not tokens: continue - element_key = _resolve_element_key(all_info, tokens[0]) + element_key = type(self)._resolve_element_key(all_info, tokens[0]) if element_key is None: continue selectors = tokens[1:] - selected_lines = _select_lines(all_info[element_key], selectors) - if len(selected_lines) == 0: + selected_lines = type(self)._select_lines(all_info[element_key], selectors) + if not selected_lines: continue - if len(selectors) == 0: + if not selectors: self.model_elements[element_key] = selected_lines else: existing = self.model_elements.get(element_key) @@ -383,7 +187,7 @@ def _select_lines(line_dict, selectors): existing.update(selected_lines) self.model_elements[element_key] = existing - if len(self.model_elements) == 0: + if not self.model_elements: self.model_elements = None def remove_elements_from_model(self, elements): @@ -401,35 +205,18 @@ def remove_elements_from_model(self, elements): if self.model_elements is None: return - def _normalize_specs(specs): - if isinstance(specs, str): - return [s.strip() for s in specs.split(",") if s.strip()] - if isinstance(specs, (list, tuple, set)): - out = [] - for spec in specs: - out.extend([s.strip() for s in str(spec).split(",") if s.strip()]) - return out - raise TypeError("elements must be a string or a sequence of strings") - - def _resolve_element_key(model_elements, token): - token_norm = str(token).strip().lower() - for key in model_elements.keys(): - if str(key).lower() == token_norm: - return key - return None - - specs = _normalize_specs(elements) + specs = type(self)._normalize_element_specs(elements) for spec in specs: tokens = str(spec).split() - if len(tokens) == 0: + if not tokens: continue - element_key = _resolve_element_key(self.model_elements, tokens[0]) + element_key = type(self)._resolve_element_key(self.model_elements, tokens[0]) if element_key is None: continue selectors = [str(token).strip().lower() for token in tokens[1:] if str(token).strip()] - if len(selectors) == 0: + if not selectors: self.model_elements.pop(element_key, None) continue @@ -438,15 +225,15 @@ def _resolve_element_key(model_elements, token): self.model_elements.pop(element_key, None) continue - for line_name in list(lines_info.keys()): - line_norm = str(line_name).strip().lower() - if any(line_norm == sel or line_norm.startswith(sel) for sel in selectors): - lines_info.pop(line_name, None) - - if len(lines_info) == 0: + self.model_elements[element_key] = { + line_name: line_info + for line_name, line_info in lines_info.items() + if not type(self)._line_matches_selectors(line_name, selectors) + } + if not self.model_elements[element_key]: self.model_elements.pop(element_key, None) - if len(self.model_elements) == 0: + if not self.model_elements: self.model_elements = None def clear_model_elements(self): From eeeba25735dfc2a73ee706a69cec9b2e87c8db3f Mon Sep 17 00:00:00 2001 From: smribet Date: Fri, 6 Mar 2026 05:43:07 -0800 Subject: [PATCH 063/136] generate spectrum Images --- src/quantem/spectroscopy/dataset3deds.py | 26 +++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index e0ed6614..14d1bee9 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -78,7 +78,7 @@ def __init__( self._virtual_images = {} self.dataset_type = "EDS" - def lookup_x_ray_lines( + def x_ray_lookup( self, spec: str | list[str] | tuple[str, ...] | set[str] ) -> tuple[np.ndarray, np.ndarray, list[str]]: """Lookup EDS X-ray lines for element, shell, or specific line specifiers. @@ -200,6 +200,30 @@ def _norm_token(text: str) -> str: labels = [row[0] for row in unique_rows] return energies, weights, labels + def generage_spectrum_images(self, elements, width=0.15, return_maps=False): + energies, weights, labels = self.x_ray_lookup(elements) + energy_axis = np.arange(self.shape[0]) * self.sampling[0] + self.origin[0] + energy_axis_2d = energy_axis[:, None] + energies_2d = (energies)[None, :] + + mask = (energy_axis_2d > energies_2d - width) & (energy_axis_2d < energies_2d + width) + + N, H, W = self.array.shape + K = mask.shape[1] + eds2 = self.array.reshape(N, -1) + w = mask.astype(self.array.dtype) * weights + + maps = (w.T @ eds2).reshape(K, H, W) + + if hasattr(self, "_spectrum_images"): + self._spectrum_images = {**self._spectrum_images, **dict(zip(labels, maps))} + else: + self._spectrum_images = {} + self._spectrum_images = {**self._spectrum_images, **dict(zip(labels, maps))} + + if return_maps: + return maps, labels + def quantify_composition( self, roi=None, elements=None, k_factors=None, method="cliff_lorimer", mask=None ): From 8e643d3c9e207205627fc8f412f9be6483d7610c Mon Sep 17 00:00:00 2001 From: smribet Date: Fri, 6 Mar 2026 06:01:01 -0800 Subject: [PATCH 064/136] plot spectrum images --- src/quantem/spectroscopy/dataset3deds.py | 122 +++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 14d1bee9..bf369457 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -221,9 +221,131 @@ def generage_spectrum_images(self, elements, width=0.15, return_maps=False): self._spectrum_images = {} self._spectrum_images = {**self._spectrum_images, **dict(zip(labels, maps))} + self.plot_spectrum_images() + if return_maps: return maps, labels + def plot_spectrum_images(self, x_ray_lines=None, **kwargs): + """Plot cached spectrum-image maps from ``self._spectrum_images``. + + Parameters + ---------- + x_ray_lines : None | str | sequence[str], optional + Selection behavior: + - ``None`` (default): sum and plot one map per element (e.g., all Au lines together) + - ``"Au"``: sum and plot all maps for element Au + - ``"AuKa1"``: plot a specific line map + - ``"AuK"``: sum and plot all matching line-prefix maps + **kwargs + Forwarded to :func:`quantem.core.visualization.show_2d`. + + Returns + ------- + tuple + ``(fig, axs)`` from ``show_2d``. + """ + spectrum_images = getattr(self, "_spectrum_images", None) + if not isinstance(spectrum_images, dict) or len(spectrum_images) == 0: + raise ValueError("No spectrum images found. Run generage_spectrum_images(...) first.") + + line_map = {str(label): np.asarray(image) for label, image in spectrum_images.items()} + if len(line_map) == 0: + raise ValueError("No spectrum images available to plot") + + if type(self).element_info is None: + type(self).load_element_info() + known_elements = sorted( + [str(key) for key in (type(self).element_info or {}).keys()], + key=lambda k: (-len(k), k), + ) + + def _normalize_specs(specs): + if specs is None: + return None + if isinstance(specs, str): + return [s.strip() for s in specs.split(",") if s.strip()] + if isinstance(specs, (list, tuple, set)): + out = [] + for item in specs: + out.extend([s.strip() for s in str(item).split(",") if s.strip()]) + return out + raise TypeError("x_ray_lines must be None, a string, or a sequence of strings") + + def _resolve_element_from_label(label): + for element in known_elements: + if str(label).startswith(element): + return element + m = re.match(r"^[A-Z][a-z]?", str(label)) + return m.group(0) if m else None + + def _sum_maps(labels): + maps = [line_map[label] for label in labels] + return np.sum(np.stack(maps, axis=0), axis=0) + + lines_by_element = {} + for label in sorted(line_map.keys()): + element = _resolve_element_from_label(label) + if element is None: + continue + lines_by_element.setdefault(element, []).append(label) + + normalized_label_map = {label.lower(): label for label in line_map.keys()} + normalized_element_map = {element.lower(): element for element in lines_by_element.keys()} + + specs = _normalize_specs(x_ray_lines) + images_to_plot = [] + titles = [] + + if specs is None or len(specs) == 0: + for element in sorted(lines_by_element.keys()): + labels = lines_by_element[element] + if len(labels) == 0: + continue + images_to_plot.append(_sum_maps(labels)) + titles.append(element) + else: + for raw_spec in specs: + spec = str(raw_spec).strip() + if not spec: + continue + + label_key = normalized_label_map.get(spec.lower()) + if label_key is not None: + images_to_plot.append(line_map[label_key]) + titles.append(label_key) + continue + + element_key = normalized_element_map.get(spec.lower()) + if element_key is not None: + labels = lines_by_element[element_key] + images_to_plot.append(_sum_maps(labels)) + titles.append(element_key) + continue + + compact = re.sub(r"[\s_-]+", "", spec).lower() + matched_labels = [ + label + for label in line_map.keys() + if re.sub(r"[\s_-]+", "", label).lower().startswith(compact) + ] + if len(matched_labels) == 0: + raise ValueError( + f"No spectrum images matched selector '{spec}'. " + f"Available examples: {', '.join(sorted(line_map.keys())[:10])}" + ) + if len(matched_labels) == 1: + images_to_plot.append(line_map[matched_labels[0]]) + titles.append(matched_labels[0]) + else: + images_to_plot.append(_sum_maps(matched_labels)) + titles.append(spec) + + if len(images_to_plot) == 0: + raise ValueError("No spectrum images selected for plotting") + + return show_2d(images_to_plot, title=titles, **kwargs) + def quantify_composition( self, roi=None, elements=None, k_factors=None, method="cliff_lorimer", mask=None ): From 5be8301c019e8f62a5def60dbd18a6dbac0af074 Mon Sep 17 00:00:00 2001 From: smribet Date: Fri, 6 Mar 2026 06:17:05 -0800 Subject: [PATCH 065/136] plotting update --- src/quantem/spectroscopy/dataset3deds.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index bf369457..3e89ecb3 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -344,7 +344,17 @@ def _sum_maps(labels): if len(images_to_plot) == 0: raise ValueError("No spectrum images selected for plotting") - return show_2d(images_to_plot, title=titles, **kwargs) + cmap = kwargs.pop("cmap", "magma") + return show_2d( + images_to_plot, + title=titles, + cmap=cmap, + scalebar={ + "sampling": self.sampling[1], + "units": self.units[1], + }, + **kwargs, + ) def quantify_composition( self, roi=None, elements=None, k_factors=None, method="cliff_lorimer", mask=None From 6fc62b660de52c9e594cca591f3ec5e57cda7534 Mon Sep 17 00:00:00 2001 From: smribet Date: Fri, 6 Mar 2026 06:18:32 -0800 Subject: [PATCH 066/136] fix --- src/quantem/spectroscopy/dataset3deds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 3e89ecb3..1e2defbf 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -211,7 +211,7 @@ def generage_spectrum_images(self, elements, width=0.15, return_maps=False): N, H, W = self.array.shape K = mask.shape[1] eds2 = self.array.reshape(N, -1) - w = mask.astype(self.array.dtype) * weights + w = mask.astype(self.array.dtype) maps = (w.T @ eds2).reshape(K, H, W) From 522f50decea86a35666e954297a537434c54d809 Mon Sep 17 00:00:00 2001 From: smribet Date: Fri, 6 Mar 2026 07:40:10 -0800 Subject: [PATCH 067/136] spectrum images pytorch --- src/quantem/spectroscopy/dataset3deds.py | 53 +++++++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 1e2defbf..ed1c0424 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -226,7 +226,7 @@ def generage_spectrum_images(self, elements, width=0.15, return_maps=False): if return_maps: return maps, labels - def plot_spectrum_images(self, x_ray_lines=None, **kwargs): + def plot_spectrum_images(self, x_ray_lines=None, return_fig=False, **kwargs): """Plot cached spectrum-image maps from ``self._spectrum_images``. Parameters @@ -345,7 +345,7 @@ def _sum_maps(labels): raise ValueError("No spectrum images selected for plotting") cmap = kwargs.pop("cmap", "magma") - return show_2d( + fig, ax = show_2d( images_to_plot, title=titles, cmap=cmap, @@ -353,8 +353,39 @@ def _sum_maps(labels): "sampling": self.sampling[1], "units": self.units[1], }, + returnfig=True, **kwargs, ) + if return_fig: + return fig, ax + + def _build_pytorch_spectrum_images( + self, abundance_maps: np.ndarray, element_names: list[str] | tuple[str, ...] + ) -> dict[str, np.ndarray]: + """Build per-line maps from fitted per-element abundance maps.""" + maps = np.asarray(abundance_maps) + if maps.ndim != 3: + return {} + + line_maps = {} + for element_index, element_name in enumerate(element_names): + if element_index >= maps.shape[0]: + break + + element_map = np.asarray(maps[element_index], dtype=float) + try: + _, line_weights, line_labels = self.x_ray_lookup(str(element_name)) + except ValueError: + continue + + for weight, label in zip(line_weights, line_labels): + try: + weight_value = float(weight) + except (TypeError, ValueError): + continue + line_maps[str(label)] = element_map * weight_value + + return line_maps def quantify_composition( self, roi=None, elements=None, k_factors=None, method="cliff_lorimer", mask=None @@ -2507,6 +2538,23 @@ def _local_closure(): abundance_maps = conc_final.view(n_y, n_x, n_elements).permute(2, 0, 1).cpu().numpy() peak_widths = nn.functional.softplus(peak_width_params).detach().cpu().numpy() + + pytorch_spectrum_images = self._build_pytorch_spectrum_images( + abundance_maps=abundance_maps, + element_names=list(global_model.peak_model.element_names), + ) + if hasattr(self, "_spectrum_images_pytorch"): + self._spectrum_images_pytorch = { + **self._spectrum_images_pytorch, + **pytorch_spectrum_images, + } + else: + self._spectrum_images_pytorch = {} + self._spectrum_images_pytorch = { + **self._spectrum_images_pytorch, + **pytorch_spectrum_images, + } + loss_history_array = np.asarray(loss_history) energy_axis_np = energy_axis.cpu().numpy() @@ -2584,6 +2632,7 @@ def _local_closure(): "fitted_spectrum_all_pixels": mean_fitted_spectrum_all, "background_spectrum_all_pixels": mean_background_spectrum_all, "fit_range": energy_range, + "spectrum_images_pytorch": self._spectrum_images_pytorch, } def calculate_background_powerlaw(self, spectrum): From 735d6e9e56f171fe7412d2510e31ad781423b6f6 Mon Sep 17 00:00:00 2001 From: smribet Date: Fri, 6 Mar 2026 07:42:43 -0800 Subject: [PATCH 068/136] improve naming --- src/quantem/spectroscopy/dataset3deds.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index ed1c0424..c0d84e4f 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -221,12 +221,12 @@ def generage_spectrum_images(self, elements, width=0.15, return_maps=False): self._spectrum_images = {} self._spectrum_images = {**self._spectrum_images, **dict(zip(labels, maps))} - self.plot_spectrum_images() + self.show_spectrum_images() if return_maps: return maps, labels - def plot_spectrum_images(self, x_ray_lines=None, return_fig=False, **kwargs): + def show_spectrum_images(self, x_ray_lines=None, return_fig=False, **kwargs): """Plot cached spectrum-image maps from ``self._spectrum_images``. Parameters From d23c9eb0eefc48f0cfdcf600efc92ed4c956aeab Mon Sep 17 00:00:00 2001 From: smribet Date: Fri, 6 Mar 2026 14:48:25 -0800 Subject: [PATCH 069/136] update plotting --- src/quantem/spectroscopy/dataset3deds.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index c0d84e4f..286139ba 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -226,7 +226,9 @@ def generage_spectrum_images(self, elements, width=0.15, return_maps=False): if return_maps: return maps, labels - def show_spectrum_images(self, x_ray_lines=None, return_fig=False, **kwargs): + def show_spectrum_images( + self, x_ray_lines=None, return_fig=False, method="integration", **kwargs + ): """Plot cached spectrum-image maps from ``self._spectrum_images``. Parameters @@ -237,6 +239,9 @@ def show_spectrum_images(self, x_ray_lines=None, return_fig=False, **kwargs): - ``"Au"``: sum and plot all maps for element Au - ``"AuKa1"``: plot a specific line map - ``"AuK"``: sum and plot all matching line-prefix maps + method : ``"integration"`` | str + ``"integration"`` : plots maps based on integration method + ``"fit"`` : plots maps based on fitting method **kwargs Forwarded to :func:`quantem.core.visualization.show_2d`. @@ -245,7 +250,15 @@ def show_spectrum_images(self, x_ray_lines=None, return_fig=False, **kwargs): tuple ``(fig, axs)`` from ``show_2d``. """ - spectrum_images = getattr(self, "_spectrum_images", None) + if method == "integration": + spectrum_images = getattr(self, "_spectrum_images", None) + elif method == "fit": + spectrum_images = getattr(self, "_spectrum_images_pytorch", None) + else: + raise ValueError( + "Method {method}, is not supported, please choose 'integration' or 'fit'" + ) + if not isinstance(spectrum_images, dict) or len(spectrum_images) == 0: raise ValueError("No spectrum images found. Run generage_spectrum_images(...) first.") @@ -2614,8 +2627,7 @@ def _local_closure(): plt.tight_layout() plt.show() - map_titles = [f"{name}" for name in global_model.peak_model.element_names] - show_2d(list(abundance_maps), title=map_titles) + self.show_spectrum_images(method="fit") return { "abundance_maps": abundance_maps, From 1b7ba7b2bce4b6f0028b2da54d8d1923a1a338ad Mon Sep 17 00:00:00 2001 From: smribet Date: Fri, 6 Mar 2026 14:56:29 -0800 Subject: [PATCH 070/136] using model elements --- src/quantem/spectroscopy/dataset3deds.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 286139ba..f592f554 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -200,7 +200,13 @@ def _norm_token(text: str) -> str: labels = [row[0] for row in unique_rows] return energies, weights, labels - def generage_spectrum_images(self, elements, width=0.15, return_maps=False): + def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False): + if elements is None and self.model_elements is not None: + elements = list(self.model_elements.keys()) + print(f"using model_elements {elements}") + else: + raise ValueError("elements must be specified") + energies, weights, labels = self.x_ray_lookup(elements) energy_axis = np.arange(self.shape[0]) * self.sampling[0] + self.origin[0] energy_axis_2d = energy_axis[:, None] From c9fab6d001c7dfa050ad042f64c6fb7242d4532c Mon Sep 17 00:00:00 2001 From: smribet Date: Fri, 6 Mar 2026 17:55:22 -0800 Subject: [PATCH 071/136] refactor --- src/quantem/spectroscopy/dataset3deds.py | 694 ++++++++--------------- 1 file changed, 231 insertions(+), 463 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index f592f554..56b3df6d 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -1,4 +1,3 @@ -import builtins import re from typing import Any @@ -78,6 +77,38 @@ def __init__( self._virtual_images = {} self.dataset_type = "EDS" + @staticmethod + def _normalize_specs(specs, param_name="spec", allow_none=False): + if specs is None and allow_none: + return None + if isinstance(specs, str): + return [s.strip() for s in specs.split(",") if s.strip()] + if isinstance(specs, (list, tuple, set)): + out = [] + for item in specs: + out.extend([s.strip() for s in str(item).split(",") if s.strip()]) + return out + raise TypeError( + f"{param_name} must be {'None, ' if allow_none else ''}a string or a sequence of strings" + ) + + @staticmethod + def _normalize_token(text): + return re.sub(r"[^a-z0-9]", "", str(text).lower()) + + @staticmethod + def _ordered_element_keys(all_info): + return sorted([str(key) for key in all_info.keys()], key=lambda k: (-len(k), k)) + + @classmethod + def _resolve_element_from_label(cls, label, ordered_elements): + label_str = str(label) + for element_name in ordered_elements: + if label_str.startswith(element_name): + return element_name + m = re.match(r"^[A-Z][a-z]?", label_str) + return m.group(0) if m else None + def x_ray_lookup( self, spec: str | list[str] | tuple[str, ...] | set[str] ) -> tuple[np.ndarray, np.ndarray, list[str]]: @@ -87,10 +118,10 @@ def x_ray_lookup( ---------- spec : str or sequence of str Supported examples: - - ``"Au"``: all Au lines - - ``"AuK"`` or ``"Au K"``: all Au K-shell lines - - ``"AuKa1"`` or ``"Au Ka1"``: specific line - - ``"AuKa"``: line family prefix (e.g., Ka1, Ka2, Ka1,2) + - ``"Fe"``: all Fe lines + - ``"FeK"``: all Fe K-shell lines + - ``"FeKa1"``: specific line + - ``"FeKa"``: line family prefix (e.g., Ka1, Ka2) Returns ------- @@ -107,19 +138,8 @@ def x_ray_lookup( if len(all_info) == 0: raise ValueError("X-ray lines database is empty") - if isinstance(spec, str): - specs = [spec] - elif isinstance(spec, (list, tuple, set)): - specs = [str(item) for item in spec] - else: - raise TypeError("spec must be a string or a sequence of strings") - - def _norm_token(text: str) -> str: - return re.sub(r"[^a-z0-9]", "", str(text).lower()) - - ordered_elements = sorted( - (str(key) for key in all_info.keys()), key=lambda k: (-len(k), k) - ) + specs = type(self)._normalize_specs(spec, param_name="spec") + ordered_elements = type(self)._ordered_element_keys(all_info) rows = [] for raw_spec in specs: @@ -139,18 +159,17 @@ def _norm_token(text: str) -> str: if not isinstance(lines_info, dict) or len(lines_info) == 0: raise ValueError(f"No X-ray lines found for element '{element_key}'") - selected_rows = [] if line_suffix == "": selected_rows = list(lines_info.items()) else: - suffix_norm = _norm_token(line_suffix) + suffix_norm = type(self)._normalize_token(line_suffix) if not suffix_norm: raise ValueError(f"Could not parse line/edge token from '{raw_spec}'") exact_matches = [] prefix_matches = [] for line_name, line_info in lines_info.items(): - line_norm = _norm_token(str(line_name).split("__", 1)[0]) + line_norm = type(self)._normalize_token(str(line_name).split("__", 1)[0]) if line_norm == suffix_norm: exact_matches.append((line_name, line_info)) if line_norm.startswith(suffix_norm): @@ -183,9 +202,7 @@ def _norm_token(text: str) -> str: if len(rows) == 0: raise ValueError(f"No X-ray lines matched specifier(s): {specs}") - # Keep stable output order by sorting on energy, then stronger lines, then label. rows = sorted(rows, key=lambda item: (item[1], -item[2], item[0])) - unique_rows = [] seen = set() for label, energy, weight in rows: @@ -201,11 +218,11 @@ def _norm_token(text: str) -> str: return energies, weights, labels def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False): - if elements is None and self.model_elements is not None: + if elements is None: + if self.model_elements is None: + raise ValueError("elements must be specified") elements = list(self.model_elements.keys()) print(f"using model_elements {elements}") - else: - raise ValueError("elements must be specified") energies, weights, labels = self.x_ray_lookup(elements) energy_axis = np.arange(self.shape[0]) * self.sampling[0] + self.origin[0] @@ -221,11 +238,8 @@ def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False) maps = (w.T @ eds2).reshape(K, H, W) - if hasattr(self, "_spectrum_images"): - self._spectrum_images = {**self._spectrum_images, **dict(zip(labels, maps))} - else: - self._spectrum_images = {} - self._spectrum_images = {**self._spectrum_images, **dict(zip(labels, maps))} + existing = getattr(self, "_spectrum_images", {}) + self._spectrum_images = {**existing, **dict(zip(labels, maps))} self.show_spectrum_images() @@ -241,10 +255,10 @@ def show_spectrum_images( ---------- x_ray_lines : None | str | sequence[str], optional Selection behavior: - - ``None`` (default): sum and plot one map per element (e.g., all Au lines together) - - ``"Au"``: sum and plot all maps for element Au - - ``"AuKa1"``: plot a specific line map - - ``"AuK"``: sum and plot all matching line-prefix maps + - ``"Fe"``: all Fe lines + - ``"FeK"``: all Fe K-shell lines + - ``"FeKa1"``: specific line + - ``"FeKa"``: line family prefix (e.g., Ka1, Ka2) method : ``"integration"`` | str ``"integration"`` : plots maps based on integration method ``"fit"`` : plots maps based on fitting method @@ -272,31 +286,7 @@ def show_spectrum_images( if len(line_map) == 0: raise ValueError("No spectrum images available to plot") - if type(self).element_info is None: - type(self).load_element_info() - known_elements = sorted( - [str(key) for key in (type(self).element_info or {}).keys()], - key=lambda k: (-len(k), k), - ) - - def _normalize_specs(specs): - if specs is None: - return None - if isinstance(specs, str): - return [s.strip() for s in specs.split(",") if s.strip()] - if isinstance(specs, (list, tuple, set)): - out = [] - for item in specs: - out.extend([s.strip() for s in str(item).split(",") if s.strip()]) - return out - raise TypeError("x_ray_lines must be None, a string, or a sequence of strings") - - def _resolve_element_from_label(label): - for element in known_elements: - if str(label).startswith(element): - return element - m = re.match(r"^[A-Z][a-z]?", str(label)) - return m.group(0) if m else None + known_elements = type(self)._ordered_element_keys(type(self).element_info or {}) def _sum_maps(labels): maps = [line_map[label] for label in labels] @@ -304,7 +294,7 @@ def _sum_maps(labels): lines_by_element = {} for label in sorted(line_map.keys()): - element = _resolve_element_from_label(label) + element = type(self)._resolve_element_from_label(label, known_elements) if element is None: continue lines_by_element.setdefault(element, []).append(label) @@ -312,7 +302,7 @@ def _sum_maps(labels): normalized_label_map = {label.lower(): label for label in line_map.keys()} normalized_element_map = {element.lower(): element for element in lines_by_element.keys()} - specs = _normalize_specs(x_ray_lines) + specs = type(self)._normalize_specs(x_ray_lines, param_name="x_ray_lines", allow_none=True) images_to_plot = [] titles = [] @@ -342,11 +332,11 @@ def _sum_maps(labels): titles.append(element_key) continue - compact = re.sub(r"[\s_-]+", "", spec).lower() + compact = type(self)._normalize_token(spec) matched_labels = [ label for label in line_map.keys() - if re.sub(r"[\s_-]+", "", label).lower().startswith(compact) + if type(self)._normalize_token(label).startswith(compact) ] if len(matched_labels) == 0: raise ValueError( @@ -406,425 +396,207 @@ def _build_pytorch_spectrum_images( return line_maps - def quantify_composition( - self, roi=None, elements=None, k_factors=None, method="cliff_lorimer", mask=None + def quantify_composition_cliff_lorimer( + self, k_factors, method="integration", return_maps=False ): - """ - Quantify elemental composition from EDS spectrum using Cliff-Lorimer approach. - - The Cliff-Lorimer equation relates atomic fractions to X-ray intensities: - CA/CB = kAB * (IA/IB) + """Quantify composition from cached spectrum maps using Cliff-Lorimer. Parameters ---------- - roi : list or tuple, optional - Region of interest as [y, x, dy, dx]. If None, uses full image. - elements : list, required - List of element symbols to quantify (e.g., ['Pt', 'Co']). - k_factors : dict or array-like, required - K-factors for the quantified elements. - - dict format: {'Pt': 1.0, 'Co': 1.23} - - array/list format: [1.0, 1.23] mapped in the same order as ``elements`` - - per-shell dict format: - {'Pt': {'K': 0, 'L': 1.12, 'M': 0}, 'Co': {'K': 1.23, 'L': 0, 'M': 0}} - where 0 means shell unavailable. - method : str, optional - Quantification method. Currently supports 'cliff_lorimer'. - mask : array, optional - Boolean mask for energy channel selection. - - Returns - ------- - dict : Composition results containing: - - 'atomic_percent': dict of element -> atomic % - - 'weight_percent': dict of element -> weight % - - 'intensities': dict of element -> integrated intensity - - 'k_factors': dict of k-factors used - - Examples - -------- - # With dictionary k-factors - k_factors = {'Pt': 1.0, 'Co': 1.23} - comp = dataset.quantify_composition(elements=['Pt', 'Co'], k_factors=k_factors) - - # With array-like k-factors (same order as elements) - comp = dataset.quantify_composition(elements=['Pt', 'Co'], k_factors=[1.0, 1.23]) - - # With per-shell k-factors (0 means unavailable shell) - shell_kf = { - 'Pt': {'K': 0, 'L': 1.12, 'M': 0}, - 'Co': {'K': 1.23, 'L': 0, 'M': 0}, - } - comp = dataset.quantify_composition(elements=['Pt', 'Co'], k_factors=shell_kf) - - # Access results - print(f"Pt: {comp['atomic_percent']['Pt']:.1f} at%") - print(f"Co: {comp['atomic_percent']['Co']:.1f} at%") + k_factors : dict + Mapping selector -> k-factor, where selectors are element+edge/line, e.g. + ``"AuL"``, ``"TeL"``,. + method : {"integration", "fit"} + Source of maps: + - ``"integration"`` uses ``self._spectrum_images`` + - ``"fit"`` uses ``self._spectrum_images_pytorch`` + return_maps : bool, optional + If ``True``, include per-selector and per-element maps plus map-based + atomic/weight percent outputs. """ + if not isinstance(k_factors, dict) or len(k_factors) == 0: + raise ValueError("k_factors must be a non-empty dict") - # Input validation - if elements is None or len(elements) < 2: - raise ValueError("At least 2 elements required for quantification") + if method == "integration": + spectrum_images = getattr(self, "_spectrum_images", None) + elif method == "fit": + spectrum_images = getattr(self, "_spectrum_images_pytorch", None) + else: + raise ValueError( + f"Method {method!r} is not supported, please choose 'integration' or 'fit'" + ) + if not isinstance(spectrum_images, dict) or len(spectrum_images) == 0: + raise ValueError("No spectrum images available for quantification") - # Load element info if not available if type(self).element_info is None: type(self).load_element_info() - # Extract spectrum from ROI - spectrum_data = self._extract_spectrum_for_quantification(roi, mask) - spec = spectrum_data["spectrum"] - E = spectrum_data["energy"] + ordered_elements = type(self)._ordered_element_keys(type(self).element_info or {}) + line_map = { + str(label): np.asarray(image, dtype=float) for label, image in spectrum_images.items() + } + normalized_label_map = {str(label).lower(): str(label) for label in line_map} + + def _match_labels(selector): + token = str(selector).strip() + if not token: + return [] - # Determine max usable energy from the actual dataset - max_energy = float(E.max()) if len(E) > 0 else 20.0 + exact = normalized_label_map.get(token.lower()) + if exact is not None: + return [exact] - # Determine shell for each element and validate/normalize k-factors - if k_factors is None: - raise ValueError("Must provide k_factors as a dict or array-like") + try: + _, _, labels_lookup = self.x_ray_lookup(token) + labels = [label for label in labels_lookup if label in line_map] + if len(labels) > 0: + return labels + except ValueError: + pass - element_shells = self._determine_element_shells(elements, max_energy) - k_factors = self._normalize_k_factors(elements, k_factors, element_shells) + compact = type(self)._normalize_token(token) + return [ + label + for label in line_map + if type(self)._normalize_token(label).startswith(compact) + ] - # Get X-ray line intensities for each element using the correct shell + details = {} intensities = {} - for element in elements: - shell = element_shells.get(element, "K") # Default to K if not determined - intensity = self._integrate_element_intensity(element, spec, E, shell) - intensities[element] = intensity - - # Apply Cliff-Lorimer quantification - if method == "cliff_lorimer": - results = self._cliff_lorimer_quantification( - elements, intensities, k_factors, method, roi - ) - else: - raise ValueError(f"Unknown quantification method: {method}") - - return results - - def _extract_spectrum_for_quantification(self, roi, mask): - """Extract spectrum data for quantification (similar to show_mean_spectrum).""" - # Parse ROI (reuse logic from show_mean_spectrum) - if roi is None: - y, x, dy, dx = 0, 0, int(self.shape[1]), int(self.shape[2]) - elif len(roi) == 2: - y, x, dy, dx = int(roi[0]), int(roi[1]), 1, 1 - elif len(roi) == 4: - y_val, x_val, dy_val, dx_val = roi - y = 0 if y_val is None else int(y_val) - x = 0 if x_val is None else int(x_val) - dy = int(self.shape[1]) - y if dy_val is None else int(dy_val) - dx = int(self.shape[2]) - x if dx_val is None else int(dx_val) - else: - raise ValueError("roi must be None, [y, x], or [y, x, dy, dx]") + weighted_intensities = {} + selector_maps = {} + intensity_maps = {} + weighted_intensity_maps = {} + normalized_k = {} - # Energy axis - dE = float(self.sampling[0]) - E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 - E = E0 + dE * np.arange(self.shape[0]) + for selector, k_value_raw in k_factors.items(): + try: + k_value = float(k_value_raw) + except (TypeError, ValueError): + raise ValueError(f"k_factors[{selector!r}] must be numeric") + if not np.isfinite(k_value) or k_value <= 0: + raise ValueError(f"k_factors[{selector!r}] must be a positive finite number") - # Extract spectrum with mask handling - if mask is not None: - mask = np.asarray(mask, dtype=bool) - if mask.shape != (self.shape[0],): + labels = _match_labels(selector) + if len(labels) == 0: raise ValueError( - f"Mask shape {mask.shape} doesn't match energy axis ({self.shape[0]},)" + f"No spectrum images matched selector {selector!r}. " + f"Available examples: {', '.join(sorted(line_map.keys())[:10])}" ) - arr = np.asarray(self.array, dtype=float)[mask, :, :] - spec = arr.sum(axis=(1, 2)) if arr.shape[0] > 0 else np.zeros(0) - E = E[mask] - else: - spec = np.empty(self.shape[0], dtype=float) - for k in range(self.shape[0]): - img = np.asarray(self.array[k], dtype=float) - roi_data = img[y : y + dy, x : x + dx] - if roi_data.size == 0: - raise ValueError("ROI is empty") - spec[k] = roi_data.mean() - return {"spectrum": spec, "energy": E} - - def _integrate_element_intensity(self, element, spectrum, energy, shell="K"): - """Integrate X-ray intensity for a specific element using characteristic lines from the specified shell. - - Parameters - ---------- - element : str - Element symbol - spectrum : array - Spectrum intensities - energy : array - Energy axis in keV - shell : str - X-ray shell to use: 'K', 'L', or 'M' - """ - all_info = type(self).element_info - if element not in all_info: - raise ValueError(f"Element {element} not found in database") - - total_intensity = 0.0 - element_lines = all_info[element] - - # Filter lines by the specified shell (K, L, or M) - # For K-shell: Ka, Kb lines - # For L-shell: La, Lb, Lg lines - # For M-shell: Ma, Mb lines - shell_lines = [] - for line_name, info in element_lines.items(): - line_energy = info["energy (keV)"] - line_weight = info["weight"] - - # Check if line belongs to the specified shell - if shell == "K" and ("Ka" in line_name or "Kb" in line_name): - shell_lines.append((line_weight, line_energy, line_name)) - elif shell == "L" and ("La" in line_name or "Lb" in line_name or "Lg" in line_name): - shell_lines.append((line_weight, line_energy, line_name)) - elif shell == "M" and ("Ma" in line_name or "Mb" in line_name): - shell_lines.append((line_weight, line_energy, line_name)) - - # Sort by weight (highest first) and ignore lines beyond detector range - shell_lines = [(w, e, n) for w, e, n in shell_lines if e <= 12.0] - shell_lines.sort(reverse=True) - - # Use top 3 most intense lines from the specified shell for integration - for weight, line_energy, line_name in shell_lines[:3]: - if weight > 0.1: # Only significant lines - # Find integration window around the line - # Use +/- 0.1 keV window or adaptive based on energy resolution - window_width = max(0.1, line_energy * 0.01) # 1% of energy or 0.1 keV minimum - - # Find energy indices for integration - energy_mask = (energy >= line_energy - window_width) & ( - energy <= line_energy + window_width + matched_elements = { + type(self)._resolve_element_from_label(label, ordered_elements) for label in labels + } + matched_elements = {elem for elem in matched_elements if elem is not None} + if len(matched_elements) != 1: + raise ValueError( + f"Selector {selector!r} matched multiple elements: {sorted(matched_elements)}. " + "Use selectors like 'AuK' or 'AuKa1'." ) + element = next(iter(matched_elements)) + + grouped_map = np.sum(np.stack([line_map[label] for label in labels], axis=0), axis=0) + intensity = float(np.sum(grouped_map)) + weighted = float(k_value * intensity) + weighted_map = grouped_map * k_value + + selector_key = str(selector) + normalized_k[selector_key] = k_value + selector_maps[selector_key] = grouped_map + details[selector_key] = { + "element": element, + "labels": list(labels), + "intensity": intensity, + "k_factor": k_value, + "weighted_intensity": weighted, + } - if np.any(energy_mask): - # Simple background subtraction: use linear interpolation at edges - line_spectrum = spectrum[energy_mask] - if len(line_spectrum) > 2: - # Background level from edges of integration window - bg_level = (line_spectrum[0] + line_spectrum[-1]) / 2 - # Integrate above background, weighted by line intensity - net_intensity = np.sum(line_spectrum - bg_level) * weight - total_intensity += max(0, net_intensity) # No negative intensities - - return total_intensity - - def _determine_element_shells(self, elements, max_energy): - """Determine the appropriate X-ray shell (K, L, or M) for each element based on available lines. - - Parameters - ---------- - elements : list - List of element symbols - max_energy : float - Maximum energy in keV from the dataset - """ - all_info = type(self).element_info - element_shells = {} - - for element in elements: - if element not in all_info: - element_shells[element] = "K" # Default - continue - - element_lines = all_info[element] - - # Check which X-ray series is present AND within usable energy range - has_usable_k_lines = any( - ("Ka" in line or "Kb" in line) and info["energy (keV)"] <= max_energy - for line, info in element_lines.items() + intensities[element] = float(intensities.get(element, 0.0)) + intensity + weighted_intensities[element] = ( + float(weighted_intensities.get(element, 0.0)) + weighted ) - has_usable_l_lines = any( - ("La" in line or "Lb" in line or "Lg" in line) - and info["energy (keV)"] <= max_energy - for line, info in element_lines.items() - ) - has_usable_m_lines = any( - ("Ma" in line or "Mb" in line) and info["energy (keV)"] <= max_energy - for line, info in element_lines.items() - ) - - # Prioritize K-lines, then L-lines, then M-lines (only if within usable range) - if has_usable_k_lines: - element_shells[element] = "K" - elif has_usable_l_lines: - element_shells[element] = "L" - elif has_usable_m_lines: - element_shells[element] = "M" + if element in intensity_maps: + intensity_maps[element] = intensity_maps[element] + grouped_map + weighted_intensity_maps[element] = weighted_intensity_maps[element] + weighted_map else: - element_shells[element] = "K" # Default fallback - - return element_shells - - def _normalize_k_factors(self, elements, k_factors, element_shells=None): - """Normalize k-factors input to a dict keyed by element symbol. - - Supports: - - scalar dict per element, e.g. {'Pt': 1.0, 'Co': 1.23} - - array-like values aligned with ``elements`` order - - per-shell dict per element, e.g. {'Pt': {'K': 0, 'L': 1.1, 'M': 0}} - where non-positive values are treated as unavailable shell entries. - """ - shell_order = ("K", "L", "M") - if element_shells is None: - element_shells = {} - - def _to_positive_float_or_none(value): - try: - parsed = float(value) - except (TypeError, ValueError): - return None - if not np.isfinite(parsed) or parsed <= 0: - return None - return parsed - - def _extract_shell_value(elem, shell_values): - preferred_shell = str(element_shells.get(elem, "K")).upper() - candidate_order = [preferred_shell] + [s for s in shell_order if s != preferred_shell] - - normalized_shell_values = {} - for shell in shell_order: - raw_value = shell_values.get(shell) - if raw_value is None: - raw_value = shell_values.get(shell.lower()) - normalized_shell_values[shell] = _to_positive_float_or_none(raw_value) - - for shell in candidate_order: - value = normalized_shell_values.get(shell) - if value is not None: - return value - - raise ValueError(f"k_factors['{elem}'] has no usable positive shell value in K/L/M") - - if isinstance(k_factors, dict): - missing = [elem for elem in elements if elem not in k_factors] - if missing: - raise ValueError(f"k_factors is missing elements: {missing}") - - normalized = {} - for elem in elements: - raw_entry = k_factors[elem] - - if isinstance(raw_entry, dict): - value = _extract_shell_value(elem, raw_entry) - else: - try: - value = float(raw_entry) - except (TypeError, ValueError): - raise TypeError( - f"k_factors['{elem}'] must be numeric or a dict with K/L/M entries" - ) - if not np.isfinite(value) or value <= 0: - raise ValueError(f"k_factors['{elem}'] must be a positive finite number") - - normalized[elem] = value - return normalized - - if isinstance(k_factors, (str, bytes)): - raise TypeError("k_factors must be a dict or array-like of numeric values") - - try: - values = list(k_factors) - except TypeError: - raise TypeError("k_factors must be a dict or array-like of numeric values") - - if len(values) != len(elements): - raise ValueError( - "Array-like k_factors length must match elements length " - f"({len(values)} != {len(elements)})" - ) - - normalized = {} - for elem, raw_value in zip(elements, values): - try: - value = float(raw_value) - except (TypeError, ValueError): - raise TypeError(f"k_factors value for '{elem}' must be numeric") - if not np.isfinite(value) or value <= 0: - raise ValueError(f"k_factors value for '{elem}' must be a positive finite number") - normalized[elem] = value + intensity_maps[element] = grouped_map.copy() + weighted_intensity_maps[element] = weighted_map.copy() - return normalized + if len(weighted_intensities) < 2: + raise ValueError("At least two elements are required for Cliff-Lorimer quantification") - def _cliff_lorimer_quantification(self, elements, intensities, k_factors, method, roi): - """Apply Cliff-Lorimer quantification method.""" - # Cliff-Lorimer equation: CA/CB = kAB * (IA/IB) - # For multiple elements: CA = kA * IA / SUM(ki * Ii) - - # Calculate weighted intensities - weighted_sum = 0.0 - weighted_intensities = {} - - for element in elements: - weighted_intensity = k_factors[element] * intensities[element] - weighted_intensities[element] = weighted_intensity - weighted_sum += weighted_intensity - - # Calculate atomic percentages - atomic_percent = {} - for element in elements: - if weighted_sum > 0: - atomic_percent[element] = (weighted_intensities[element] / weighted_sum) * 100.0 - else: - atomic_percent[element] = 0.0 + weighted_sum = float(sum(weighted_intensities.values())) + atomic_percent = { + element: (100.0 * value / weighted_sum if weighted_sum > 0 else 0.0) + for element, value in weighted_intensities.items() + } - # Calculate weight percentages (requires atomic weights) if type(self).atomic_weights is None: type(self).load_atomic_weights() atomic_weights = type(self).atomic_weights or {} + missing = [element for element in atomic_percent if element not in atomic_weights] + if missing: + raise ValueError(f"Atomic weights not found for elements: {missing}") - missing_weights = [element for element in elements if element not in atomic_weights] - if missing_weights: - raise ValueError( - f"Atomic weights not found for elements: {missing_weights}. " - "Use valid element symbols (e.g., 'Fe', 'Au', 'Te')." + weight_sum = sum( + (atomic_percent[element] / 100.0) * float(atomic_weights[element]) + for element in atomic_percent + ) + weight_percent = { + element: ( + ((atomic_percent[element] / 100.0) * float(atomic_weights[element]) / weight_sum) + * 100.0 + if weight_sum > 0 + else 0.0 ) + for element in atomic_percent + } - # Convert atomic % to weight % - weight_sum = 0.0 - for element in elements: - atomic_wt = atomic_weights[element] - weight_sum += (atomic_percent[element] / 100.0) * atomic_wt - - weight_percent = {} - for element in elements: - if weight_sum > 0: - atomic_wt = atomic_weights[element] - weight_percent[element] = ( - (atomic_percent[element] / 100.0) * atomic_wt / weight_sum - ) * 100.0 - else: - weight_percent[element] = 0.0 - - # Print summary in Cliff-Lorimer format - print("\n=== Quantification (Cliff-Lorimer) ===") - print(f"ROI: {'Full image' if roi is None else roi}") - print(f"Elements: {', '.join(elements)}") - - print("\nRaw Intensities:") - for elem in elements: - print(f" {elem}: {intensities[elem]:.2f}") - - print("\nk-factors:") - for elem in elements: - print(f" {elem}: {k_factors[elem]:.2f}") - - print("\nAtomic %:") - for elem in elements: - print(f" {elem}: {atomic_percent[elem]:.1f} at%") - - print("\nWeight %:") - for elem in elements: - print(f" {elem}: {weight_percent[elem]:.1f} wt%") - - return { + result = { + "intensities": intensities, + "weighted_intensities": weighted_intensities, "atomic_percent": atomic_percent, "weight_percent": weight_percent, - "intensities": intensities, - "k_factors": k_factors, - "method": "cliff_lorimer", } + if return_maps: + weighted_stack = np.stack(list(weighted_intensity_maps.values()), axis=0) + weighted_sum_map = np.sum(weighted_stack, axis=0) + atomic_percent_maps = { + element: np.divide( + weighted_map * 100.0, + weighted_sum_map, + out=np.zeros_like(weighted_sum_map, dtype=float), + where=weighted_sum_map > 0, + ) + for element, weighted_map in weighted_intensity_maps.items() + } + + mass_maps = { + element: (atomic_percent_maps[element] / 100.0) * float(atomic_weights[element]) + for element in atomic_percent_maps + } + mass_sum_map = np.sum(np.stack(list(mass_maps.values()), axis=0), axis=0) + weight_percent_maps = { + element: np.divide( + mass_map * 100.0, + mass_sum_map, + out=np.zeros_like(mass_sum_map, dtype=float), + where=mass_sum_map > 0, + ) + for element, mass_map in mass_maps.items() + } + + result.update( + { + "selector_maps": selector_maps, + "intensity_maps": intensity_maps, + "weighted_intensity_maps": weighted_intensity_maps, + "atomic_percent_maps": atomic_percent_maps, + "weight_percent_maps": weight_percent_maps, + } + ) + + return result def peak_autoid( self, @@ -863,7 +635,7 @@ def peak_autoid( if isinstance(elements, str): elements = [elements] - if elements is not None and not isinstance(elements, (list, tuple, builtins.set)): + if elements is not None and not isinstance(elements, (list, tuple, set)): raise TypeError("elements must be None, a string, or a sequence of strings") def _line_matches_selector(line_name: str, selector: str) -> bool: @@ -891,7 +663,7 @@ def _parse_requested_elements_with_edges(element_specs): selectors = [str(token).strip() for token in tokens[1:] if str(token).strip()] if element_key not in parsed: - parsed[element_key] = None if len(selectors) == 0 else builtins.set(selectors) + parsed[element_key] = None if len(selectors) == 0 else set(selectors) continue existing = parsed[element_key] @@ -912,9 +684,7 @@ def _edge_filters_from_saved_model(model_elements): for element_name, lines_info in model_elements.items(): element_key = str(element_name) if isinstance(lines_info, dict) and len(lines_info) > 0: - parsed[element_key] = builtins.set( - str(line_name) for line_name in lines_info.keys() - ) + parsed[element_key] = set(str(line_name) for line_name in lines_info.keys()) else: parsed[element_key] = None @@ -934,14 +704,12 @@ def _edge_filters_from_saved_model(model_elements): if isinstance(ignore_elements, str): ignore_elements = [ignore_elements] - if ignore_elements is not None and not isinstance( - ignore_elements, (list, tuple, builtins.set) - ): + if ignore_elements is not None and not isinstance(ignore_elements, (list, tuple, set)): raise TypeError("ignore_elements must be None, a string, or a sequence of strings") ignored_elements = ( {str(element_name) for element_name in ignore_elements} if ignore_elements is not None - else builtins.set() + else set() ) fig, (ax_img, ax_spec) = self.show_mean_spectrum( @@ -1109,7 +877,7 @@ def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): return best_element, best_line_name, best_line_weight, best_distance if elements is not None: - search_elements = builtins.set(elements) + search_elements = set(elements) else: search_elements = None @@ -1138,7 +906,7 @@ def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): ) ) - detected_elements = builtins.set() + detected_elements = set() detected_sample_peaks = {} element_confidence = {} element_stats = {} @@ -1163,8 +931,8 @@ def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): if element_name not in element_stats: element_stats[element_name] = { "raw_conf": 0.0, - "shells": builtins.set(), - "lines": builtins.set(), + "shells": set(), + "lines": set(), "strong_matches": 0, "match_count": 0, "best_match_conf": 0.0, @@ -1347,7 +1115,7 @@ def _best_supported_line_match_with_prior( candidate_elements = sorted( str(element_name) - for element_name in builtins.set(element_stats.keys()) + for element_name in set(element_stats.keys()) if str(element_name) not in detected_elements ) @@ -1371,7 +1139,7 @@ def _format_elements_with_lines(element_names): formatted = [] for element_name in sorted(str(name) for name in element_names): stats = element_stats.get(str(element_name), {}) - lines = stats.get("lines", builtins.set()) + lines = stats.get("lines", set()) line_names = sorted(str(line_name) for line_name in lines) if len(line_names) > 0: formatted.append(f"{element_name} [{', '.join(line_names)}]") @@ -1398,7 +1166,7 @@ def _format_elements_with_lines(element_names): print("Possible: None") # Stable per-element colors (same element color across K/L/M lines) - elements_for_color = builtins.set(detected_elements) + elements_for_color = set(detected_elements) if search_elements is not None: elements_for_color.update(str(el) for el in search_elements) elements_for_color.update(str(match[4]) for match in peak_matches) From e1f7bf56e7988f5e14624086795b39ce0919b2ca Mon Sep 17 00:00:00 2001 From: smribet Date: Sat, 7 Mar 2026 06:10:31 -0800 Subject: [PATCH 072/136] trying to organize text parsing --- src/quantem/spectroscopy/dataset3deds.py | 643 ++++++++---------- .../spectroscopy/dataset3dspectroscopy.py | 6 +- 2 files changed, 283 insertions(+), 366 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 56b3df6d..66b89c32 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -32,10 +32,6 @@ class Dataset3deds(Dataset3dspectroscopy): """ - element_info = None - element_info_path = "x_ray_lines.csv" - dataset_type = "EDS" - def __init__( self, array: NDArray | Any, @@ -74,8 +70,6 @@ def __init__( signal_units=signal_units, _token=_token, ) - self._virtual_images = {} - self.dataset_type = "EDS" @staticmethod def _normalize_specs(specs, param_name="spec", allow_none=False): @@ -98,7 +92,7 @@ def _normalize_token(text): @staticmethod def _ordered_element_keys(all_info): - return sorted([str(key) for key in all_info.keys()], key=lambda k: (-len(k), k)) + return sorted([str(key) for key in all_info], key=lambda k: (-len(k), k)) @classmethod def _resolve_element_from_label(cls, label, ordered_elements): @@ -109,79 +103,132 @@ def _resolve_element_from_label(cls, label, ordered_elements): m = re.match(r"^[A-Z][a-z]?", label_str) return m.group(0) if m else None - def x_ray_lookup( - self, spec: str | list[str] | tuple[str, ...] | set[str] - ) -> tuple[np.ndarray, np.ndarray, list[str]]: - """Lookup EDS X-ray lines for element, shell, or specific line specifiers. + @classmethod + def _ensure_element_info(cls): + """Load and return the element->lines info dict.""" + if cls.element_info is None: + cls.load_element_info() + return cls.element_info or {} - Parameters - ---------- - spec : str or sequence of str - Supported examples: - - ``"Fe"``: all Fe lines - - ``"FeK"``: all Fe K-shell lines - - ``"FeKa1"``: specific line - - ``"FeKa"``: line family prefix (e.g., Ka1, Ka2) + @classmethod + def _parse_element_selectors(cls, specs, *, allow_none=False, param_name="spec"): + """Parse selectors like 'Fe', 'FeK', 'FeKa1' into {element: None|set[tokens]}.""" + tokens = cls._normalize_specs(specs, param_name=param_name, allow_none=allow_none) + if tokens is None: + return None - Returns - ------- - tuple - ``(energies_keV, weights, line_labels)``, where: - - ``energies_keV`` is a 1D numpy array of line energies in keV - - ``weights`` is a 1D numpy array of corresponding line weights - - ``line_labels`` is a list like ``["AuKa1", "AuKa2", ...]`` - """ - if type(self).element_info is None: - type(self).load_element_info() + info = cls._ensure_element_info() + ordered = cls._ordered_element_keys(info) + out: dict[str, set[str] | None] = {} - all_info = type(self).element_info or {} - if len(all_info) == 0: - raise ValueError("X-ray lines database is empty") + for raw in tokens: + compact = re.sub(r"[\s_-]+", "", str(raw).strip()) + if not compact: + continue - specs = type(self)._normalize_specs(spec, param_name="spec") - ordered_elements = type(self)._ordered_element_keys(all_info) - rows = [] + element = next((k for k in ordered if compact.lower().startswith(k.lower())), None) + if element is None: + raise ValueError(f"Could not resolve element from specifier '{raw}'") - for raw_spec in specs: - compact_spec = re.sub(r"[\s_-]+", "", str(raw_spec).strip()) - if not compact_spec: - continue + suffix = compact[len(element) :] + if not suffix: + out[element] = None + else: + out.setdefault(element, set()) + if out[element] is not None: + out[element].add(str(suffix)) - element_key = next( - (key for key in ordered_elements if compact_spec.lower().startswith(key.lower())), - None, + return out or None + + @staticmethod + def _canonical_line_name(line_name: str) -> str: + return str(line_name).split("__", 1)[0] + + @classmethod + def _iter_selected_lines(cls, element: str, suffix: str, *, raw_spec: str): + """Yield (line_name, line_info) for an element based on suffix matching.""" + info = cls._ensure_element_info() + lines = info.get(element) or {} + if not isinstance(lines, dict) or not lines: + raise ValueError(f"No X-ray lines found for element '{element}'") + + if not suffix: + yield from lines.items() + return + + suffix_norm = cls._normalize_token(suffix) + if not suffix_norm: + raise ValueError(f"Could not parse line/edge token from '{raw_spec}'") + + exact, prefix = [], [] + for ln, li in lines.items(): + base = cls._canonical_line_name(str(ln)) + ln_norm = cls._normalize_token(base) + if ln_norm == suffix_norm: + exact.append((ln, li)) + if ln_norm.startswith(suffix_norm): + prefix.append((ln, li)) + + chosen = exact or prefix + if not chosen: + raise ValueError( + f"No X-ray lines matched specifier '{raw_spec}' for element '{element}'" ) - if element_key is None: - raise ValueError(f"Could not resolve element from specifier '{raw_spec}'") + yield from chosen - line_suffix = compact_spec[len(element_key) :] - lines_info = all_info.get(element_key, {}) - if not isinstance(lines_info, dict) or len(lines_info) == 0: - raise ValueError(f"No X-ray lines found for element '{element_key}'") + @classmethod + def _group_labels_by_element(cls, labels: list[str]): + info = cls._ensure_element_info() + ordered = cls._ordered_element_keys(info) + grouped: dict[str, list[str]] = {} + for lbl in sorted(map(str, labels)): + element = cls._resolve_element_from_label(lbl, ordered) + if element: + grouped.setdefault(element, []).append(lbl) + return grouped - if line_suffix == "": - selected_rows = list(lines_info.items()) - else: - suffix_norm = type(self)._normalize_token(line_suffix) - if not suffix_norm: - raise ValueError(f"Could not parse line/edge token from '{raw_spec}'") + @classmethod + def _select_labels( + cls, selector: str, *, labels: list[str], labels_by_element: dict[str, list[str]] + ): + """Select labels from available spectrum-image labels using selector semantics.""" + sel = str(selector).strip() + if not sel: + return [] - exact_matches = [] - prefix_matches = [] - for line_name, line_info in lines_info.items(): - line_norm = type(self)._normalize_token(str(line_name).split("__", 1)[0]) - if line_norm == suffix_norm: - exact_matches.append((line_name, line_info)) - if line_norm.startswith(suffix_norm): - prefix_matches.append((line_name, line_info)) - - selected_rows = exact_matches if len(exact_matches) > 0 else prefix_matches - if len(selected_rows) == 0: - raise ValueError( - f"No X-ray lines matched specifier '{raw_spec}' for element '{element_key}'" - ) + lower_map = {lbl.lower(): lbl for lbl in labels} + if sel.lower() in lower_map: + return [lower_map[sel.lower()]] + + elem_map = {elem.lower(): elem for elem in labels_by_element} + if sel.lower() in elem_map: + return list(labels_by_element[elem_map[sel.lower()]]) + + compact = cls._normalize_token(sel) + return [lbl for lbl in labels if cls._normalize_token(lbl).startswith(compact)] + + def x_ray_lookup( + self, spec: str | list[str] | tuple[str, ...] | set[str] + ) -> tuple[np.ndarray, np.ndarray, list[str]]: + """Lookup EDS X-ray lines for element, shell, or specific line specifiers.""" + info = type(self)._ensure_element_info() + ordered = type(self)._ordered_element_keys(info) + specs = type(self)._normalize_specs(spec, param_name="spec") - for line_name, line_info in selected_rows: + rows: list[tuple[str, float, float]] = [] + for raw in specs: + compact = re.sub(r"[\s_-]+", "", str(raw).strip()) + if not compact: + continue + + element = next((k for k in ordered if compact.lower().startswith(k.lower())), None) + if element is None: + raise ValueError(f"Could not resolve element from specifier '{raw}'") + + suffix = compact[len(element) :] + for line_name, line_info in type(self)._iter_selected_lines( + element, suffix, raw_spec=str(raw) + ): if not isinstance(line_info, dict): continue @@ -196,25 +243,25 @@ def x_ray_lookup( except (TypeError, ValueError): weight = 0.0 - canonical_line = str(line_name).split("__", 1)[0] - rows.append((f"{element_key}{canonical_line}", energy, weight)) + canonical = type(self)._canonical_line_name(str(line_name)) + rows.append((f"{element}{canonical}", energy, weight)) - if len(rows) == 0: + if not rows: raise ValueError(f"No X-ray lines matched specifier(s): {specs}") - rows = sorted(rows, key=lambda item: (item[1], -item[2], item[0])) - unique_rows = [] + rows.sort(key=lambda t: (t[1], -t[2], t[0])) seen = set() - for label, energy, weight in rows: - key = (label, round(float(energy), 12), round(float(weight), 12)) - if key in seen: + unique = [] + for lbl, e, w in rows: + k = (lbl, round(float(e), 12), round(float(w), 12)) + if k in seen: continue - seen.add(key) - unique_rows.append((label, energy, weight)) + seen.add(k) + unique.append((lbl, e, w)) - energies = np.asarray([row[1] for row in unique_rows], dtype=float) - weights = np.asarray([row[2] for row in unique_rows], dtype=float) - labels = [row[0] for row in unique_rows] + energies = np.asarray([e for _, e, _ in unique], dtype=float) + weights = np.asarray([w for _, _, w in unique], dtype=float) + labels = [lbl for lbl, _, _ in unique] return energies, weights, labels def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False): @@ -224,8 +271,14 @@ def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False) elements = list(self.model_elements.keys()) print(f"using model_elements {elements}") - energies, weights, labels = self.x_ray_lookup(elements) - energy_axis = np.arange(self.shape[0]) * self.sampling[0] + self.origin[0] + energies, _, labels = self.x_ray_lookup(elements) + energy_max = self.energy_axis.max() + energy_min = self.energy_axis.min() + ind = np.logical_and(energies > energy_min, energies < energy_max) + energies = energies[ind] + labels = [label for label, keep in zip(labels, ind) if keep] + + energy_axis = self.energy_axis.copy() energy_axis_2d = energy_axis[:, None] energies_2d = (energies)[None, :] @@ -249,119 +302,59 @@ def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False) def show_spectrum_images( self, x_ray_lines=None, return_fig=False, method="integration", **kwargs ): - """Plot cached spectrum-image maps from ``self._spectrum_images``. - - Parameters - ---------- - x_ray_lines : None | str | sequence[str], optional - Selection behavior: - - ``"Fe"``: all Fe lines - - ``"FeK"``: all Fe K-shell lines - - ``"FeKa1"``: specific line - - ``"FeKa"``: line family prefix (e.g., Ka1, Ka2) - method : ``"integration"`` | str - ``"integration"`` : plots maps based on integration method - ``"fit"`` : plots maps based on fitting method - **kwargs - Forwarded to :func:`quantem.core.visualization.show_2d`. - - Returns - ------- - tuple - ``(fig, axs)`` from ``show_2d``. - """ - if method == "integration": - spectrum_images = getattr(self, "_spectrum_images", None) - elif method == "fit": - spectrum_images = getattr(self, "_spectrum_images_pytorch", None) - else: + """Plot cached spectrum-image maps.""" + spectrum_images = ( + getattr(self, "_spectrum_images", None) + if method == "integration" + else getattr(self, "_spectrum_images_pytorch", None) + if method == "fit" + else None + ) + if spectrum_images is None: raise ValueError( - "Method {method}, is not supported, please choose 'integration' or 'fit'" + f"Method {method!r} is not supported, please choose 'integration' or 'fit'" ) - - if not isinstance(spectrum_images, dict) or len(spectrum_images) == 0: + if not isinstance(spectrum_images, dict) or not spectrum_images: raise ValueError("No spectrum images found. Run generage_spectrum_images(...) first.") - line_map = {str(label): np.asarray(image) for label, image in spectrum_images.items()} - if len(line_map) == 0: - raise ValueError("No spectrum images available to plot") + line_map = {str(k): np.asarray(v) for k, v in spectrum_images.items()} + labels = list(line_map) + labels_by_element = type(self)._group_labels_by_element(labels) - known_elements = type(self)._ordered_element_keys(type(self).element_info or {}) - - def _sum_maps(labels): - maps = [line_map[label] for label in labels] - return np.sum(np.stack(maps, axis=0), axis=0) - - lines_by_element = {} - for label in sorted(line_map.keys()): - element = type(self)._resolve_element_from_label(label, known_elements) - if element is None: - continue - lines_by_element.setdefault(element, []).append(label) - - normalized_label_map = {label.lower(): label for label in line_map.keys()} - normalized_element_map = {element.lower(): element for element in lines_by_element.keys()} + def _sum_maps(lbls): + return np.sum(np.stack([line_map[lbl] for lbl in lbls], axis=0), axis=0) specs = type(self)._normalize_specs(x_ray_lines, param_name="x_ray_lines", allow_none=True) - images_to_plot = [] - titles = [] - - if specs is None or len(specs) == 0: - for element in sorted(lines_by_element.keys()): - labels = lines_by_element[element] - if len(labels) == 0: - continue - images_to_plot.append(_sum_maps(labels)) - titles.append(element) + images, titles = [], [] + + if not specs: + for element in sorted(labels_by_element): + lbls = labels_by_element[element] + if lbls: + images.append(_sum_maps(lbls)) + titles.append(element) else: - for raw_spec in specs: - spec = str(raw_spec).strip() - if not spec: - continue - - label_key = normalized_label_map.get(spec.lower()) - if label_key is not None: - images_to_plot.append(line_map[label_key]) - titles.append(label_key) - continue - - element_key = normalized_element_map.get(spec.lower()) - if element_key is not None: - labels = lines_by_element[element_key] - images_to_plot.append(_sum_maps(labels)) - titles.append(element_key) - continue - - compact = type(self)._normalize_token(spec) - matched_labels = [ - label - for label in line_map.keys() - if type(self)._normalize_token(label).startswith(compact) - ] - if len(matched_labels) == 0: + for raw in specs: + selected = type(self)._select_labels( + str(raw), labels=labels, labels_by_element=labels_by_element + ) + if not selected: raise ValueError( - f"No spectrum images matched selector '{spec}'. " - f"Available examples: {', '.join(sorted(line_map.keys())[:10])}" + f"No spectrum images matched selector '{raw}'. " + f"Available examples: {', '.join(sorted(labels)[:10])}" ) - if len(matched_labels) == 1: - images_to_plot.append(line_map[matched_labels[0]]) - titles.append(matched_labels[0]) - else: - images_to_plot.append(_sum_maps(matched_labels)) - titles.append(spec) + images.append(line_map[selected[0]] if len(selected) == 1 else _sum_maps(selected)) + titles.append(selected[0] if len(selected) == 1 else str(raw).strip()) - if len(images_to_plot) == 0: + if not images: raise ValueError("No spectrum images selected for plotting") cmap = kwargs.pop("cmap", "magma") fig, ax = show_2d( - images_to_plot, + images, title=titles, cmap=cmap, - scalebar={ - "sampling": self.sampling[1], - "units": self.units[1], - }, + scalebar={"sampling": self.sampling[1], "units": self.units[1]}, returnfig=True, **kwargs, ) @@ -399,95 +392,61 @@ def _build_pytorch_spectrum_images( def quantify_composition_cliff_lorimer( self, k_factors, method="integration", return_maps=False ): - """Quantify composition from cached spectrum maps using Cliff-Lorimer. - - Parameters - ---------- - k_factors : dict - Mapping selector -> k-factor, where selectors are element+edge/line, e.g. - ``"AuL"``, ``"TeL"``,. - method : {"integration", "fit"} - Source of maps: - - ``"integration"`` uses ``self._spectrum_images`` - - ``"fit"`` uses ``self._spectrum_images_pytorch`` - return_maps : bool, optional - If ``True``, include per-selector and per-element maps plus map-based - atomic/weight percent outputs. - """ - if not isinstance(k_factors, dict) or len(k_factors) == 0: + """Quantify composition from cached spectrum maps using Cliff-Lorimer.""" + if not isinstance(k_factors, dict) or not k_factors: raise ValueError("k_factors must be a non-empty dict") - if method == "integration": - spectrum_images = getattr(self, "_spectrum_images", None) - elif method == "fit": - spectrum_images = getattr(self, "_spectrum_images_pytorch", None) - else: + spectrum_images = ( + getattr(self, "_spectrum_images", None) + if method == "integration" + else getattr(self, "_spectrum_images_pytorch", None) + if method == "fit" + else None + ) + if spectrum_images is None: raise ValueError( f"Method {method!r} is not supported, please choose 'integration' or 'fit'" ) - if not isinstance(spectrum_images, dict) or len(spectrum_images) == 0: + if not isinstance(spectrum_images, dict) or not spectrum_images: raise ValueError("No spectrum images available for quantification") - if type(self).element_info is None: - type(self).load_element_info() - + type(self)._ensure_element_info() ordered_elements = type(self)._ordered_element_keys(type(self).element_info or {}) - line_map = { - str(label): np.asarray(image, dtype=float) for label, image in spectrum_images.items() - } - normalized_label_map = {str(label).lower(): str(label) for label in line_map} - def _match_labels(selector): - token = str(selector).strip() - if not token: - return [] + line_map = {str(k): np.asarray(v, dtype=float) for k, v in spectrum_images.items()} + labels = list(line_map) + labels_by_element = type(self)._group_labels_by_element(labels) - exact = normalized_label_map.get(token.lower()) - if exact is not None: - return [exact] + def _match(selector: str) -> list[str]: + return type(self)._select_labels( + selector, labels=labels, labels_by_element=labels_by_element + ) + intensities: dict[str, float] = {} + weighted_intensities: dict[str, float] = {} + selector_maps = {} if return_maps else None + intensity_maps = {} if return_maps else None + weighted_intensity_maps = {} if return_maps else None + + for selector, k_raw in k_factors.items(): try: - _, _, labels_lookup = self.x_ray_lookup(token) - labels = [label for label in labels_lookup if label in line_map] - if len(labels) > 0: - return labels - except ValueError: - pass - - compact = type(self)._normalize_token(token) - return [ - label - for label in line_map - if type(self)._normalize_token(label).startswith(compact) - ] - - details = {} - intensities = {} - weighted_intensities = {} - selector_maps = {} - intensity_maps = {} - weighted_intensity_maps = {} - normalized_k = {} - - for selector, k_value_raw in k_factors.items(): - try: - k_value = float(k_value_raw) + k_val = float(k_raw) except (TypeError, ValueError): raise ValueError(f"k_factors[{selector!r}] must be numeric") - if not np.isfinite(k_value) or k_value <= 0: + if not np.isfinite(k_val) or k_val <= 0: raise ValueError(f"k_factors[{selector!r}] must be a positive finite number") - labels = _match_labels(selector) - if len(labels) == 0: + sel_labels = _match(str(selector).strip()) + if not sel_labels: raise ValueError( f"No spectrum images matched selector {selector!r}. " - f"Available examples: {', '.join(sorted(line_map.keys())[:10])}" + f"Available examples: {', '.join(sorted(labels)[:10])}" ) matched_elements = { - type(self)._resolve_element_from_label(label, ordered_elements) for label in labels + type(self)._resolve_element_from_label(lbl, ordered_elements) for lbl in sel_labels } - matched_elements = {elem for elem in matched_elements if elem is not None} + matched_elements = {e for e in matched_elements if e is not None} if len(matched_elements) != 1: raise ValueError( f"Selector {selector!r} matched multiple elements: {sorted(matched_elements)}. " @@ -495,61 +454,51 @@ def _match_labels(selector): ) element = next(iter(matched_elements)) - grouped_map = np.sum(np.stack([line_map[label] for label in labels], axis=0), axis=0) + grouped_map = np.sum(np.stack([line_map[lbl] for lbl in sel_labels], axis=0), axis=0) intensity = float(np.sum(grouped_map)) - weighted = float(k_value * intensity) - weighted_map = grouped_map * k_value - - selector_key = str(selector) - normalized_k[selector_key] = k_value - selector_maps[selector_key] = grouped_map - details[selector_key] = { - "element": element, - "labels": list(labels), - "intensity": intensity, - "k_factor": k_value, - "weighted_intensity": weighted, - } + weighted = float(k_val * intensity) intensities[element] = float(intensities.get(element, 0.0)) + intensity weighted_intensities[element] = ( float(weighted_intensities.get(element, 0.0)) + weighted ) - if element in intensity_maps: - intensity_maps[element] = intensity_maps[element] + grouped_map - weighted_intensity_maps[element] = weighted_intensity_maps[element] + weighted_map - else: - intensity_maps[element] = grouped_map.copy() - weighted_intensity_maps[element] = weighted_map.copy() + + if return_maps: + weighted_map = grouped_map * k_val + selector_maps[str(selector)] = grouped_map + if element in intensity_maps: + intensity_maps[element] = intensity_maps[element] + grouped_map + weighted_intensity_maps[element] = ( + weighted_intensity_maps[element] + weighted_map + ) + else: + intensity_maps[element] = grouped_map.copy() + weighted_intensity_maps[element] = weighted_map.copy() if len(weighted_intensities) < 2: raise ValueError("At least two elements are required for Cliff-Lorimer quantification") weighted_sum = float(sum(weighted_intensities.values())) atomic_percent = { - element: (100.0 * value / weighted_sum if weighted_sum > 0 else 0.0) - for element, value in weighted_intensities.items() + el: (100.0 * val / weighted_sum if weighted_sum > 0 else 0.0) + for el, val in weighted_intensities.items() } if type(self).atomic_weights is None: type(self).load_atomic_weights() atomic_weights = type(self).atomic_weights or {} - missing = [element for element in atomic_percent if element not in atomic_weights] + missing = [el for el in atomic_percent if el not in atomic_weights] if missing: raise ValueError(f"Atomic weights not found for elements: {missing}") weight_sum = sum( - (atomic_percent[element] / 100.0) * float(atomic_weights[element]) - for element in atomic_percent + (atomic_percent[el] / 100.0) * float(atomic_weights[el]) for el in atomic_percent ) weight_percent = { - element: ( - ((atomic_percent[element] / 100.0) * float(atomic_weights[element]) / weight_sum) - * 100.0 - if weight_sum > 0 - else 0.0 - ) - for element in atomic_percent + el: (((atomic_percent[el] / 100.0) * float(atomic_weights[el]) / weight_sum) * 100.0) + if weight_sum > 0 + else 0.0 + for el in atomic_percent } result = { @@ -558,34 +507,33 @@ def _match_labels(selector): "atomic_percent": atomic_percent, "weight_percent": weight_percent, } + if return_maps: weighted_stack = np.stack(list(weighted_intensity_maps.values()), axis=0) weighted_sum_map = np.sum(weighted_stack, axis=0) atomic_percent_maps = { - element: np.divide( - weighted_map * 100.0, + el: np.divide( + wmap * 100.0, weighted_sum_map, out=np.zeros_like(weighted_sum_map, dtype=float), where=weighted_sum_map > 0, ) - for element, weighted_map in weighted_intensity_maps.items() + for el, wmap in weighted_intensity_maps.items() } - mass_maps = { - element: (atomic_percent_maps[element] / 100.0) * float(atomic_weights[element]) - for element in atomic_percent_maps + el: (atomic_percent_maps[el] / 100.0) * float(atomic_weights[el]) + for el in atomic_percent_maps } mass_sum_map = np.sum(np.stack(list(mass_maps.values()), axis=0), axis=0) weight_percent_maps = { - element: np.divide( - mass_map * 100.0, + el: np.divide( + mmap * 100.0, mass_sum_map, out=np.zeros_like(mass_sum_map, dtype=float), where=mass_sum_map > 0, ) - for element, mass_map in mass_maps.items() + for el, mmap in mass_maps.items() } - result.update( { "selector_maps": selector_maps, @@ -627,78 +575,28 @@ def peak_autoid( If True, return full internal results (matches/confidence/peaks). If False (default), return only figure and axes. """ - if type(self).element_info is None: - type(self).load_element_info() + type(self)._ensure_element_info() if grid_peaks is None: grid_peaks = {} - if isinstance(elements, str): - elements = [elements] - if elements is not None and not isinstance(elements, (list, tuple, set)): - raise TypeError("elements must be None, a string, or a sequence of strings") - - def _line_matches_selector(line_name: str, selector: str) -> bool: - line = str(line_name).strip().lower() - token = str(selector).strip().lower() - if token in {"k", "l", "m"}: - return line.startswith(token) - return token in line - - def _parse_requested_elements_with_edges(element_specs): - if element_specs is None: - return None - - parsed = {} - for spec in element_specs: - raw = str(spec).strip() - if not raw: - continue - - tokens = raw.replace(",", " ").split() - if len(tokens) == 0: - continue - - element_key = str(tokens[0]) - selectors = [str(token).strip() for token in tokens[1:] if str(token).strip()] - - if element_key not in parsed: - parsed[element_key] = None if len(selectors) == 0 else set(selectors) - continue - - existing = parsed[element_key] - if existing is None: - continue - if len(selectors) == 0: - parsed[element_key] = None - else: - existing.update(selectors) - - return parsed if len(parsed) > 0 else None - - def _edge_filters_from_saved_model(model_elements): - if not isinstance(model_elements, dict) or len(model_elements) == 0: - return None - - parsed = {} - for element_name, lines_info in model_elements.items(): - element_key = str(element_name) - if isinstance(lines_info, dict) and len(lines_info) > 0: - parsed[element_key] = set(str(line_name) for line_name in lines_info.keys()) - else: - parsed[element_key] = None - - return parsed if len(parsed) > 0 else None - - requested_edge_filters = _parse_requested_elements_with_edges(elements) - saved_model_edge_filters = _edge_filters_from_saved_model( - getattr(self, "model_elements", None) + requested_edge_filters = type(self)._parse_element_selectors( + elements, allow_none=True, param_name="elements" ) + saved_model_edge_filters = { + str(k): (set(map(str, v.keys())) if isinstance(v, dict) and v else None) + for k, v in (getattr(self, "model_elements", {}) or {}).items() + } or None + using_saved_model_elements = False - if requested_edge_filters is None and elements is None: - if saved_model_edge_filters is not None: - requested_edge_filters = saved_model_edge_filters - using_saved_model_elements = True + if ( + requested_edge_filters is None + and elements is None + and saved_model_edge_filters is not None + ): + requested_edge_filters = saved_model_edge_filters + using_saved_model_elements = True + if requested_edge_filters is not None: elements = list(requested_edge_filters.keys()) @@ -737,7 +635,7 @@ def _edge_filters_from_saved_model(model_elements): E = E0 + dE * np.arange(self.shape[0]) if energy_range is not None: - indices = np.where((E >= energy_range[0]) & (E <= energy_range[1]))[0] + indices = np.where((energy_range[0] <= E) & (energy_range[1] >= E))[0] E = E[indices] if ignore_range is None: @@ -828,6 +726,13 @@ def _peak_confidence(snr_value, line_weight, distance_value): np.log1p(max(float(snr_value), 0.0)) * (0.5 + float(line_weight)) * (0.5 + quality) ) + def _line_matches_selector(line_name: str, selector: str) -> bool: + line = str(line_name).strip().lower() + token = str(selector).strip().lower() + if token in {"k", "l", "m"}: + return line.startswith(token) + return token in line + def _line_allowed_for_element(element_name, line_name, edge_filters=None): if edge_filters is None: return True @@ -876,10 +781,7 @@ def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): return best_element, best_line_name, best_line_weight, best_distance - if elements is not None: - search_elements = set(elements) - else: - search_elements = None + search_elements = set(elements) if elements is not None else None for peak_idx, height, peak_energy, snr in display_peaks: best_match_info = _best_line_match( @@ -1124,7 +1026,7 @@ def _format_saved_model_elements(edge_filters): return "None" formatted = [] - for element_name in sorted(str(name) for name in edge_filters.keys()): + for element_name in sorted(str(name) for name in edge_filters): selectors = edge_filters.get(element_name) if selectors is None or len(selectors) == 0: formatted.append(f"{element_name} [all]") @@ -1725,7 +1627,13 @@ def fit_spectrum_mean_pytorch( if device.type == "cuda" and not torch.cuda.is_available(): raise ValueError("CUDA device requested but torch.cuda.is_available() is False.") - energy_axis_np = np.arange(self.shape[0]) * self.sampling[0] + self.origin[0] + if elements_to_fit is None: + if self.model_elements is None: + raise ValueError("elements_to_fit must be specified") + elements_to_fit = list(self.model_elements.keys()) + print(f"using model_elements {elements_to_fit}") + + energy_axis_np = self.energy_axis.copy() energy_axis = torch.tensor(energy_axis_np, dtype=torch.float32, device=device) spectra = torch.tensor(self.array, dtype=torch.float32, device=device) @@ -1881,7 +1789,7 @@ def fit_spectrum_pytorch( If ``None``, the full energy axis is used. elements_to_fit : sequence[str] | None, optional Element symbols (or model-supported element labels) to include in the - fit. If ``None``, all supported elements from the model are considered. + fit. If ``None``, uses keys from ``self.model_elements``. peak_width : float, optional Initial peak width (FWHM-like parameter in keV) for model peaks. num_iters : int, optional @@ -1995,6 +1903,13 @@ def _normalize_choice(name, param_name, allowed_values): if background_prior_lambda < 0: raise ValueError("constrain_background must be >= 0") + if elements_to_fit is None: + if self.model_elements is None: + raise ValueError("elements_to_fit must be specified") + elements_to_fit = list(self.model_elements.keys()) + if verbose: + print(f"using model_elements {elements_to_fit}") + if device is None: device = torch.device("cuda" if torch.cuda.is_available() else "cpu") else: @@ -2005,7 +1920,7 @@ def _normalize_choice(name, param_name, allowed_values): effective_lr_global = lr_global effective_lr_local = lr_local - energy_axis_np = np.arange(self.shape[0]) * self.sampling[0] + self.origin[0] + energy_axis_np = self.energy_axis.copy() energy_axis = torch.tensor(energy_axis_np, dtype=torch.float32, device=device) spectra = torch.tensor(self.array, dtype=torch.float32, device=device) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 8b1f5a24..79c84077 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -1013,5 +1013,7 @@ def subtract_background( else: print(f"Notice: no spectrum recorded to attached_spectra in {self}") - -Dataset3dspectroscopy.load_element_info() + @property + def energy_axis(self): + energy_axis = np.arange(self.shape[0]) * self.sampling[0] + self.origin[0] + return energy_axis From 59322df68fb7de81c2e34360955bd6550cb2934b Mon Sep 17 00:00:00 2001 From: smribet Date: Sat, 7 Mar 2026 06:17:16 -0800 Subject: [PATCH 073/136] CL adding table --- src/quantem/spectroscopy/dataset3deds.py | 42 ++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 66b89c32..c2451fce 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -390,9 +390,25 @@ def _build_pytorch_spectrum_images( return line_maps def quantify_composition_cliff_lorimer( - self, k_factors, method="integration", return_maps=False + self, + k_factors, + method="integration", + return_maps=False, + verbose=True, ): - """Quantify composition from cached spectrum maps using Cliff-Lorimer.""" + """Quantify composition from cached spectrum maps using Cliff-Lorimer. + + Parameters + ---------- + k_factors : dict + Mapping of selector -> k-factor. + method : {"integration", "fit"}, optional + Source map set to use. + return_maps : bool, optional + If True, include per-element/per-selector map outputs. + verbose : bool, optional + If True, print a small scalar text table (no maps). + """ if not isinstance(k_factors, dict) or not k_factors: raise ValueError("k_factors must be a non-empty dict") @@ -508,6 +524,28 @@ def _match(selector: str) -> list[str]: "weight_percent": weight_percent, } + ordered_elements = sorted( + weighted_intensities.keys(), + key=lambda element_name: weighted_intensities[element_name], + reverse=True, + ) + table_lines = [ + "Element Intensity Weighted Intensity Atomic % Weight %", + "------- ------------- -------------------- ---------- ----------", + ] + for element_name in ordered_elements: + table_lines.append( + f"{element_name:<7} " + f"{intensities[element_name]:>13.3f} " + f"{weighted_intensities[element_name]:>20.3f} " + f"{atomic_percent[element_name]:>10.3f} " + f"{weight_percent[element_name]:>10.3f}" + ) + table_text = "\n".join(table_lines) + result["summary_table"] = table_text + if verbose: + print(table_text) + if return_maps: weighted_stack = np.stack(list(weighted_intensity_maps.values()), axis=0) weighted_sum_map = np.sum(weighted_stack, axis=0) From c8679528f536f926644e27ac795802ddef67a080 Mon Sep 17 00:00:00 2001 From: smribet Date: Sat, 7 Mar 2026 06:43:59 -0800 Subject: [PATCH 074/136] clear spectrum images, update history size for lbfgs --- src/quantem/spectroscopy/dataset3deds.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index c2451fce..73b9313e 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -584,6 +584,12 @@ def _match(selector: str) -> list[str]: return result + def clear_spectrum_images(self): + if self._spectrum_images is not None: + self._spectrum_images = {} + if self._spectrum_images_pytorch is not None: + self._spectrum_images = {} + def peak_autoid( self, roi=None, @@ -2184,7 +2190,6 @@ def _normalize_choice(name, param_name, allowed_values): trainable_params, lr=local_lr, line_search_fn="strong_wolfe", - history_size=10, ) loss_history = [] From 96927e55b5a8ff1e6c53c37a1bdcd67c5d9d3005 Mon Sep 17 00:00:00 2001 From: smribet Date: Sat, 7 Mar 2026 06:46:58 -0800 Subject: [PATCH 075/136] add --- src/quantem/spectroscopy/dataset3deds.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 73b9313e..3b051176 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -587,6 +587,8 @@ def _match(selector: str) -> list[str]: def clear_spectrum_images(self): if self._spectrum_images is not None: self._spectrum_images = {} + + def clear_spectrum_images_pytorch(self): if self._spectrum_images_pytorch is not None: self._spectrum_images = {} From dbfb639da4e2fdb1cd2615097807640e661f493d Mon Sep 17 00:00:00 2001 From: smribet Date: Sat, 7 Mar 2026 06:55:20 -0800 Subject: [PATCH 076/136] cleaning up show_mean_spectrum --- src/quantem/spectroscopy/dataset3deds.py | 11 --- .../spectroscopy/dataset3dspectroscopy.py | 82 +------------------ 2 files changed, 2 insertions(+), 91 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 3b051176..ae6548e8 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -659,19 +659,8 @@ def peak_autoid( fig, (ax_img, ax_spec) = self.show_mean_spectrum( roi=roi, energy_range=energy_range, - elements=elements, - ignore_range=ignore_range, - threshold=threshold, - tolerance=tolerance, mask=mask, - show_lines=True, - show_text=show_text, - snr_min=snr_min, - snr_threshold=snr_threshold, - distance_threshold_for_sample=distance_threshold_for_sample, - grid_peaks=grid_peaks, data_type="eds", - peaks=peaks, show=False, ) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 79c84077..00ed8dfa 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -607,19 +607,8 @@ def show_mean_spectrum( self, roi=None, energy_range=None, - elements=None, - ignore_range=None, - threshold=5.0, - tolerance=0.15, mask=None, - show_lines=True, - show_text=True, - snr_min=None, - snr_threshold=None, - distance_threshold_for_sample=0.05, - grid_peaks=None, data_type="eds", - peaks=15, show=True, ): """ @@ -638,8 +627,6 @@ def show_mean_spectrum( If roi=None, uses full image. Can also be [y, x] for single pixel. energy_range : list or tuple, optional Energy range to display as [min_energy, max_energy] in keV. - ignore_range : list or tuple, optional - Ignored in this plotting-only method. Kept for backward compatibility. mask : array, optional Boolean mask for pixel selection. show : bool, optional @@ -677,7 +664,7 @@ def show_mean_spectrum( # CALCULATE MEAN SPECTRUM FOR GIVEN ROI AND ENERGY RANGE -------------------------- - spec = self.calculate_mean_spectrum(roi, energy_range, ignore_range, mask) + spec = self.calculate_mean_spectrum(roi=roi, energy_range=energy_range, mask=mask) dE = float(self.sampling[0]) E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 @@ -687,10 +674,6 @@ def show_mean_spectrum( indices = np.where((E >= energy_range[0]) & (E <= energy_range[1]))[0] E = E[indices] - # Store ignore_range for later use in element line filtering - if ignore_range is None: - ignore_range = [0, 0.25] # Default: ignore 0-0.25 keV for element lines only - # PLOTTING --------------------------------------------------------------------------- # Create subplot layout: image on left, spectrum on right @@ -723,8 +706,7 @@ def show_mean_spectrum( plt.colorbar(im, ax=ax_img) # RIGHT PLOT: Show spectrum - (spectrum_line,) = ax_spec.plot(E, spec, linewidth=1.5) - spectrum_color = spectrum_line.get_color() + ax_spec.plot(E, spec, linewidth=1.5) if data_type == "eds": ax_spec.set_xlabel("Energy (keV)") else: @@ -733,66 +715,6 @@ def show_mean_spectrum( ax_spec.set_title(f"Spectrum from ROI [{y}:{y + dy}, {x}:{x + dx}]") ax_spec.grid(True, alpha=0.1) - if show_lines and isinstance(self.model_elements, dict) and len(self.model_elements) > 0: - x_min = float(np.nanmin(E)) if E.size > 0 else None - x_max = float(np.nanmax(E)) if E.size > 0 else None - model_marker_energies = [] - - energy_keys = ( - "energy (keV)", - "energy_keV", - "energy (eV)", - "onset (eV)", - "edge (eV)", - "energy", - ) - - for _, lines_info in self.model_elements.items(): - if not isinstance(lines_info, dict): - continue - - for _, line_info in lines_info.items(): - if not isinstance(line_info, dict): - continue - - line_energy = None - for key in energy_keys: - if key in line_info: - try: - line_energy = float(line_info[key]) - break - except (TypeError, ValueError): - continue - - if line_energy is None: - continue - if x_min is not None and (line_energy < x_min or line_energy > x_max): - continue - - model_marker_energies.append(line_energy) - - if len(model_marker_energies) > 0: - marker_x = np.unique(np.asarray(model_marker_energies, dtype=float)) - y_min = float(np.nanmin(spec)) if spec.size > 0 else 0.0 - y_max = float(np.nanmax(spec)) if spec.size > 0 else 1.0 - y_scale = max(y_max - y_min, 1e-12) - y_dot = y_min - 0.04 * y_scale - - ax_spec.plot( - marker_x, - np.full(marker_x.shape, y_dot, dtype=float), - marker="o", - markersize=2.5, - color=spectrum_color, - alpha=0.5, - linestyle="None", - zorder=5, - ) - - current_bottom, current_top = ax_spec.get_ylim() - dot_padding = 0.02 * y_scale - ax_spec.set_ylim(bottom=min(current_bottom, y_dot - dot_padding), top=current_top) - fig.tight_layout() if show: plt.show() From 72553fefd3767237a160e3b76ef490577a9a5489 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Tue, 10 Mar 2026 11:56:33 -0700 Subject: [PATCH 077/136] first chunk of powerlaw bg fit for eels and modified function call in dataset3dspectroscopy --- src/quantem/spectroscopy/dataset3deels.py | 38 ++++++++++++++----- .../spectroscopy/dataset3dspectroscopy.py | 7 +++- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index 8777f91a..e347a7bb 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -1,13 +1,10 @@ from typing import Any import numpy as np - +from numpy.typing import NDArray from scipy.interpolate import interp1d - from scipy.ndimage import median_filter -from numpy.typing import NDArray - from quantem.spectroscopy import Dataset3dspectroscopy @@ -112,13 +109,38 @@ def calculate_background_iterative(self, spectrum): return background_fit + def powerlaw_backgroundfit_eels(self, spectrum, energy_range, target_edge): + dE = float(self.sampling[0]) + E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 + E = E0 + dE * np.arange(self.shape[0]) + + if energy_range is not None: + energy_range[0] = np.maximum(energy_range[0], E[0]) + energy_range[1] = np.minimum(energy_range[1], E[-1]) + + indices = np.where((E >= energy_range[0]) & (E <= energy_range[1]))[0] + E = E[indices] + else: + indices = np.arange(self.shape[0]) + + # Check that the target edge is within the energy range of the spectrum + # and that a pre-edge region of size at least 10% of the target edge, ending 5 eV before the target edge + # exists for pre-edge fitting. + + if target_edge < E[0] or target_edge > E[-1]: + raise ValueError("Target edge is outside of energy range.") + elif ((target_edge - 5) - target_edge * 0.1) < E[0]: + raise ValueError( + "Insufficient pre-edge background fitting region for this target within given energy range" + ) + def calibrate_zero_loss_peak(self, center_guess=None, search_window=10): """ Calibrate the energy axis by centering the zero loss peak at 0 eV. Finds the ZLP at every pixel, fits a 2D plane to the ZLP positions, and shifts each spectrum individually so the ZLP sits at 0, while aligning - all ZLPs to the same channel index, allowing a single origin to correctly - calibrate the entire dataset. + all ZLPs to the same channel index, allowing a single origin to correctly + calibrate the entire dataset. Parameters ---------- @@ -174,9 +196,7 @@ def calibrate_zero_loss_peak(self, center_guess=None, search_window=10): # This smooths out noisy per-pixel ZLP measurements by assuming # the drift varies linearly across the scan area. - y_coords, x_coords = np.meshgrid( - np.arange(n_y), np.arange(n_x), indexing="ij" - ) + y_coords, x_coords = np.meshgrid(np.arange(n_y), np.arange(n_x), indexing="ij") y_flat = y_coords.ravel() x_flat = x_coords.ravel() z_flat = zlp_map.ravel() diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 00ed8dfa..2fe3e937 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -839,7 +839,9 @@ def subtract_background( energy_range=None, ignore_range=None, mask=None, + target_edge=None, data_type="eds", + method="powerlaw", return_dataset=True, attach_spectrum=True, ): @@ -864,7 +866,10 @@ def subtract_background( if data_type == "eds": background = self.calculate_background_powerlaw(spec) elif data_type == "eels": - background = self.calculate_background_iterative(spec) + if method == "powerlaw": + background = self.powerlaw_backgroundfit_eels(spec, energy_range, target_edge) + elif method == "iterative": + background = self.calculate_background_iterative(spec) subtracted_mean_spectrum = np.maximum(spec - background, 0) From 1726386f93c117afa232c07fb36330fa81120ea3 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Tue, 10 Mar 2026 15:40:10 -0700 Subject: [PATCH 078/136] user specified pre-edge window size option for eels bg fit (powerlaw) --- src/quantem/spectroscopy/dataset3deels.py | 34 +++++++++++++++++-- .../spectroscopy/dataset3dspectroscopy.py | 5 ++- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index e347a7bb..f44b9e08 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -4,6 +4,7 @@ from numpy.typing import NDArray from scipy.interpolate import interp1d from scipy.ndimage import median_filter +from scipy.optimize import curve_fit from quantem.spectroscopy import Dataset3dspectroscopy @@ -109,7 +110,12 @@ def calculate_background_iterative(self, spectrum): return background_fit - def powerlaw_backgroundfit_eels(self, spectrum, energy_range, target_edge): + def powerlaw_backgroundfit_eels(self, spectrum, energy_range, target_edge, window_size): + """ + Using a window of the energy axis preceding the target edge, fit a power law function to use for background subtraction. + The input window size should be 10-30% of the target edge energy. + """ + dE = float(self.sampling[0]) E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 E = E0 + dE * np.arange(self.shape[0]) @@ -123,17 +129,39 @@ def powerlaw_backgroundfit_eels(self, spectrum, energy_range, target_edge): else: indices = np.arange(self.shape[0]) + # Check that input window size is between 10% and 30% + + if window_size < 10 or window_size > 30: + raise ValueError("Invalid window size. Please input a value of between 10 and 30.") + # Check that the target edge is within the energy range of the spectrum # and that a pre-edge region of size at least 10% of the target edge, ending 5 eV before the target edge # exists for pre-edge fitting. if target_edge < E[0] or target_edge > E[-1]: raise ValueError("Target edge is outside of energy range.") - elif ((target_edge - 5) - target_edge * 0.1) < E[0]: + elif ((target_edge - 5) - target_edge * (window_size / 100)) < E[0]: raise ValueError( - "Insufficient pre-edge background fitting region for this target within given energy range" + "Insufficient pre-edge background fitting region for this target edge and window size within given energy range." ) + # Fit power law function to spectrum within window region of the energy exis + + window_minE = (target_edge - 5) - target_edge * (window_size / 100) + window_maxE = target_edge - 5 + + window_indices = np.where((E >= window_minE) & E <= window_maxE)[0] + + window_E = E[window_indices] + window_I = spectrum[window_indices] + + def powerlaw_function(E, A, r): + return A * (E ^ (-r)) + + background_fit = curve_fit(powerlaw_function, window_E, window_I) + + return background_fit + def calibrate_zero_loss_peak(self, center_guess=None, search_window=10): """ Calibrate the energy axis by centering the zero loss peak at 0 eV. diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 2fe3e937..c1bca9d6 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -840,6 +840,7 @@ def subtract_background( ignore_range=None, mask=None, target_edge=None, + window_size=10, data_type="eds", method="powerlaw", return_dataset=True, @@ -867,7 +868,9 @@ def subtract_background( background = self.calculate_background_powerlaw(spec) elif data_type == "eels": if method == "powerlaw": - background = self.powerlaw_backgroundfit_eels(spec, energy_range, target_edge) + background = self.powerlaw_backgroundfit_eels( + spec, energy_range, target_edge, window_size + ) elif method == "iterative": background = self.calculate_background_iterative(spec) From f53f0f936ee72fa4aaac8cecf2563becdcb8efb7 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Wed, 11 Mar 2026 13:21:04 -0700 Subject: [PATCH 079/136] fixing background curve fitting/plotting for eels --- src/quantem/spectroscopy/dataset3deels.py | 44 ++++++++++++++++------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index f44b9e08..49cca597 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -1,5 +1,6 @@ from typing import Any +import matplotlib.pyplot as plt import numpy as np from numpy.typing import NDArray from scipy.interpolate import interp1d @@ -118,14 +119,16 @@ def powerlaw_backgroundfit_eels(self, spectrum, energy_range, target_edge, windo dE = float(self.sampling[0]) E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 - E = E0 + dE * np.arange(self.shape[0]) + energy_axis = E0 + dE * np.arange(self.shape[0]) if energy_range is not None: - energy_range[0] = np.maximum(energy_range[0], E[0]) - energy_range[1] = np.minimum(energy_range[1], E[-1]) + energy_range[0] = np.maximum(energy_range[0], energy_axis[0]) + energy_range[1] = np.minimum(energy_range[1], energy_axis[-1]) - indices = np.where((E >= energy_range[0]) & (E <= energy_range[1]))[0] - E = E[indices] + indices = np.where( + (energy_axis >= energy_range[0]) & (energy_axis <= energy_range[1]) + )[0] + energy_axis = energy_axis[indices] else: indices = np.arange(self.shape[0]) @@ -138,9 +141,9 @@ def powerlaw_backgroundfit_eels(self, spectrum, energy_range, target_edge, windo # and that a pre-edge region of size at least 10% of the target edge, ending 5 eV before the target edge # exists for pre-edge fitting. - if target_edge < E[0] or target_edge > E[-1]: + if target_edge < energy_axis[0] or target_edge > energy_axis[-1]: raise ValueError("Target edge is outside of energy range.") - elif ((target_edge - 5) - target_edge * (window_size / 100)) < E[0]: + elif ((target_edge - 5) - target_edge * (window_size / 100)) < energy_axis[0]: raise ValueError( "Insufficient pre-edge background fitting region for this target edge and window size within given energy range." ) @@ -150,15 +153,32 @@ def powerlaw_backgroundfit_eels(self, spectrum, energy_range, target_edge, windo window_minE = (target_edge - 5) - target_edge * (window_size / 100) window_maxE = target_edge - 5 - window_indices = np.where((E >= window_minE) & E <= window_maxE)[0] + window_indices = np.where((energy_axis >= window_minE) & (energy_axis <= window_maxE))[0] - window_E = E[window_indices] + window_E = energy_axis[window_indices] window_I = spectrum[window_indices] def powerlaw_function(E, A, r): - return A * (E ^ (-r)) - - background_fit = curve_fit(powerlaw_function, window_E, window_I) + return A * (E ** (-r)) + + popt, _ = curve_fit(powerlaw_function, window_E, window_I) + background_fit = powerlaw_function(energy_axis, popt[0], popt[1]) + + # Plot the region of the spectrum between user-specified energy range, overlaid with the background fit curve, with background estimation + # window boundaries indicated + + fig, ax = plt.subplots() + ax.plot(energy_axis, spectrum, label="spectrum", color="b") + ax.plot(energy_axis, background_fit, label="background", color="r") + ax.vlines( + x=[window_minE, window_maxE], + ymin=0, + ymax=np.max(spectrum), + label="window limits", + color="k", + linestyle="dashed", + ) + ax.legend() return background_fit From 01db03358ba4b8082226cf60979119bbb3a919f3 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Wed, 11 Mar 2026 14:16:27 -0700 Subject: [PATCH 080/136] increase max iterations for curve fit --- src/quantem/spectroscopy/dataset3deels.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index 49cca597..16ca3a4c 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -161,7 +161,7 @@ def powerlaw_backgroundfit_eels(self, spectrum, energy_range, target_edge, windo def powerlaw_function(E, A, r): return A * (E ** (-r)) - popt, _ = curve_fit(powerlaw_function, window_E, window_I) + popt, _ = curve_fit(powerlaw_function, window_E, window_I, maxfev=2000) background_fit = powerlaw_function(energy_axis, popt[0], popt[1]) # Plot the region of the spectrum between user-specified energy range, overlaid with the background fit curve, with background estimation From 3e8b72d621e7526a36aba537a5daf9862aa308e8 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Fri, 13 Mar 2026 11:18:10 -0700 Subject: [PATCH 081/136] draft EELS pca denoising function --- src/quantem/spectroscopy/dataset3deels.py | 74 +++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index 16ca3a4c..f8774edf 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -6,6 +6,7 @@ from scipy.interpolate import interp1d from scipy.ndimage import median_filter from scipy.optimize import curve_fit +from sklearn.decomposition import PCA from quantem.spectroscopy import Dataset3dspectroscopy @@ -182,6 +183,79 @@ def powerlaw_function(E, A, r): return background_fit + def smooth_eels_pca(self, roi=None, energy_range=None, ignore_range=None, mask=None): + pca = PCA(n_components=2) + # kpca = KernelPCA(n_components=10, kernel='rbf', gamma=50, fit_inverse_transform=True) + + # #test on mean spectrum + # spec = self.calculate_mean_spectrum(roi, energy_range, ignore_range, mask) + + dE = float(self.sampling[0]) + E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 + energy_axis = E0 + dE * np.arange(self.shape[0]) + + # if energy_range is not None: + # energy_range[0] = np.maximum(energy_range[0], energy_axis[0]) + # energy_range[1] = np.minimum(energy_range[1], energy_axis[-1]) + + # indices = np.where( + # (energy_axis >= energy_range[0]) & (energy_axis <= energy_range[1]) + # )[0] + # energy_axis = energy_axis[indices] + # else: + # indices = np.arange(self.shape[0]) + + # Try denoising on 2D SI images + + # transformed_array3d = np.empty([self.array.shape[0], self.array.shape[1], self.array.shape[2]], dtype=float) + + # for kk in range(self.array.shape[1]): + # spec_image = self.array[:,kk,:] + # # array2d_transformed = pca.fit(spec_image) + # # array2d_smoothed = pca.inverse_transform(array2d_transformed) + # array2d_transformed = kpca.fit_transform(spec_image) + # array2d_smoothed = kpca.inverse_transform(array2d_transformed) + # transformed_array3d[:,kk,:] = array2d_smoothed + + # Reduce 3D dataset to two dimensions + + # array2d = self.array.reshape(self.array.shape[0],self.array.shape[1]*self.array.shape[2]) + # array2d_transformed = kpca.fit_transform(array2d) + # array2d_smoothed = kpca.inverse_transform(array2d_transformed) + + array2d = self.array.reshape( + self.array.shape[0], self.array.shape[1] * self.array.shape[2] + ) + pca.fit(array2d) + variance = pca.explained_variance_ + + array2d_smoothed = pca.inverse_transform(pca.transform(array2d)) + array3d_smoothed = array2d_smoothed.reshape(self.array.shape) + + fig, scree = plt.subplots() + values = np.arange(len(variance)) + 1 + scree.plot(values, variance, label="Scree plot", marker="o") + scree.legend() + + smoothed_data3d = Dataset3deels.from_array( + array=array3d_smoothed, + sampling=self.sampling, + origin=self.origin, + units=self.units, + ) + + mean_spectrum_raw = self.calculate_mean_spectrum(roi, energy_range, ignore_range, mask) + mean_spectrum_smoothed = smoothed_data3d.calculate_mean_spectrum( + roi, energy_range, ignore_range, mask + ) + + fig, ax = plt.subplots() + ax.plot(energy_axis, mean_spectrum_raw, label="raw spectrum", color="b") + ax.plot(energy_axis, mean_spectrum_smoothed, label="kpca-fit spectrum", color="r") + ax.legend() + + return smoothed_data3d + def calibrate_zero_loss_peak(self, center_guess=None, search_window=10): """ Calibrate the energy axis by centering the zero loss peak at 0 eV. From e4f1478cb8983ce9e1bb78f8191ea89d0029daec Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Fri, 13 Mar 2026 11:57:43 -0700 Subject: [PATCH 082/136] make pca denoising user energy_range compatible --- src/quantem/spectroscopy/dataset3deels.py | 35 ++++++++++++----------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index f8774edf..f6d56117 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -187,23 +187,23 @@ def smooth_eels_pca(self, roi=None, energy_range=None, ignore_range=None, mask=N pca = PCA(n_components=2) # kpca = KernelPCA(n_components=10, kernel='rbf', gamma=50, fit_inverse_transform=True) - # #test on mean spectrum - # spec = self.calculate_mean_spectrum(roi, energy_range, ignore_range, mask) - dE = float(self.sampling[0]) E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 energy_axis = E0 + dE * np.arange(self.shape[0]) - # if energy_range is not None: - # energy_range[0] = np.maximum(energy_range[0], energy_axis[0]) - # energy_range[1] = np.minimum(energy_range[1], energy_axis[-1]) + if energy_range is not None: + energy_range[0] = np.maximum(energy_range[0], energy_axis[0]) + energy_range[1] = np.minimum(energy_range[1], energy_axis[-1]) - # indices = np.where( - # (energy_axis >= energy_range[0]) & (energy_axis <= energy_range[1]) - # )[0] - # energy_axis = energy_axis[indices] - # else: - # indices = np.arange(self.shape[0]) + indices = np.where( + (energy_axis >= energy_range[0]) & (energy_axis <= energy_range[1]) + )[0] + energy_axis = energy_axis[indices] + else: + indices = np.arange(self.shape[0]) + + array3d_subrange = self.array[indices, :, :] + print(array3d_subrange.shape) # Try denoising on 2D SI images @@ -223,14 +223,17 @@ def smooth_eels_pca(self, roi=None, energy_range=None, ignore_range=None, mask=N # array2d_transformed = kpca.fit_transform(array2d) # array2d_smoothed = kpca.inverse_transform(array2d_transformed) - array2d = self.array.reshape( - self.array.shape[0], self.array.shape[1] * self.array.shape[2] + array2d = array3d_subrange.reshape( + energy_axis.shape[0], array3d_subrange.shape[1] * array3d_subrange.shape[2] ) pca.fit(array2d) variance = pca.explained_variance_ array2d_smoothed = pca.inverse_transform(pca.transform(array2d)) - array3d_smoothed = array2d_smoothed.reshape(self.array.shape) + array3d_smoothed = array2d_smoothed.reshape( + energy_axis.shape[0], array3d_subrange.shape[1], array3d_subrange.shape[2] + ) + print(array3d_smoothed.shape) fig, scree = plt.subplots() values = np.arange(len(variance)) + 1 @@ -240,7 +243,7 @@ def smooth_eels_pca(self, roi=None, energy_range=None, ignore_range=None, mask=N smoothed_data3d = Dataset3deels.from_array( array=array3d_smoothed, sampling=self.sampling, - origin=self.origin, + origin=energy_axis[0], units=self.units, ) From e3cd8279f84b6bf6d73aec25fa7f4c6135553829 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Fri, 13 Mar 2026 11:59:19 -0700 Subject: [PATCH 083/136] some cleanup --- src/quantem/spectroscopy/dataset3deels.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index f6d56117..95cd78ad 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -254,7 +254,7 @@ def smooth_eels_pca(self, roi=None, energy_range=None, ignore_range=None, mask=N fig, ax = plt.subplots() ax.plot(energy_axis, mean_spectrum_raw, label="raw spectrum", color="b") - ax.plot(energy_axis, mean_spectrum_smoothed, label="kpca-fit spectrum", color="r") + ax.plot(energy_axis, mean_spectrum_smoothed, label="pca-fit spectrum", color="r") ax.legend() return smoothed_data3d From 2f5633ef0dfacf1e4f52ebc6fb3bafbe8e4c75c6 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Wed, 18 Mar 2026 14:32:39 -0700 Subject: [PATCH 084/136] rolling average smoothing function for eels --- src/quantem/spectroscopy/dataset3deels.py | 67 +++++++++++++++++++++-- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index 95cd78ad..2e17a7af 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -183,9 +183,61 @@ def powerlaw_function(E, A, r): return background_fit + def smooth_eels_rollingaverage( + self, roi=None, energy_range=None, ignore_range=None, mask=None, kernel_size=10 + ): + dE = float(self.sampling[0]) + E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 + energy_axis = E0 + dE * np.arange(self.shape[0]) + + if energy_range is not None: + energy_range[0] = np.maximum(energy_range[0], energy_axis[0]) + energy_range[1] = np.minimum(energy_range[1], energy_axis[-1]) + + indices = np.where( + (energy_axis >= energy_range[0]) & (energy_axis <= energy_range[1]) + )[0] + energy_axis = energy_axis[indices] + else: + indices = np.arange(self.shape[0]) + + array3d_subrange = self.array[indices, :, :] + + kernel = np.ones(kernel_size) / kernel_size + + # For each probe position, convolve spectral data with smoothing kernel + + array3d_smoothed = np.zeros(array3d_subrange.shape) + + for kk in range(array3d_subrange.shape[1]): + for ll in range(array3d_subrange.shape[2]): + probe_spectrum = self.array[:, kk, ll] + spectrum_smoothed = np.convolve(probe_spectrum, kernel, mode="same") + array3d_smoothed[:, kk, ll] = spectrum_smoothed + + smoothed_data3d = Dataset3deels.from_array( + array=array3d_smoothed, + sampling=self.sampling, + origin=energy_axis[0], + units=self.units, + ) + + # Plot raw and smoothed mean spectra on the same set of axes + + mean_spectrum_raw = self.calculate_mean_spectrum(roi, energy_range, ignore_range, mask) + mean_spectrum_smoothed = smoothed_data3d.calculate_mean_spectrum( + roi, energy_range, ignore_range, mask + ) + + fig, ax = plt.subplots() + ax.plot(energy_axis, mean_spectrum_raw, label="raw spectrum", color="b") + ax.plot(energy_axis, mean_spectrum_smoothed, label="kernel-smoothed spectrum", color="r") + ax.legend() + + return smoothed_data3d + def smooth_eels_pca(self, roi=None, energy_range=None, ignore_range=None, mask=None): pca = PCA(n_components=2) - # kpca = KernelPCA(n_components=10, kernel='rbf', gamma=50, fit_inverse_transform=True) dE = float(self.sampling[0]) E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 @@ -203,7 +255,6 @@ def smooth_eels_pca(self, roi=None, energy_range=None, ignore_range=None, mask=N indices = np.arange(self.shape[0]) array3d_subrange = self.array[indices, :, :] - print(array3d_subrange.shape) # Try denoising on 2D SI images @@ -220,26 +271,28 @@ def smooth_eels_pca(self, roi=None, energy_range=None, ignore_range=None, mask=N # Reduce 3D dataset to two dimensions # array2d = self.array.reshape(self.array.shape[0],self.array.shape[1]*self.array.shape[2]) - # array2d_transformed = kpca.fit_transform(array2d) - # array2d_smoothed = kpca.inverse_transform(array2d_transformed) array2d = array3d_subrange.reshape( energy_axis.shape[0], array3d_subrange.shape[1] * array3d_subrange.shape[2] ) + pca.fit(array2d) variance = pca.explained_variance_ - array2d_smoothed = pca.inverse_transform(pca.transform(array2d)) + array2d_smoothed = pca.inverse_transform(pca.fit_transform(array2d)) array3d_smoothed = array2d_smoothed.reshape( energy_axis.shape[0], array3d_subrange.shape[1], array3d_subrange.shape[2] ) - print(array3d_smoothed.shape) + + # Generate PCA scree plot fig, scree = plt.subplots() values = np.arange(len(variance)) + 1 scree.plot(values, variance, label="Scree plot", marker="o") scree.legend() + # Write PCA-smooted data to new Dataset3deels object + smoothed_data3d = Dataset3deels.from_array( array=array3d_smoothed, sampling=self.sampling, @@ -247,6 +300,8 @@ def smooth_eels_pca(self, roi=None, energy_range=None, ignore_range=None, mask=N units=self.units, ) + # Plot raw and smoothed mean spectra on the same set of axes + mean_spectrum_raw = self.calculate_mean_spectrum(roi, energy_range, ignore_range, mask) mean_spectrum_smoothed = smoothed_data3d.calculate_mean_spectrum( roi, energy_range, ignore_range, mask From a3d1746390c67742236c7ce2acef9c92220a0df1 Mon Sep 17 00:00:00 2001 From: smribet Date: Thu, 19 Mar 2026 09:46:21 -0700 Subject: [PATCH 085/136] remove sklearn and replace with pytorch pca --- src/quantem/spectroscopy/dataset3deels.py | 10 +--- .../spectroscopy/dataset3dspectroscopy.py | 56 +++++++++++++++---- 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index 2e17a7af..3b23f119 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -6,9 +6,8 @@ from scipy.interpolate import interp1d from scipy.ndimage import median_filter from scipy.optimize import curve_fit -from sklearn.decomposition import PCA -from quantem.spectroscopy import Dataset3dspectroscopy +from quantem.spectroscopy.dataset3dspectroscopy import Dataset3dspectroscopy, _run_pca class Dataset3deels(Dataset3dspectroscopy): @@ -237,8 +236,6 @@ def smooth_eels_rollingaverage( return smoothed_data3d def smooth_eels_pca(self, roi=None, energy_range=None, ignore_range=None, mask=None): - pca = PCA(n_components=2) - dE = float(self.sampling[0]) E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 energy_axis = E0 + dE * np.arange(self.shape[0]) @@ -276,10 +273,7 @@ def smooth_eels_pca(self, roi=None, energy_range=None, ignore_range=None, mask=N energy_axis.shape[0], array3d_subrange.shape[1] * array3d_subrange.shape[2] ) - pca.fit(array2d) - variance = pca.explained_variance_ - - array2d_smoothed = pca.inverse_transform(pca.fit_transform(array2d)) + _, _, variance, _, array2d_smoothed = _run_pca(array2d, 2) array3d_smoothed = array2d_smoothed.reshape( energy_axis.shape[0], array3d_subrange.shape[1], array3d_subrange.shape[2] ) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index c1bca9d6..0c91827e 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -5,14 +5,41 @@ import matplotlib.pyplot as plt import numpy as np +import torch from matplotlib.patches import Rectangle from numpy.typing import NDArray -from sklearn.decomposition import PCA from quantem.core.datastructures.dataset3d import Dataset3d from quantem.spectroscopy.utils import load_xray_lines_database +def _run_pca(data: NDArray | Any, n_components: int): + array = np.asarray(data, dtype=float) + n_samples, n_features = array.shape + max_components = min(n_samples, n_features) + if not 1 <= n_components <= max_components: + raise ValueError(f"n_components={n_components} must be between 1 and {max_components}") + + mean = np.mean(array, axis=0) + centered = torch.as_tensor(array - mean, dtype=torch.float64) + _, s, vh = torch.linalg.svd(centered, full_matrices=False) + + components = vh[:n_components].cpu().numpy() + loadings = (centered @ vh[:n_components].T).cpu().numpy() + + denom = max(n_samples - 1, 1) + explained_variance = ((s[:n_components] ** 2) / denom).cpu().numpy() + total_variance = torch.sum((s**2) / denom).item() + explained_variance_ratio = ( + explained_variance / total_variance + if total_variance > 0 + else np.zeros_like(explained_variance) + ) + reconstructed = loadings @ components + mean + + return components, loadings, explained_variance, explained_variance_ratio, reconstructed + + class Dataset3dspectroscopy(Dataset3d): # stores the element line info so you don't need to reload each time element_info = None @@ -337,15 +364,18 @@ def perform_pca( data_processed = data_masked # Perform PCA - pca = PCA(n_components=n_components, random_state=random_state) - loadings = pca.fit_transform(data_processed) # (n_pixels, n_components) - components = pca.components_ # (n_components, n_energy) + del random_state + ( + components, + loadings, + explained_variance, + explained_variance_ratio, + reconstructed, + ) = _run_pca(data_processed, n_components) # Reconstruct data if standardize: - reconstructed = pca.inverse_transform(loadings) * std + mean - else: - reconstructed = pca.inverse_transform(loadings) + reconstructed = reconstructed * std + mean if mask is None: loadings_spatial = loadings.T.reshape(n_components, ny, nx) @@ -358,16 +388,20 @@ def perform_pca( self._plot_pca_results( components, loadings_spatial, - pca.explained_variance_ratio_, + explained_variance_ratio, n_show=min(4, n_components), ) return { - "pca": pca, + "pca": { + "components_": components, + "explained_variance_": explained_variance, + "explained_variance_ratio_": explained_variance_ratio, + }, "components": components, "loadings": loadings_spatial, - "explained_variance_ratio": pca.explained_variance_ratio_, - "explained_variance": pca.explained_variance_, + "explained_variance_ratio": explained_variance_ratio, + "explained_variance": explained_variance, "reconstructed": reconstructed.T.reshape(n_energy, ny, nx) if mask is None else reconstructed, From 25d3dc0d24c5c1f48acbe15ac6b89cc981ec21b3 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Thu, 19 Mar 2026 11:04:19 -0700 Subject: [PATCH 086/136] remove pca redundancy, 3d dataset output from perform_pca, and cleanup --- src/quantem/spectroscopy/dataset3deds.py | 1 + .../spectroscopy/dataset3dspectroscopy.py | 37 +++++++++++++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index ae6548e8..d1f08576 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -70,6 +70,7 @@ def __init__( signal_units=signal_units, _token=_token, ) + self.dataset_type = "EDS" @staticmethod def _normalize_specs(specs, param_name="spec", allow_none=False): diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 0c91827e..e247b062 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -271,19 +271,19 @@ def clear_model_elements(self): def add_spectrum_to_data(self, spectrum, energy_axis): """ - Store processed spectra in the 3D spectroscopy dataset structure, in a 1D array of 2D arrays. By default, calculate_mean_spectrum will + Store processed spectra in the 3D spectroscopy dataset structure, in a 1D array of 2D arrays. By default, calculate_mean_spectrum will store spectrum at first available index """ from quantem.core.datastructures.dataset1d import Dataset1d - two_d_spectrum = Dataset1d.from_array( + one_d_spectrum = Dataset1d.from_array( array=spectrum, origin=energy_axis[0], sampling=self.sampling[0], units=self.units[0] ) if self.attached_spectra is not None: - self.attached_spectra.append(two_d_spectrum) + self.attached_spectra.append(one_d_spectrum) else: self.attached_spectra = [] - self.attached_spectra.append(two_d_spectrum) + self.attached_spectra.append(one_d_spectrum) def clear_attached_spectra(self): self.attached_spectra = None @@ -341,8 +341,16 @@ def perform_pca( - 'components': principal component spectra (n_components x n_energy) - 'loadings': spatial loadings (n_components x n_pixels) - 'explained_variance_ratio': explained variance for each component - - 'reconstructed': reconstructed dataset using n_components + - 'reconstructed': reconstructed dataset (dataset3dspectroscopy) using n_components """ + + from quantem.spectroscopy import ( + Dataset3deds as Dataset3deds, + ) + from quantem.spectroscopy import ( + Dataset3deels as Dataset3deels, + ) + data = np.asarray(self.array, dtype=float) n_energy, ny, nx = data.shape @@ -392,6 +400,21 @@ def perform_pca( n_show=min(4, n_components), ) + if self.dataset_type == "EDS": + reconstructed_data3d = Dataset3deds.from_array( + array=reconstructed.T.reshape(n_energy, ny, nx), + sampling=self.sampling, + origin=self.origin, + units=self.units, + ) + elif self.dataset_type == "EELS": + reconstructed_data3d = Dataset3deels.from_array( + array=reconstructed.T.reshape(n_energy, ny, nx), + sampling=self.sampling, + origin=self.origin, + units=self.units, + ) + return { "pca": { "components_": components, @@ -402,9 +425,7 @@ def perform_pca( "loadings": loadings_spatial, "explained_variance_ratio": explained_variance_ratio, "explained_variance": explained_variance, - "reconstructed": reconstructed.T.reshape(n_energy, ny, nx) - if mask is None - else reconstructed, + "reconstructed": reconstructed_data3d if mask is None else reconstructed_data3d, } def _plot_pca_results( From 3c65fdb02252bf09acfb6f7c5346e6a12d80b6a3 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Thu, 19 Mar 2026 11:26:30 -0700 Subject: [PATCH 087/136] remove smooth_eels_pca --- src/quantem/spectroscopy/dataset3deels.py | 75 +---------------------- 1 file changed, 1 insertion(+), 74 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index 3b23f119..b2ae959c 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -7,7 +7,7 @@ from scipy.ndimage import median_filter from scipy.optimize import curve_fit -from quantem.spectroscopy.dataset3dspectroscopy import Dataset3dspectroscopy, _run_pca +from quantem.spectroscopy.dataset3dspectroscopy import Dataset3dspectroscopy class Dataset3deels(Dataset3dspectroscopy): @@ -235,79 +235,6 @@ def smooth_eels_rollingaverage( return smoothed_data3d - def smooth_eels_pca(self, roi=None, energy_range=None, ignore_range=None, mask=None): - dE = float(self.sampling[0]) - E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 - energy_axis = E0 + dE * np.arange(self.shape[0]) - - if energy_range is not None: - energy_range[0] = np.maximum(energy_range[0], energy_axis[0]) - energy_range[1] = np.minimum(energy_range[1], energy_axis[-1]) - - indices = np.where( - (energy_axis >= energy_range[0]) & (energy_axis <= energy_range[1]) - )[0] - energy_axis = energy_axis[indices] - else: - indices = np.arange(self.shape[0]) - - array3d_subrange = self.array[indices, :, :] - - # Try denoising on 2D SI images - - # transformed_array3d = np.empty([self.array.shape[0], self.array.shape[1], self.array.shape[2]], dtype=float) - - # for kk in range(self.array.shape[1]): - # spec_image = self.array[:,kk,:] - # # array2d_transformed = pca.fit(spec_image) - # # array2d_smoothed = pca.inverse_transform(array2d_transformed) - # array2d_transformed = kpca.fit_transform(spec_image) - # array2d_smoothed = kpca.inverse_transform(array2d_transformed) - # transformed_array3d[:,kk,:] = array2d_smoothed - - # Reduce 3D dataset to two dimensions - - # array2d = self.array.reshape(self.array.shape[0],self.array.shape[1]*self.array.shape[2]) - - array2d = array3d_subrange.reshape( - energy_axis.shape[0], array3d_subrange.shape[1] * array3d_subrange.shape[2] - ) - - _, _, variance, _, array2d_smoothed = _run_pca(array2d, 2) - array3d_smoothed = array2d_smoothed.reshape( - energy_axis.shape[0], array3d_subrange.shape[1], array3d_subrange.shape[2] - ) - - # Generate PCA scree plot - - fig, scree = plt.subplots() - values = np.arange(len(variance)) + 1 - scree.plot(values, variance, label="Scree plot", marker="o") - scree.legend() - - # Write PCA-smooted data to new Dataset3deels object - - smoothed_data3d = Dataset3deels.from_array( - array=array3d_smoothed, - sampling=self.sampling, - origin=energy_axis[0], - units=self.units, - ) - - # Plot raw and smoothed mean spectra on the same set of axes - - mean_spectrum_raw = self.calculate_mean_spectrum(roi, energy_range, ignore_range, mask) - mean_spectrum_smoothed = smoothed_data3d.calculate_mean_spectrum( - roi, energy_range, ignore_range, mask - ) - - fig, ax = plt.subplots() - ax.plot(energy_axis, mean_spectrum_raw, label="raw spectrum", color="b") - ax.plot(energy_axis, mean_spectrum_smoothed, label="pca-fit spectrum", color="r") - ax.legend() - - return smoothed_data3d - def calibrate_zero_loss_peak(self, center_guess=None, search_window=10): """ Calibrate the energy axis by centering the zero loss peak at 0 eV. From cc00c10fa108bc1b844ab7ad51b4089ad7321e36 Mon Sep 17 00:00:00 2001 From: Sangoda <87961379+Sangoda@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:02:29 -0700 Subject: [PATCH 088/136] test --- src/quantem/spectroscopy/dataset3deds.py | 767 ++++++++++++++---- .../spectroscopy/dataset3dspectroscopy.py | 409 ++++++++-- 2 files changed, 912 insertions(+), 264 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index d1f08576..a5cb0898 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -5,6 +5,7 @@ import numpy as np import torch import torch.nn as nn +from matplotlib.lines import Line2D from numpy.typing import NDArray from scipy.signal import find_peaks @@ -70,7 +71,6 @@ def __init__( signal_units=signal_units, _token=_token, ) - self.dataset_type = "EDS" @staticmethod def _normalize_specs(specs, param_name="spec", allow_none=False): @@ -208,6 +208,45 @@ def _select_labels( compact = cls._normalize_token(sel) return [lbl for lbl in labels if cls._normalize_token(lbl).startswith(compact)] + @staticmethod + def _line_shell(line_name: str) -> str: + line_upper = str(line_name).upper() + if line_upper.startswith("K"): + return "K" + if line_upper.startswith("L"): + return "L" + if line_upper.startswith("M"): + return "M" + return "?" + + @staticmethod + def _peak_confidence( + snr_value: float, line_weight: float, distance_value: float, tolerance: float + ) -> float: + quality = max(0.0, 1.0 - (distance_value / max(float(tolerance), 1e-9))) + return np.log1p(max(float(snr_value), 0.0)) * (0.5 + float(line_weight)) * ( + 0.5 + quality + ) + + @staticmethod + def _line_matches_selector(line_name: str, selector: str) -> bool: + line = str(line_name).strip().lower() + token = str(selector).strip().lower() + if token in {"k", "l", "m"}: + return line.startswith(token) + return token in line + + @classmethod + def _line_allowed_for_element( + cls, element_name: str, line_name: str, edge_filters=None + ) -> bool: + if edge_filters is None: + return True + selectors = edge_filters.get(str(element_name)) + if selectors is None: + return True + return any(cls._line_matches_selector(line_name, token) for token in selectors) + def x_ray_lookup( self, spec: str | list[str] | tuple[str, ...] | set[str] ) -> tuple[np.ndarray, np.ndarray, list[str]]: @@ -265,7 +304,7 @@ def x_ray_lookup( labels = [lbl for lbl, _, _ in unique] return energies, weights, labels - def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False): + def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False, show=True): if elements is None: if self.model_elements is None: raise ValueError("elements must be specified") @@ -295,11 +334,109 @@ def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False) existing = getattr(self, "_spectrum_images", {}) self._spectrum_images = {**existing, **dict(zip(labels, maps))} - self.show_spectrum_images() + if show: + self.show_spectrum_images(x_ray_lines=elements) if return_maps: return maps, labels + def Integrate(self, spec, width=0.15, return_maps=False, show=True, **kwargs): + """Integrate selected X-ray lines and return combined map(s). + + This method builds per-selector energy masks (union of line windows) + and integrates the dataset over those masks. For single-selector + plotting it reuses ``show_energy_window_map`` with the computed mask. + + Parameters + ---------- + spec : str | list[str] | tuple[str, ...] | set[str] + Selector(s) like ``"Au"``, ``"AuK"``, ``"AuKa1"``. + Element selectors (e.g. ``"Au"``) integrate all available lines. + width : float, optional + Half-width in keV for line-window integration, by default 0.15. + return_maps : bool, optional + If True, always return ``dict[selector, map]``. + If False and a single selector is provided, return only that 2D map. + show : bool, optional + If True, display the integrated map(s). + """ + try: + width = float(width) + except (TypeError, ValueError): + raise ValueError("width must be a positive finite number") + if not np.isfinite(width) or width <= 0: + raise ValueError("width must be a positive finite number") + + specs = type(self)._normalize_specs(spec, param_name="spec") + arr = np.asarray(self.array, dtype=float) + energy_axis = np.asarray(self.energy_axis, dtype=float) + energy_min = float(np.min(energy_axis)) + energy_max = float(np.max(energy_axis)) + + integrated_maps = {} + selector_masks = {} + for raw in specs: + selector = str(raw).strip() + line_energies, _, _ = self.x_ray_lookup(selector) + in_range = np.logical_and(line_energies >= energy_min, line_energies <= energy_max) + line_energies = np.asarray(line_energies[in_range], dtype=float) + if line_energies.size == 0: + raise ValueError( + f"No X-ray lines for selector '{selector}' are within the dataset energy range" + ) + + selector_mask = np.zeros(energy_axis.shape, dtype=bool) + for line_energy in line_energies: + selector_mask |= np.logical_and( + energy_axis >= (float(line_energy) - width), + energy_axis <= (float(line_energy) + width), + ) + + if not np.any(selector_mask): + raise ValueError( + f"No energy channels selected for selector '{selector}'. " + "Try increasing width." + ) + + selector_masks[selector] = selector_mask + integrated_maps[selector] = arr[selector_mask, :, :].sum(axis=0) + + if show: + if len(integrated_maps) == 1: + selector = next(iter(integrated_maps.keys())) + self.show_energy_window_map( + energy_window=[energy_min, energy_max], + roi=kwargs.pop("roi", None), + roi_px=kwargs.pop("roi_px", None), + roi_cal=kwargs.pop("roi_cal", None), + mask=selector_masks[selector], + data_type=kwargs.pop("data_type", "eds"), + cmap=kwargs.pop("cmap", "magma"), + show=True, + ) + else: + show_2d( + list(integrated_maps.values()), + title=list(integrated_maps.keys()), + cmap=kwargs.pop("cmap", "magma"), + scalebar={"sampling": self.sampling[1], "units": self.units[1]}, + **kwargs, + ) + + if return_maps or len(integrated_maps) != 1: + return integrated_maps + return next(iter(integrated_maps.values())) + + def integrate(self, spec, width=0.15, return_maps=False, show=True, **kwargs): + """Lowercase alias for :meth:`Integrate`.""" + return self.Integrate( + spec=spec, + width=width, + return_maps=return_maps, + show=show, + **kwargs, + ) + def show_spectrum_images( self, x_ray_lines=None, return_fig=False, method="integration", **kwargs ): @@ -596,8 +733,11 @@ def clear_spectrum_images_pytorch(self): def peak_autoid( self, roi=None, + roi_px=None, + roi_cal=None, energy_range=None, elements=None, + refline=None, ignore_elements=None, ignore_range=None, threshold=5.0, @@ -635,14 +775,21 @@ def peak_autoid( for k, v in (getattr(self, "model_elements", {}) or {}).items() } or None - using_saved_model_elements = False - if ( - requested_edge_filters is None - and elements is None - and saved_model_edge_filters is not None - ): + if saved_model_edge_filters is not None and requested_edge_filters is not None: + merged_edge_filters = dict(saved_model_edge_filters) + for element_name, selectors in requested_edge_filters.items(): + if element_name not in merged_edge_filters: + merged_edge_filters[element_name] = selectors + continue + + existing = merged_edge_filters[element_name] + if existing is None or selectors is None: + merged_edge_filters[element_name] = None + else: + merged_edge_filters[element_name] = set(existing).union(set(selectors)) + requested_edge_filters = merged_edge_filters + elif requested_edge_filters is None and saved_model_edge_filters is not None: requested_edge_filters = saved_model_edge_filters - using_saved_model_elements = True if requested_edge_filters is not None: elements = list(requested_edge_filters.keys()) @@ -659,13 +806,22 @@ def peak_autoid( fig, (ax_img, ax_spec) = self.show_mean_spectrum( roi=roi, + roi_px=roi_px, + roi_cal=roi_cal, energy_range=energy_range, mask=mask, data_type="eds", show=False, ) - spec = self.calculate_mean_spectrum(roi, energy_range, ignore_range, mask) + spec = self.calculate_mean_spectrum( + roi=roi, + roi_px=roi_px, + roi_cal=roi_cal, + energy_range=energy_range, + ignore_range=ignore_range, + mask=mask, + ) dE = float(self.sampling[0]) E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 E = E0 + dE * np.arange(self.shape[0]) @@ -691,36 +847,48 @@ def peak_autoid( initial_snrs.append(height / background_std) if len(initial_snrs) > 0: - # snr_median = float(np.nanmedian(initial_snrs)) - snr_75th = float(np.nanpercentile(initial_snrs, 75)) - num_high_snr_peaks = int(np.sum(np.array(initial_snrs) > 50)) + snr_values = np.asarray(initial_snrs, dtype=float) + snr_values = snr_values[np.isfinite(snr_values)] else: - # snr_median = 0.0 - snr_75th = 0.0 - num_high_snr_peaks = 0 + snr_values = np.asarray([], dtype=float) if snr_min is None: - if len(initial_snrs) > 0: - snr_values = np.asarray(initial_snrs, dtype=float) - target_rank = max(1, min(int(peaks), int(snr_values.size))) - kth_largest_snr = float(np.sort(snr_values)[-target_rank]) - distribution_cutoff = float(np.percentile(snr_values, 55)) - adaptive_cutoff = min(distribution_cutoff, 0.9 * kth_largest_snr) - min_snr = float(np.clip(adaptive_cutoff, 8.0, 20.0)) + if snr_values.size > 0: + sorted_snrs = np.sort(snr_values) + target_rank = int(np.clip(2 * int(peaks), 12, 64)) + target_rank = min(target_rank, int(sorted_snrs.size)) + rank_cutoff = float(sorted_snrs[-target_rank]) + q30 = float(np.percentile(sorted_snrs, 30)) + q40 = float(np.percentile(sorted_snrs, 40)) + q50 = float(np.percentile(sorted_snrs, 50)) + adaptive_cutoff = min(q50, max(q30, 0.35 * rank_cutoff, 0.9 * q40)) + # Keep the display threshold permissive so moderate-SNR peaks remain visible. + min_snr = float(np.clip(adaptive_cutoff, 7.0, 14.0)) else: min_snr = 8.0 else: min_snr = float(snr_min) if snr_threshold is None: - if num_high_snr_peaks > 50: - snr_threshold_for_sample = min(80.0, snr_75th * 1.2) - elif num_high_snr_peaks > 20: - snr_threshold_for_sample = min(60.0, snr_75th * 1.1) - elif num_high_snr_peaks < 10: - snr_threshold_for_sample = max(30.0, snr_75th * 0.8) + if snr_values.size > 0: + high_snr_pool = snr_values[snr_values >= min_snr] + if high_snr_pool.size == 0: + high_snr_pool = snr_values + sorted_high = np.sort(high_snr_pool)[::-1] + anchor_count = int(np.clip(int(peaks), 10, 40)) + anchor_count = min(anchor_count, int(sorted_high.size)) + anchor_pool = sorted_high[:anchor_count] + anchor_median = float(np.percentile(anchor_pool, 50)) + anchor_q75 = float(np.percentile(anchor_pool, 75)) + anchor_q90 = float(np.percentile(anchor_pool, 90)) + adaptive_threshold = max(anchor_median, 0.7 * anchor_q75, 2.5 * min_snr) + # Anchor to the strongest displayed-peak regime so defaults do not + # collapse toward the low-SNR bulk when peak counts are large. + snr_threshold_for_sample = float( + np.clip(adaptive_threshold, max(2.5 * min_snr, min_snr), anchor_q90) + ) else: - snr_threshold_for_sample = 40.0 + snr_threshold_for_sample = max(4.0 * min_snr, 30.0) else: snr_threshold_for_sample = float(snr_threshold) @@ -874,6 +1042,7 @@ def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): "strong_matches": 0, "match_count": 0, "best_match_conf": 0.0, + "best_match_snr": 0.0, "best_match_energy": 0.0, "best_match_distance": float("inf"), "best_match_weight": 0.0, @@ -889,6 +1058,7 @@ def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): if float(match_confidence) > float(element_stats[element_name]["best_match_conf"]): element_stats[element_name]["best_match_conf"] = float(match_confidence) + element_stats[element_name]["best_match_snr"] = float(snr) element_stats[element_name]["best_match_energy"] = float(peak_energy) element_stats[element_name]["best_match_distance"] = float(distance) element_stats[element_name]["best_match_weight"] = float(line_weight) @@ -918,11 +1088,19 @@ def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): stats = element_stats[element_name] valid_shells = {shell for shell in stats["shells"] if shell in {"K", "L", "M"}} has_major_shell = len(valid_shells.intersection({"K", "L"})) > 0 - is_supported = confidence >= confidence_cutoff + is_supported = ( + confidence >= confidence_cutoff + and ( + stats["strong_matches"] >= 1 + or stats["best_match_snr"] + >= max(min_snr, 0.6 * snr_threshold_for_sample) + ) + ) is_near_cutoff_but_consistent = ( confidence >= 0.75 * confidence_cutoff and stats["match_count"] >= 2 and has_major_shell + and stats["best_match_snr"] >= max(min_snr, 0.5 * snr_threshold_for_sample) ) is_high_energy_singleton_anchor = ( stats["match_count"] == 1 @@ -931,11 +1109,20 @@ def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): and stats["best_match_distance"] <= 0.35 * tolerance and confidence >= 0.45 * confidence_cutoff ) + is_high_quality_singleton = ( + stats["match_count"] == 1 + and has_major_shell + and stats["best_match_snr"] >= max(min_snr, 0.6 * snr_threshold_for_sample) + and stats["best_match_weight"] >= 0.5 + and stats["best_match_distance"] <= 0.30 * tolerance + and confidence >= 0.35 * confidence_cutoff + ) if ( is_supported or is_near_cutoff_but_consistent or is_high_energy_singleton_anchor + or is_high_quality_singleton ): detected_elements.add(element_name) @@ -1000,6 +1187,91 @@ def _best_supported_line_match_with_prior( return best_tuple + def _top_supported_line_matches_with_prior( + peak_energy, snr, allowed_elements, edge_filters=None, top_k=3 + ): + if not all_info or top_k <= 0: + return [] + + scored_matches = [] + denom = max(float(max_detected_conf), 1e-9) + + for element_name, lines in all_info.items(): + if allowed_elements is not None and element_name not in allowed_elements: + continue + + prior = float(element_confidence.get(element_name, 0.0)) / denom + prior_factor = 1.0 + 0.5 * prior + + for line_name, line_info in lines.items(): + if not _line_allowed_for_element(element_name, line_name, edge_filters): + continue + line_energy = line_info["energy (keV)"] + line_weight = line_info.get("weight", 0.5) + distance = abs(peak_energy - line_energy) + shell = _line_shell(line_name) + + is_m_line = "M" in line_name and not ("Ma" in line_name or "Mb" in line_name) + effective_tolerance = tolerance * 0.5 if is_m_line else tolerance + + if line_weight <= 0.3 or distance > effective_tolerance: + continue + + local_conf = _peak_confidence(snr, line_weight, distance) + anchor_boost = 1.0 + if element_name in anchor_elements and shell == "M" and peak_energy <= 3.0: + anchor_boost = 2.2 + elif element_name in anchor_elements and shell in {"K", "L"}: + anchor_boost = 1.15 + + score = local_conf * prior_factor * anchor_boost + scored_matches.append( + (float(score), str(element_name), str(line_name), float(line_weight), float(distance)) + ) + + scored_matches.sort(key=lambda item: item[0], reverse=True) + unique = [] + seen_labels = set() + for score, element_name, line_name, line_weight, distance in scored_matches: + label = f"{element_name} {line_name}" + if label in seen_labels: + continue + seen_labels.add(label) + unique.append((element_name, line_name, line_weight, distance, score)) + + if len(unique) == 0: + return [] + + # Keep the top-scoring best match first, then prefer alternatives + # from different elements before falling back to same-element lines. + best_match = unique[0] + selected = [best_match] + selected_labels = {f"{best_match[0]} {best_match[1]}"} + used_elements = {str(best_match[0])} + + for candidate in unique[1:]: + element_name, line_name, _, _, _ = candidate + label = f"{element_name} {line_name}" + if label in selected_labels or str(element_name) in used_elements: + continue + selected.append(candidate) + selected_labels.add(label) + used_elements.add(str(element_name)) + if len(selected) >= int(top_k): + return selected + + for candidate in unique[1:]: + element_name, line_name, _, _, _ = candidate + label = f"{element_name} {line_name}" + if label in selected_labels: + continue + selected.append(candidate) + selected_labels.add(label) + if len(selected) >= int(top_k): + break + + return selected + for peak_idx, height, peak_energy, snr in display_peaks: match = raw_match_by_idx.get(int(peak_idx)) @@ -1085,11 +1357,7 @@ def _format_elements_with_lines(element_names): formatted.append(str(element_name)) return ", ".join(formatted) - model_elements_header = ( - "Saved Model Elements (Plotted):\n" - if using_saved_model_elements - else "Saved Model Elements (Not Plotted When Elements Specified):\n" - ) + model_elements_header = "Saved Model Elements (Plotted):\n" print( f"\n{model_elements_header} {_format_saved_model_elements(saved_model_edge_filters)}" ) @@ -1137,14 +1405,56 @@ def _format_elements_with_lines(element_names): table_rows = [] matched_row_count = 0 + + def _mark_autodetected_label(label_text): + if not label_text or label_text == "-": + return label_text + element_token = str(label_text).split()[0] + if element_token in detected_elements: + return f"{label_text}*" + return label_text + for peak_idx, height, peak_energy, snr in display_peaks: match = refined_match_by_idx.get(int(peak_idx)) if match is not None: - table_rows.append((peak_energy, height, snr, str(match[5]))) + if search_elements is not None: + allowed_for_table = set(str(element_name) for element_name in search_elements) + else: + allowed_for_table = { + str(element_name) + for element_name in (all_info or {}).keys() + if str(element_name) not in ignored_elements + } + if len(allowed_for_table) == 0: + allowed_for_table = None + + top_matches = _top_supported_line_matches_with_prior( + peak_energy, + snr, + allowed_for_table, + requested_edge_filters, + top_k=3, + ) + best_match = _mark_autodetected_label(str(match[5])) + alt_2 = "-" + alt_3 = "-" + + if len(top_matches) > 0: + ranked_labels = [ + f"{element_name} {line_name}" + for element_name, line_name, _, _, _ in top_matches + ] + best_match = _mark_autodetected_label(ranked_labels[0]) + if len(ranked_labels) > 1: + alt_2 = _mark_autodetected_label(ranked_labels[1]) + if len(ranked_labels) > 2: + alt_3 = _mark_autodetected_label(ranked_labels[2]) + + table_rows.append((peak_energy, height, snr, best_match, alt_2, alt_3)) matched_row_count += 1 else: row_label = "Unmatched" if search_elements is not None else "Unknown" - table_rows.append((peak_energy, height, snr, row_label)) + table_rows.append((peak_energy, height, snr, row_label, "-", "-")) sorted_table_rows = sorted(table_rows, key=lambda item: item[0]) @@ -1222,6 +1532,7 @@ def _infer_requested_element_for_color(peak_energy): for peak_idx, height, peak_energy, snr in display_peaks: is_sample = detected_sample_peaks.get(peak_energy, False) match = refined_match_by_idx.get(int(peak_idx)) + is_possible = match is not None and str(match[4]) not in detected_elements if match is not None: peak_element = match[4] line_color = element_color_map.get(peak_element, "red") @@ -1240,6 +1551,14 @@ def _infer_requested_element_for_color(peak_energy): alpha=0.5, linewidth=1.5, ) + elif is_possible: + ax_spec.axvline( + peak_energy, + color=line_color, + linestyle="--", + alpha=0.45, + linewidth=1.25, + ) else: ax_spec.plot( [peak_energy], @@ -1278,10 +1597,13 @@ def _infer_requested_element_for_color(peak_energy): # If elements were explicitly requested, overlay reference X-ray lines from the # database even when they are not peak-matched by auto-id. - dotted_reference_rows = [] + all_label_candidates = [] if search_elements is not None: energy_min = float(np.min(E)) energy_max = float(np.max(E)) + displayed_peak_energies = [float(peak_energy) for _, _, peak_energy, _ in display_peaks] + display_peak_tolerance = max(0.05, 0.5 * tolerance) + possible_elements_set = set(str(element_name) for element_name in candidate_elements) reference_label_counts = {} existing_matches_by_element = {} for ( @@ -1301,11 +1623,12 @@ def _infer_requested_element_for_color(peak_energy): existing_matches_by_element[element_key] = [] existing_matches_by_element[element_key].append(float(peak_energy)) - y_top = float(np.nanmax(spec)) if len(spec) > 0 else 1.0 - y_top = max(y_top, 1.0) - for element_name in sorted(search_elements): element_key = str(element_name) + is_reference_only = ( + element_key not in detected_elements + and element_key not in possible_elements_set + ) lines_info = all_info.get(element_key, {}) if all_info is not None else {} if not isinstance(lines_info, dict) or len(lines_info) == 0: continue @@ -1346,6 +1669,16 @@ def _infer_requested_element_for_color(peak_energy): )[:6] for line_name, line_energy, line_weight in filtered_lines: + # For possible elements, draw guides only near peaks that already + # passed snr_min. For reference-only requested elements, keep + # explicit refline guides even without nearby peaks. + if not is_reference_only: + if not any( + abs(line_energy - peak_energy) <= display_peak_tolerance + for peak_energy in displayed_peak_energies + ): + continue + matched_energies = existing_matches_by_element.get(element_key, []) if any( abs(line_energy - matched_energy) <= max(0.05, 0.5 * tolerance) @@ -1354,30 +1687,37 @@ def _infer_requested_element_for_color(peak_energy): continue line_color = element_color_map.get(element_key, "black") + line_style = ":" if is_reference_only else "--" + line_alpha = 0.35 if is_reference_only else 0.3 ax_spec.axvline( line_energy, color=line_color, - linestyle="--", - alpha=0.3, + linestyle=line_style, + alpha=line_alpha, linewidth=1.2, ) - dotted_reference_rows.append((element_key, str(line_name), float(line_energy))) label_index = reference_label_counts.get(element_key, 0) reference_label_counts[element_key] = label_index + 1 - y_label = y_top * (0.95 - 0.05 * (label_index % 3)) - ax_spec.text( - line_energy, - y_label, - f"{element_key} {line_name}", - ha="center", - va="bottom", - fontsize=8, - color=line_color, - rotation=90, - alpha=0.8, + # Keep dashed reference labels inside the axes near the top border. + y_label_axes = 0.98 - 0.06 * (label_index % 3) + all_label_candidates.append( + ( + float(line_energy), + f"{element_key} {line_name}", + line_color, + line_style, + float(y_label_axes), + "axes_top", + 8, + "normal", + 0.8, + ) ) - if detected_elements: + legend_handles = [] + legend_labels = set() + + if show_text and peak_matches: labels_to_plot = [] for ( peak_idx, @@ -1391,142 +1731,221 @@ def _infer_requested_element_for_color(peak_energy): line_weight, match_confidence, ) in peak_matches: - if element_name in detected_elements and detected_sample_peaks.get( + is_detected_peak = element_name in detected_elements and detected_sample_peaks.get( peak_energy, False - ): - line_name = match_str.split()[-1] if match_str else "" - label_text = f"{element_name} {line_name}" if line_name else element_name - color = element_color_map.get(element_name, "black") - labels_to_plot.append((peak_energy, label_text, color, height)) - - labels_to_plot.sort(key=lambda item: item[0]) - - if show_text: - # Merge labels that are too close in energy into a single line of text - # to avoid unreadable overlap. - overlap_threshold = max(0.10, 0.7 * float(tolerance)) - same_label_overlap_threshold = max(0.16, 1.1 * float(tolerance)) - same_color_overlap_threshold = max(0.22, 1.6 * float(tolerance)) - grouped_labels = [] - current_group = [] - - def _same_color(c1, c2): - try: - return np.allclose(np.asarray(c1), np.asarray(c2)) - except Exception: - return str(c1) == str(c2) + ) + is_possible_peak = element_name not in detected_elements + if not (is_detected_peak or is_possible_peak): + continue - for label in labels_to_plot: - if not current_group: - current_group.append(label) - continue + line_name = match_str.split()[-1] if match_str else "" + label_text = f"{element_name} {line_name}" if line_name else element_name + if is_detected_peak: + label_text = f"{label_text}*" + color = element_color_map.get(element_name, "black") + linestyle = "-" if is_detected_peak else "--" + labels_to_plot.append((peak_energy, label_text, color, height, linestyle)) + + label_vertical_offset = max(0.03 * y_scale, 0.08) + for peak_energy, label_text, color, height, linestyle in labels_to_plot: + if linestyle == "--": + all_label_candidates.append( + ( + float(peak_energy), + label_text, + color, + linestyle, + 0.90, + "axes_top", + 9, + "normal", + 0.9, + ) + ) + else: + all_label_candidates.append( + ( + float(peak_energy), + label_text, + color, + linestyle, + float(height + label_vertical_offset), + "data", + 10, + "bold", + 1.0, + ) + ) - prev_energy = current_group[-1][0] - prev_text = current_group[-1][1] - prev_color = current_group[-1][2] - energy_delta = abs(label[0] - prev_energy) + if show_text and all_label_candidates: + all_label_candidates.sort(key=lambda item: item[0]) - should_group = energy_delta <= overlap_threshold - if not should_group and label[1] == prev_text: - should_group = energy_delta <= same_label_overlap_threshold - if not should_group and _same_color(label[2], prev_color): - should_group = energy_delta <= same_color_overlap_threshold + overlap_threshold = max(0.10, 0.7 * float(tolerance)) + same_label_overlap_threshold = max(0.16, 1.1 * float(tolerance)) + same_color_overlap_threshold = max(0.22, 1.6 * float(tolerance)) + grouped_labels = [] + current_group = [] - if should_group: - current_group.append(label) - else: - grouped_labels.append(current_group) - current_group = [label] + def _same_color(c1, c2): + try: + return np.allclose(np.asarray(c1), np.asarray(c2)) + except Exception: + return str(c1) == str(c2) - if current_group: + for label in all_label_candidates: + if not current_group: + current_group.append(label) + continue + + prev_energy = current_group[-1][0] + prev_text = current_group[-1][1] + prev_color = current_group[-1][2] + energy_delta = abs(label[0] - prev_energy) + + should_group = energy_delta <= overlap_threshold + if not should_group and label[1] == prev_text: + should_group = energy_delta <= same_label_overlap_threshold + if not should_group and _same_color(label[2], prev_color): + should_group = energy_delta <= same_color_overlap_threshold + + if should_group: + current_group.append(label) + else: grouped_labels.append(current_group) + current_group = [label] - label_vertical_offset = max(0.03 * y_scale, 0.08) - grouped_bucket_step = max(0.02 * y_scale, 0.05) + if current_group: + grouped_labels.append(current_group) - for group in grouped_labels: - if len(group) == 1: - peak_energy, label_text, color, height = group[0] - y_pos = height + label_vertical_offset + for group in grouped_labels: + if len(group) == 1: + ( + peak_energy, + label_text, + color, + linestyle, + y_value, + y_mode, + font_size, + font_weight, + alpha_value, + ) = group[0] + if y_mode == "axes_top": ax_spec.text( peak_energy, - y_pos, + y_value, label_text, ha="center", - va="bottom", - fontsize=10, + va="top", + fontsize=font_size, color=color, - weight="bold", + weight=font_weight, rotation=90, + alpha=alpha_value, + transform=ax_spec.get_xaxis_transform(), + clip_on=True, ) else: - x_pos = float(np.mean([item[0] for item in group])) - y_pos = max(item[3] for item in group) + label_vertical_offset - merged_text = ", ".join(item[1] for item in group) - first_color = group[0][2] - all_same_color = all(_same_color(item[2], first_color) for item in group) - if all_same_color: - ax_spec.text( - x_pos, - y_pos, - merged_text, - ha="center", - va="bottom", - fontsize=9, - color=group[0][2], - weight="bold", - rotation=90, + ax_spec.text( + peak_energy, + y_value, + label_text, + ha="center", + va="bottom", + fontsize=font_size, + color=color, + weight=font_weight, + rotation=90, + alpha=alpha_value, + ) + else: + for _, label_text, color, linestyle, _, _, _, _, _ in group: + legend_key = (label_text, str(color), linestyle) + if legend_key in legend_labels: + continue + legend_labels.add(legend_key) + legend_handles.append( + Line2D( + [0], + [0], + color=color, + linestyle=linestyle, + linewidth=1.5, + label=label_text, ) - else: - # Keep grouped behavior, but color by respective line colors. - # Build one comma-list per color and stack them tightly. - color_buckets = [] - for _, label_text, label_color, _ in group: - matched_bucket = None - for bucket in color_buckets: - if _same_color(bucket["color"], label_color): - matched_bucket = bucket - break - - if matched_bucket is None: - color_buckets.append( - {"color": label_color, "labels": [label_text]} - ) - else: - matched_bucket["labels"].append(label_text) - - for bucket_index, bucket in enumerate(color_buckets): - bucket_text = ", ".join(bucket["labels"]) - y_offset = bucket_index * grouped_bucket_step - ax_spec.text( - x_pos, - y_pos + y_offset, - bucket_text, - ha="center", - va="bottom", - fontsize=9, - color=bucket["color"], - weight="bold", - rotation=90, - ) - - fig.tight_layout() + ) + + overlap_legend = None + if legend_handles: + overlap_legend = ax_spec.legend( + handles=legend_handles, + loc="upper right", + fontsize=8, + title="Overlapping Labels", + ) + + style_legend_handles = [ + Line2D( + [0], + [0], + color="gray", + linestyle="None", + marker="|", + markersize=8, + markeredgewidth=1.5, + label="Gray tick: above snr_min, unmatched", + ), + Line2D( + [0], + [0], + color="black", + linestyle="--", + linewidth=1.5, + label="Dashed line: possible", + ), + Line2D( + [0], + [0], + color="black", + linestyle=":", + linewidth=1.5, + label="Dotted line: requested refline", + ), + Line2D( + [0], + [0], + color="black", + linestyle="-", + linewidth=1.5, + label="Solid line: autodetected", + ), + ] + style_legend = ax_spec.legend( + handles=style_legend_handles, + loc="upper center", + bbox_to_anchor=(0.5, -0.16), + ncol=4, + fontsize=8, + frameon=True, + title="Peak Marker Guide", + ) + if overlap_legend is not None: + ax_spec.add_artist(overlap_legend) + + fig.tight_layout(rect=[0, 0.08, 1, 1]) plt.show() - print(f"{'Energy (keV)':<12} {'Intensity':<12} {'SNR':<8} {'Best Match':<25}") - print("-" * 60) - for peak_energy, height, snr, best_match in sorted_table_rows: - print(f"{peak_energy:<12.3f} {height:<12.1f} {snr:<8.1f} {best_match:<25}") - if dotted_reference_rows: - print("-" * 60) - for element_key, line_name, line_energy in sorted( - dotted_reference_rows, key=lambda item: item[2] - ): - print( - f"{line_energy:<12.3f} {'-':<12} {'-':<8} " - f"{(element_key + ' ' + line_name + ' (ref)'):<25}" - ) - print("-" * 60) + print( + f"{'Energy (keV)':<12} {'Intensity':<12} {'SNR':<8} " + f"{'Best Match':<22} {'Alt 2':<22} {'Alt 3':<22}" + ) + print("-" * 105) + for peak_energy, height, snr, best_match, alt_2, alt_3 in sorted_table_rows: + print( + f"{peak_energy:<12.3f} {height:<12.1f} {snr:<8.1f} " + f"{best_match:<22} {alt_2:<22} {alt_3:<22}" + ) + print("-" * 105) print( f"{displayed_peak_count} of {total_over_snr_peak_count} peaks above " f"snr_min={min_snr:.1f}, snr_threshold={snr_threshold_for_sample:.1f} displayed.\n" diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index e247b062..e063fd7b 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -10,9 +10,11 @@ from numpy.typing import NDArray from quantem.core.datastructures.dataset3d import Dataset3d +from quantem.core.visualization import show_2d from quantem.spectroscopy.utils import load_xray_lines_database + def _run_pca(data: NDArray | Any, n_components: int): array = np.asarray(data, dtype=float) n_samples, n_features = array.shape @@ -191,6 +193,8 @@ def add_elements_to_model(self, elements): if self.model_elements is None: self.model_elements = {} + added_this_call = {} + for spec in specs: tokens = str(spec).split() if not tokens: @@ -205,6 +209,10 @@ def add_elements_to_model(self, elements): if not selected_lines: continue + existing_before = self.model_elements.get(element_key) + if not isinstance(existing_before, dict): + existing_before = {} + existing_keys_before = set(existing_before.keys()) if not selectors: self.model_elements[element_key] = selected_lines else: @@ -214,9 +222,24 @@ def add_elements_to_model(self, elements): existing.update(selected_lines) self.model_elements[element_key] = existing + added_keys = [ + line_name for line_name in selected_lines.keys() if line_name not in existing_keys_before + ] + if added_keys: + if element_key not in added_this_call: + added_this_call[element_key] = [] + added_this_call[element_key].extend(added_keys) if not self.model_elements: self.model_elements = None + if added_this_call: + print("Added to model:") + for element_key in sorted(added_this_call.keys()): + unique_lines = sorted(set(str(line_name) for line_name in added_this_call[element_key])) + print(f" - {element_key}: {', '.join(unique_lines)}") + else: + print("Added to model: nothing new") + def remove_elements_from_model(self, elements): """ Remove element(s) from the persistent model used in show_mean_spectrum. @@ -275,15 +298,15 @@ def add_spectrum_to_data(self, spectrum, energy_axis): """ from quantem.core.datastructures.dataset1d import Dataset1d - one_d_spectrum = Dataset1d.from_array( + two_d_spectrum = Dataset1d.from_array( array=spectrum, origin=energy_axis[0], sampling=self.sampling[0], units=self.units[0] ) if self.attached_spectra is not None: - self.attached_spectra.append(one_d_spectrum) + self.attached_spectra.append(two_d_spectrum) else: self.attached_spectra = [] - self.attached_spectra.append(one_d_spectrum) + self.attached_spectra.append(two_d_spectrum) def clear_attached_spectra(self): self.attached_spectra = None @@ -521,46 +544,46 @@ def _plot_pca_results( plt.tight_layout() plt.show() - def calculate_mean_spectrum( - self, roi=None, energy_range=None, ignore_range=None, mask=None, attach_mean_spectrum=True - ): - # ADJUST ROI BASED ON GIVEN FLAGS ----------------------------------------------- - # Parse ROI parameter - if roi is None: - # Full image - y, x, dy, dx = 0, 0, int(self.shape[1]), int(self.shape[2]) - elif len(roi) == 2: - # Single pixel [y, x] - y, x, dy, dx = int(roi[0]), int(roi[1]), 1, 1 - elif len(roi) == 4: - # Full ROI [y, x, dy, dx] with None support for defaults - y_val, x_val, dy_val, dx_val = roi + def _calibrated_position_to_pixel(self, value, axis): + if value is None: + return None - # Handle None values with defaults - y = 0 if y_val is None else int(y_val) - x = 0 if x_val is None else int(x_val) - dy = int(self.shape[1]) - y if dy_val is None else int(dy_val) - dx = int(self.shape[2]) - x if dx_val is None else int(dx_val) - else: + sampling = float(self.sampling[axis]) + if sampling == 0: + raise ValueError(f"Cannot convert calibrated ROI on axis {axis}: sampling is zero") + + origin = float(self.origin[axis]) if hasattr(self, "origin") else 0.0 + return int(np.round((float(value) - origin) / sampling)) + + def _calibrated_span_to_pixels(self, value, axis): + if value is None: + return None + + sampling = abs(float(self.sampling[axis])) + if sampling == 0: raise ValueError( - "roi must be None, [y, x], or [y, x, dy, dx] (with None for defaults)" + f"Cannot convert calibrated ROI span on axis {axis}: sampling is zero" + ) + + pixels = int(np.round(float(value) / sampling)) + if pixels < 1: + raise ValueError( + f"Calibrated ROI span on axis {axis} converts to {pixels} pixels; expected >= 1" ) + return pixels - # VALIDATE ROI BOUNDS --------------------------------------------------------------------------- + def _validate_roi_bounds(self, y, x, dy, dx): errs = [] - Ymax = int(self.shape[1]) - Xmax = int(self.shape[2]) + ymax = int(self.shape[1]) + xmax = int(self.shape[2]) - # type/NaN checks (optional if you already cast to int above) for name, val in (("y", y), ("x", x), ("dy", dy), ("dx", dx)): if val is None: errs.append(f"{name} is None (missing after normalization).") - # if any None, bail early to avoid arithmetic errors if errs: raise ValueError("Invalid ROI:\n - " + "\n - ".join(errs)) - # basic constraints if y < 0: errs.append(f"y={y} < 0") if x < 0: @@ -570,23 +593,81 @@ def calculate_mean_spectrum( if dx < 1: errs.append(f"dx={dx} < 1") - # starts within image - if y >= Ymax: - errs.append(f"y start {y} out of bounds [0, {Ymax - 1}]") - if x >= Xmax: - errs.append(f"x start {x} out of bounds [0, {Xmax - 1}]") + if y >= ymax: + errs.append(f"y start {y} out of bounds [0, {ymax - 1}]") + if x >= xmax: + errs.append(f"x start {x} out of bounds [0, {xmax - 1}]") - # ends within image end_y = y + dy end_x = x + dx - if end_y > Ymax: - errs.append(f"y+dy = {end_y} exceeds height {Ymax}") - if end_x > Xmax: - errs.append(f"x+dx = {end_x} exceeds width {Xmax}") + if end_y > ymax: + errs.append(f"y+dy = {end_y} exceeds height {ymax}") + if end_x > xmax: + errs.append(f"x+dx = {end_x} exceeds width {xmax}") if errs: raise ValueError("Invalid ROI:\n - " + "\n - ".join(errs)) + def _resolve_roi_px(self, roi=None, roi_px=None, roi_cal=None): + selector_count = int(roi is not None) + int(roi_px is not None) + int(roi_cal is not None) + if selector_count > 1: + raise ValueError("Use only one ROI selector: roi, roi_px, or roi_cal") + + if roi_px is not None: + roi_spec = roi_px + elif roi is not None: + roi_spec = roi + elif roi_cal is not None: + if len(roi_cal) == 2: + y_cal, x_cal = roi_cal + roi_spec = [ + self._calibrated_position_to_pixel(y_cal, axis=1), + self._calibrated_position_to_pixel(x_cal, axis=2), + ] + elif len(roi_cal) == 4: + y_cal, x_cal, dy_cal, dx_cal = roi_cal + roi_spec = [ + self._calibrated_position_to_pixel(y_cal, axis=1), + self._calibrated_position_to_pixel(x_cal, axis=2), + self._calibrated_span_to_pixels(dy_cal, axis=1), + self._calibrated_span_to_pixels(dx_cal, axis=2), + ] + else: + raise ValueError("roi_cal must be [y, x] or [y, x, dy, dx]") + else: + roi_spec = None + + if roi_spec is None: + y, x, dy, dx = 0, 0, int(self.shape[1]), int(self.shape[2]) + elif len(roi_spec) == 2: + y, x = roi_spec + y, x, dy, dx = int(y), int(x), 1, 1 + elif len(roi_spec) == 4: + y_val, x_val, dy_val, dx_val = roi_spec + y = 0 if y_val is None else int(y_val) + x = 0 if x_val is None else int(x_val) + dy = int(self.shape[1]) - y if dy_val is None else int(dy_val) + dx = int(self.shape[2]) - x if dx_val is None else int(dx_val) + else: + raise ValueError( + "ROI must be None, [y, x], or [y, x, dy, dx]. Use one selector: roi, roi_px, roi_cal" + ) + + self._validate_roi_bounds(y, x, dy, dx) + return y, x, dy, dx + + def calculate_mean_spectrum( + self, + roi=None, + energy_range=None, + ignore_range=None, + mask=None, + attach_mean_spectrum=True, + roi_px=None, + roi_cal=None, + ): + y, x, dy, dx = self._resolve_roi_px(roi=roi, roi_px=roi_px, roi_cal=roi_cal) + # SPECTRUM CALCULATION -------------------------------------------------------------- dE = float(self.sampling[0]) @@ -629,10 +710,10 @@ def calculate_mean_spectrum( spec = np.empty(self.shape[0], dtype=float) for k in range(self.shape[0]): img = np.asarray(self.array[k], dtype=float) - roi = img[y : y + dy, x : x + dx] - if roi.size == 0: + roi_data = img[y : y + dy, x : x + dx] + if roi_data.size == 0: raise ValueError("ROI is empty; check y/x/dy/dx.") - spec[k] = roi.mean() + spec[k] = roi_data.mean() # APPLY ENERGY RANGE --------------------------------------------------------------- @@ -661,6 +742,8 @@ def calculate_mean_spectrum( def show_mean_spectrum( self, roi=None, + roi_px=None, + roi_cal=None, energy_range=None, mask=None, data_type="eds", @@ -719,7 +802,15 @@ def show_mean_spectrum( # CALCULATE MEAN SPECTRUM FOR GIVEN ROI AND ENERGY RANGE -------------------------- - spec = self.calculate_mean_spectrum(roi=roi, energy_range=energy_range, mask=mask) + y, x, dy, dx = self._resolve_roi_px(roi=roi, roi_px=roi_px, roi_cal=roi_cal) + + spec = self.calculate_mean_spectrum( + roi=roi, + roi_px=roi_px, + roi_cal=roi_cal, + energy_range=energy_range, + mask=mask, + ) dE = float(self.sampling[0]) E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 @@ -743,11 +834,19 @@ def show_mean_spectrum( sum_img = np.asarray(self.array, dtype=float).sum(axis=0) title_suffix = "" - im = ax_img.imshow(sum_img, cmap="viridis", origin="lower") - if data_type == "eds": - ax_img.set_title(f"EDS Sum Image{title_suffix}") - else: - ax_img.set_title(f"EELS Sum Image{title_suffix}") + map_title = f"Integrated Intensity Map{title_suffix}" + show_2d( + sum_img, + figax=(fig, ax_img), + title=map_title, + cmap="viridis", + cbar=True, + show_ticks=True, + scalebar={ + "sampling": float(self.sampling[1]), + "units": str(self.units[1]), + }, + ) ax_img.set_xlabel("X (pixels)") ax_img.set_ylabel("Y (pixels)") @@ -757,9 +856,6 @@ def show_mean_spectrum( ) ax_img.add_patch(rect) - # Add colorbar for the image - plt.colorbar(im, ax=ax_img) - # RIGHT PLOT: Show spectrum ax_spec.plot(E, spec, linewidth=1.5) if data_type == "eds": @@ -775,10 +871,123 @@ def show_mean_spectrum( plt.show() return fig, (ax_img, ax_spec) + def refline( + self, + elements, + ax=None, + energy=None, + energy_range=None, + linestyle=":", + linewidth=1.2, + alpha=0.35, + show_text=True, + ): + """Overlay reference lines for selected element specifiers on a spectrum axis. + + This is plotting-only and does not modify auto-identification behavior. + """ + if elements is None: + raise ValueError("elements must be specified") + if energy is not None and energy_range is not None: + raise ValueError("Specify either energy or energy_range, not both") + + if type(self).element_info is None: + type(self).load_element_info() + all_info = type(self).element_info or {} + + specs = type(self)._normalize_element_specs(elements) + if len(specs) == 0: + raise ValueError("elements must contain at least one selector") + + if ax is None: + ax = plt.gca() + + if energy_range is not None: + if len(energy_range) != 2: + raise ValueError("energy_range must be [min_energy, max_energy]") + e_min = float(min(energy_range[0], energy_range[1])) + e_max = float(max(energy_range[0], energy_range[1])) + elif energy is not None: + tol = max(2.0 * abs(float(self.sampling[0])), 1e-9) + center = float(energy) + e_min = center - tol + e_max = center + tol + else: + xlim = ax.get_xlim() + e_min = float(min(xlim)) + e_max = float(max(xlim)) + + artists = [] + labels = [] + energies = [] + + y_top = ax.get_ylim()[1] + y_text = y_top - 0.08 * max(abs(y_top), 1e-12) + + for spec in specs: + tokens = str(spec).split() + if len(tokens) == 0: + continue + + element_key = type(self)._resolve_element_key(all_info, tokens[0]) + if element_key is None: + continue + + selectors = tokens[1:] + selected_lines = type(self)._select_lines(all_info.get(element_key, {}), selectors) + + for line_name, line_info in selected_lines.items(): + if not isinstance(line_info, dict): + continue + + energy_value = line_info.get("energy (keV)") + if energy_value is None: + energy_value = line_info.get("onset_energy (eV)") + if energy_value is None: + energy_value = line_info.get("energy") + if energy_value is None: + continue + + try: + line_energy = float(energy_value) + except (TypeError, ValueError): + continue + + if not (e_min <= line_energy <= e_max): + continue + + label = f"{element_key} {line_name}" + line_artist = ax.axvline( + line_energy, + linestyle=linestyle, + linewidth=linewidth, + alpha=alpha, + ) + artists.append(line_artist) + labels.append(label) + energies.append(line_energy) + + if show_text: + text_artist = ax.text( + line_energy, + y_text, + label, + rotation=90, + ha="center", + va="top", + alpha=min(1.0, alpha + 0.2), + clip_on=True, + ) + artists.append(text_artist) + + return {"ax": ax, "artists": artists, "labels": labels, "energies": np.asarray(energies)} + def show_energy_window_map( self, - energy_window, + energy_window=None, roi=None, + roi_px=None, + roi_cal=None, mask=None, data_type="eds", cmap="viridis", @@ -792,8 +1001,9 @@ def show_energy_window_map( Parameters ---------- - energy_window : list[float] | tuple[float, float] - Energy interval [emin, emax] to integrate. + energy_window : list[float] | tuple[float, float] | None + Energy interval [emin, emax] to integrate. If None, use the + full calibrated energy range of the dataset. roi : list | tuple | None, optional ROI as ``[y, x]`` or ``[y, x, dy, dx]`` (with ``None`` defaults), used only for overlay rectangle. @@ -810,39 +1020,29 @@ def show_energy_window_map( Returns ------- tuple - ``(fig, ax, energy_map)`` where ``energy_map`` is the integrated 2D array. + ``(fig, (ax_map, ax_spec), energy_map)`` where ``energy_map`` is the integrated 2D array. """ - if energy_window is None or len(energy_window) != 2: - raise ValueError("energy_window must be [min_energy, max_energy]") - - emin = float(energy_window[0]) - emax = float(energy_window[1]) - if not np.isfinite(emin) or not np.isfinite(emax) or emin >= emax: - raise ValueError( - "Invalid energy_window. Expected [min_energy, max_energy] with min < max" - ) - - # Parse ROI (for optional overlay only) - if roi is None: - y, x, dy, dx = 0, 0, int(self.shape[1]), int(self.shape[2]) - has_roi_overlay = False - elif len(roi) == 2: - y, x, dy, dx = int(roi[0]), int(roi[1]), 1, 1 - has_roi_overlay = True - elif len(roi) == 4: - y_val, x_val, dy_val, dx_val = roi - y = 0 if y_val is None else int(y_val) - x = 0 if x_val is None else int(x_val) - dy = int(self.shape[1]) - y if dy_val is None else int(dy_val) - dx = int(self.shape[2]) - x if dx_val is None else int(dx_val) - has_roi_overlay = True - else: - raise ValueError("roi must be None, [y, x], or [y, x, dy, dx] (with None defaults)") + y, x, dy, dx = self._resolve_roi_px(roi=roi, roi_px=roi_px, roi_cal=roi_cal) + has_roi_overlay = any(val is not None for val in (roi, roi_px, roi_cal)) dE = float(self.sampling[0]) E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 E = E0 + dE * np.arange(self.shape[0]) + if energy_window is None: + emin = float(np.min(E)) + emax = float(np.max(E)) + else: + if len(energy_window) != 2: + raise ValueError("energy_window must be [min_energy, max_energy]") + + emin = float(energy_window[0]) + emax = float(energy_window[1]) + if not np.isfinite(emin) or not np.isfinite(emax) or emin >= emax: + raise ValueError( + "Invalid energy_window. Expected [min_energy, max_energy] with min < max" + ) + window_mask = (E >= emin) & (E <= emax) if mask is not None: mask = np.asarray(mask, dtype=bool) @@ -858,13 +1058,35 @@ def show_energy_window_map( arr = np.asarray(self.array, dtype=float) energy_map = arr[window_mask, :, :].sum(axis=0) - fig, ax = plt.subplots(1, 1, figsize=(6, 5)) - im = ax.imshow(energy_map, cmap=cmap, origin="lower") + spec = self.calculate_mean_spectrum( + roi=roi, + roi_px=roi_px, + roi_cal=roi_cal, + mask=mask, + attach_mean_spectrum=False, + ) + if mask is not None: + E_spec = E[mask] + else: + E_spec = E unit_label = "keV" if str(data_type).lower() == "eds" else "eV" - ax.set_title(f"Energy-Window Map [{emin:.3f}, {emax:.3f}] {unit_label}") - ax.set_xlabel("X (pixels)") - ax.set_ylabel("Y (pixels)") + fig, (ax_map, ax_spec) = plt.subplots(1, 2, figsize=(12, 4)) + show_2d( + energy_map, + figax=(fig, ax_map), + title=f"Energy-Window Map [{emin:.3f}, {emax:.3f}] {unit_label}", + cmap=cmap, + cbar=True, + show_ticks=True, + scalebar={ + "sampling": float(self.sampling[1]), + "units": str(self.units[1]), + }, + ) + + ax_map.set_xlabel("X (pixels)") + ax_map.set_ylabel("Y (pixels)") if has_roi_overlay: rect = Rectangle( @@ -876,15 +1098,22 @@ def show_energy_window_map( facecolor="none", alpha=0.8, ) - ax.add_patch(rect) + ax_map.add_patch(rect) + + ax_spec.plot(E_spec, spec, linewidth=1.5) + ax_spec.axvspan(emin, emax, color="orange", alpha=0.2, label="Selected window") + ax_spec.set_xlabel(f"Energy ({unit_label})") + ax_spec.set_ylabel("Intensity") + ax_spec.set_title(f"Spectrum from ROI [{y}:{y + dy}, {x}:{x + dx}]") + ax_spec.grid(True, alpha=0.1) + ax_spec.legend(loc="best") - plt.colorbar(im, ax=ax, label="Integrated Intensity") fig.tight_layout() if show: plt.show() - return fig, ax, energy_map + return fig, (ax_map, ax_spec), energy_map # BACKGROND SUBTRACTION From 0b6923107c62fbd90463cc4888a9755adf01f88a Mon Sep 17 00:00:00 2001 From: yaeltsarfati Date: Thu, 5 Mar 2026 16:20:03 -0800 Subject: [PATCH 089/136] Local changes to uv.lock --- uv.lock | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/uv.lock b/uv.lock index 05052285..9629bda4 100644 --- a/uv.lock +++ b/uv.lock @@ -1306,6 +1306,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + [[package]] name = "json5" version = "0.13.0" @@ -2789,6 +2798,7 @@ dependencies = [ { name = "optuna" }, { name = "rosettasciio" }, { name = "scikit-image" }, + { name = "scikit-learn" }, { name = "scipy" }, { name = "tensorboard" }, { name = "torch" }, @@ -2832,6 +2842,7 @@ requires-dist = [ { name = "quantem-widget", marker = "extra == 'widgets'", editable = "widget" }, { name = "rosettasciio", specifier = ">=0.8.0" }, { name = "scikit-image", specifier = ">=0.25.2" }, + { name = "scikit-learn", specifier = ">=1.8.0" }, { name = "scipy" }, { name = "tensorboard", specifier = ">=2.19.0" }, { name = "torch", specifier = ">=2.7.0" }, @@ -3183,6 +3194,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/cd/4da01329b5a8d47ff7ec3c99a2b02465a8017b186027590dc7425cee0b56/scikit_image-0.26.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0608aa4a9ec39e0843de10d60edb2785a30c1c47819b67866dd223ebd149acaf", size = 11769501, upload-time = "2025-12-20T17:12:19.339Z" }, ] +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" }, + { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" }, + { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, + { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, + { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, + { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, + { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, + { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, +] + [[package]] name = "scipy" version = "1.17.1" @@ -3413,6 +3474,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, ] +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + [[package]] name = "tifffile" version = "2026.3.3" From 81f0f2578fad19e449e0c4eee18d6538926dde0e Mon Sep 17 00:00:00 2001 From: yaeltsarfati Date: Thu, 19 Mar 2026 14:01:31 -0700 Subject: [PATCH 090/136] Ignore local data and notebooks --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index d87d85c8..06c04b02 100644 --- a/.gitignore +++ b/.gitignore @@ -191,3 +191,5 @@ ipynb-playground/ # widget (JS build artifacts) node_modules/ widget/src/quantem/widget/static/ +Data/ +Notebooks/ From d300e160c2e5c7ad53369809018abb076e9c58a8 Mon Sep 17 00:00:00 2001 From: yaeltsarfati Date: Mon, 23 Mar 2026 10:20:39 -0700 Subject: [PATCH 091/136] Add thickness module and supporting alignment/visualization modules with 'EELS' titles in figures --- src/quantem/spectroscopy/__init__.py | 4 +- .../spectroscopy/dataset3dspectroscopy.py | 2 +- src/quantem/spectroscopy/eels_alignment.py | 101 +++++++++ .../spectroscopy/eels_visualization.py | 163 ++++++++++++++ src/quantem/spectroscopy/thickness.py | 198 ++++++++++++++++++ 5 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 src/quantem/spectroscopy/eels_alignment.py create mode 100644 src/quantem/spectroscopy/eels_visualization.py create mode 100644 src/quantem/spectroscopy/thickness.py diff --git a/src/quantem/spectroscopy/__init__.py b/src/quantem/spectroscopy/__init__.py index 8ddb58c0..89842ba3 100644 --- a/src/quantem/spectroscopy/__init__.py +++ b/src/quantem/spectroscopy/__init__.py @@ -9,4 +9,6 @@ Dataset3deds as Dataset3deds, ) - +from quantem.spectroscopy import thickness as thickness +from quantem.spectroscopy import eels_alignment as eels_alignment +from quantem.spectroscopy import eels_visualization as eels_visualization diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index e063fd7b..0cbf773e 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -746,7 +746,7 @@ def show_mean_spectrum( roi_cal=None, energy_range=None, mask=None, - data_type="eds", + data_type=None, show=True, ): """ diff --git a/src/quantem/spectroscopy/eels_alignment.py b/src/quantem/spectroscopy/eels_alignment.py new file mode 100644 index 00000000..0bb60495 --- /dev/null +++ b/src/quantem/spectroscopy/eels_alignment.py @@ -0,0 +1,101 @@ +import matplotlib.pyplot as plt +import numpy as np + + +def align_dual_eels_universal(ll, hl, approach="smooth", sigma=1.2): + """ + Aligns ZLP jitter across the spatial map and synchronizes Dual-EELS pairs. + """ + print(f"QuantEM: Aligning {ll.name} and syncing {hl.name}...") + + # 1. Map the drift via argmax + zlp_indices = np.argmax(ll.array, axis=0) + ref_idx = int(np.median(zlp_indices)) + shifts = zlp_indices - ref_idx + + # 2. Apply internal QuantEM calibration + ll.calibrate_zero_loss_peak() + + # 3. Synchronize High-Loss energy origin based on median shift + shift_ev = np.median(shifts) * ll.sampling[0] + hl.origin[0] -= shift_ev + + print("QuantEM: Alignment and Dual-EELS sync complete.") + return ll, hl, shifts + + +def calibrate_energy_axis(ll, hl): + """ + Fine-tunes the origin so the absolute peak position is exactly 0.0 eV. + """ + # Find the peak of the average spectrum + current_peak_idx = np.argmax(np.mean(ll.array, axis=(1, 2))) + peak_ev = ll.origin[0] + (current_peak_idx * ll.sampling[0]) + + # Apply global shift to both datasets + ll.origin[0] -= peak_ev + hl.origin[0] -= peak_ev + + print(f"QuantEM: Final calibration shift of {peak_ev:.4f} eV applied.") + + +def plot_absolute_zlp_shift(dataset, search_window=(-10, 10)): + """ + Calculates the ZLP shift per pixel and plots the absolute deviation from 0.0 eV. + """ + data = dataset.array + n_e = data.shape[0] + + # Generate energy axis + energies = dataset.origin[0] + np.arange(n_e) * dataset.sampling[0] + + # Mask energy window for peak finding + mask = (energies > search_window[0]) & (energies < search_window[1]) + search_energies = energies[mask] + + # Calculate peak map and absolute deviation + peak_indices = np.argmax(data[mask, :, :], axis=0) + zlp_map_ev = search_energies[peak_indices] + absolute_shift = np.abs(zlp_map_ev) + + # Visualization + fig, ax = plt.subplots(figsize=(8, 6)) + im = ax.imshow(absolute_shift, cmap="magma", origin="lower") + + plt.colorbar(im, ax=ax, label="Absolute Shift (eV)") + ax.set_title(f"Absolute ZLP Deviation: {dataset.name}") + ax.set_xlabel("X (pixels)") + ax.set_ylabel("Y (pixels)") + + plt.tight_layout() + plt.show() + + return absolute_shift + + +def plot_alignment_verification(dataset, shift_map, coords=(9, 9)): + """ + Plots the drift map and a specific spectrum to verify alignment quality. + """ + y, x = coords + spec = dataset.array[:, y, x] + energies = dataset.origin[0] + np.arange(len(spec)) * dataset.sampling[0] + + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5)) + + # Drift Map + im = ax1.imshow(shift_map, cmap="RdBu_r", origin="lower") + ax1.plot(x, y, "yo", markeredgecolor="k") + ax1.set_title("Drift Map") + plt.colorbar(im, ax=ax1, label="Relative Shift") + + # Spectrum Verification + ax2.plot(energies, spec, color="black", label="Aligned Spec") + ax2.axvline(0, color="red", linestyle="--", alpha=0.7, label="0.0 eV Target") + ax2.set_xlim(-5, 5) + ax2.set_title(f"ZLP Detail at Pixel ({x}, {y})") + ax2.set_xlabel("Energy Loss (eV)") + ax2.legend() + + plt.tight_layout() + plt.show() diff --git a/src/quantem/spectroscopy/eels_visualization.py b/src/quantem/spectroscopy/eels_visualization.py new file mode 100644 index 00000000..509ee971 --- /dev/null +++ b/src/quantem/spectroscopy/eels_visualization.py @@ -0,0 +1,163 @@ +import matplotlib.pyplot as plt +import numpy as np +from ipywidgets import Checkbox, IntSlider, interact +from scipy.stats import norm + + +def plot_dual_eels_picker(ll, hl, coords=(9, 9), title="QuantEM: Dual-EELS Analysis"): + """ + Interactive picker for side-by-side Low-Loss and High-Loss EELS analysis. + """ + # 1. Pre-calculate sum images for spatial maps + sum_ll = np.sum(ll.array, axis=0) + sum_hl = np.sum(hl.array, axis=0) + + # 2. Generate energy axes using dataset metadata + energy_ll = ll.origin[0] + np.arange(ll.shape[0]) * ll.sampling[0] + energy_hl = hl.origin[0] + np.arange(hl.shape[0]) * hl.sampling[0] + + def _update_plot(x, y, log_scale): + fig, axes = plt.subplots(2, 2, figsize=(14, 9)) + fig.suptitle(title, fontsize=16) + + # --- LOW LOSS ROW (Top) --- + im_ll = axes[0, 0].imshow(sum_ll, cmap="viridis", origin="lower") + axes[0, 0].plot(x, y, "r+", markersize=12, markeredgewidth=2) + axes[0, 0].set_title("Low-Loss Map (Integrated)") + fig.colorbar(im_ll, ax=axes[0, 0], label="Counts") + + axes[0, 1].plot(energy_ll, ll.array[:, y, x], color="tab:blue", lw=1.5) + axes[0, 1].set_title(f"LL Spectrum at ({x}, {y})") + axes[0, 1].set_ylabel("Intensity") + if log_scale: + axes[0, 1].set_yscale("log") + + # --- HIGH LOSS ROW (Bottom) --- + im_hl = axes[1, 0].imshow(sum_hl, cmap="magma", origin="lower") + axes[1, 0].plot(x, y, "r+", markersize=12, markeredgewidth=2) + axes[1, 0].set_title("High-Loss Map (Integrated)") + fig.colorbar(im_hl, ax=axes[1, 0], label="Counts") + + axes[1, 1].plot(energy_hl, hl.array[:, y, x], color="tab:red", lw=1.5) + axes[1, 1].set_title(f"HL Spectrum at ({x}, {y})") + axes[1, 1].set_xlabel("Energy Loss (eV)") + axes[1, 1].set_ylabel("Intensity") + if log_scale: + axes[1, 1].set_yscale("log") + + plt.tight_layout() + plt.show() + + # Standardized sliders with continuous_update=False for performance + interact( + _update_plot, + x=IntSlider(min=0, max=ll.shape[2] - 1, step=1, value=coords[1], continuous_update=False), + y=IntSlider(min=0, max=ll.shape[1] - 1, step=1, value=coords[0], continuous_update=False), + log_scale=Checkbox(value=False, description="Log Y-Axis"), + ) + + +def plot_quantem_diagnostic(dataset, zlp_window=5.0, title_suffix=""): + """ + QuantEM Diagnostic Dashboard: Visualizes mean spectra, spatial variation, and ZLP accuracy. + """ + data = dataset.array + energy = dataset.origin[0] + np.arange(data.shape[0]) * dataset.sampling[0] + + mean_spec = np.mean(data, axis=(1, 2)) + zlp_idx = np.argmax(mean_spec) + zlp_pos = energy[zlp_idx] + sum_img = np.sum(data, axis=0) + + fig = plt.figure(figsize=(15, 10)) + gs = fig.add_gridspec(2, 2, hspace=0.3, wspace=0.3) + fig.suptitle(f"QuantEM Diagnostic: {dataset.name} {title_suffix}", fontsize=16) + + # 1. Mean Spectrum with Alignment Targets + ax1 = fig.add_subplot(gs[0, 0]) + ax1.plot(energy, mean_spec, color="black", label="Mean Spectrum") + ax1.axvline(zlp_pos, color="red", ls="--", alpha=0.6, label=f"Peak: {zlp_pos:.2f} eV") + ax1.axvline(0, color="green", ls=":", lw=2, label="Target (0 eV)") + ax1.set_title("Global Average Spectrum") + ax1.set_xlabel("Energy Loss (eV)") + ax1.legend() + + # 2. Spatial Variability (Sampled 3x3 Grid) + ax2 = fig.add_subplot(gs[0, 1]) + yy = np.linspace(0, data.shape[1] - 1, 3, dtype=int) + xx = np.linspace(0, data.shape[2] - 1, 3, dtype=int) + for y in yy: + for x in xx: + ax2.plot(energy, data[:, y, x], alpha=0.4, lw=1) + ax2.set_title("Spatial Variation (Sampled Pixels)") + ax2.set_xlabel("Energy Loss (eV)") + + # 3. Integrated Intensity Map + ax3 = fig.add_subplot(gs[1, 0]) + im = ax3.imshow(sum_img, cmap="viridis", origin="lower") + fig.colorbar(im, ax=ax3, label="Total Counts") + ax3.set_title("Summed Intensity Map") + ax3.set_xlabel("X (pixels)") + ax3.set_ylabel("Y (pixels)") + + # 4. ZLP Zoom-in Detail + ax4 = fig.add_subplot(gs[1, 1]) + mask = (energy > zlp_pos - zlp_window) & (energy < zlp_pos + zlp_window) + ax4.plot(energy[mask], mean_spec[mask], color="blue", lw=2) + ax4.axvline(0, color="green", ls=":", lw=2, label="0 eV Target") + ax4.set_title(f"ZLP Alignment Detail (±{zlp_window} eV)") + ax4.set_xlabel("Energy Loss (eV)") + ax4.legend() + + plt.show() + + +def plot_zlp_drift_diagnostics(dataset, title="ZLP Drift Analysis"): + """ + QuantEM Diagnostic: Maps the ZLP position and calculates the drift distribution. + """ + data = dataset.array + energy = dataset.origin[0] + np.arange(data.shape[0]) * dataset.sampling[0] + + # 1. Mask and find peak per pixel (Vectorized for speed) + search_mask = (energy > -2.0) & (energy < 2.0) + search_energies = energy[search_mask] + peak_indices = np.argmax(data[search_mask, :, :], axis=0) + zlp_map = search_energies[peak_indices] + + # 2. Setup Figure + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 6)) + fig.suptitle(f"QuantEM: {dataset.name} - {title}", fontsize=16) + + # Plot A: Spatial Map of ZLP Shifts + im = ax1.imshow(zlp_map, cmap="RdYlBu_r", origin="lower") + ax1.set_title("Spatial Map of ZLP Positions") + ax1.set_xlabel("X (pixels)") + ax1.set_ylabel("Y (pixels)") + cbar = fig.colorbar(im, ax=ax1) + cbar.set_label("Energy Shift (eV)", rotation=270, labelpad=15) + + # Plot B: Histogram + Gaussian Fit + flat_pos = zlp_map.flatten() + mu, std = norm.fit(flat_pos) + + ax2.hist(flat_pos, bins=30, density=True, alpha=0.6, color="skyblue", ec="white") + + # Fit line display + x_range = np.linspace(np.min(flat_pos), np.max(flat_pos), 100) + ax2.plot( + x_range, + norm.pdf(x_range, mu, std), + color="darkred", + lw=2.5, + label=f"Fit: μ={mu:.3f} eV\nσ={std:.3f} eV", + ) + + ax2.set_title("ZLP Drift Distribution") + ax2.set_xlabel("Energy (eV)") + ax2.set_ylabel("Density") + ax2.legend() + ax2.grid(True, alpha=0.15) + + plt.tight_layout() + plt.show() diff --git a/src/quantem/spectroscopy/thickness.py b/src/quantem/spectroscopy/thickness.py new file mode 100644 index 00000000..6c28b7e2 --- /dev/null +++ b/src/quantem/spectroscopy/thickness.py @@ -0,0 +1,198 @@ +""" +QuantEM EELS Thickness Module +============================= +Tools for calculating specimen thickness from Low-Loss EELS using the Log-Ratio method. +t/λ = ln(I_total / I_ZLP) +""" + +import matplotlib.pyplot as plt +import numpy as np + + +def visualize_thickness_windows(dataset, zlp_window=(-3.0, 3.0), total_window=(-3.0, 75.0)): + """ + Visualizes integration windows for I0 (ZLP) and It (Total). + Returns a configuration dictionary for the calculation step. + """ + # 1. Extract Energy and Mean Spectrum + data = dataset.array + mean_spec = np.mean(data, axis=(1, 2)) + + # Use built-in energy axis if available, else generate from metadata + if hasattr(dataset, "energy_axis"): + energy = dataset.energy_axis + else: + energy = dataset.origin[0] + np.arange(dataset.shape[0]) * dataset.sampling[0] + + # 2. Find indices for the windows + zlp_idx = ( + np.argmin(np.abs(energy - zlp_window[0])), + np.argmin(np.abs(energy - zlp_window[1])), + ) + tot_idx = ( + np.argmin(np.abs(energy - total_window[0])), + np.argmin(np.abs(energy - total_window[1])), + ) + + # 3. Create the Visualization + fig, ax = plt.subplots(figsize=(10, 5)) + ax.plot(energy, mean_spec, "k-", lw=1.5, label="Mean Spectrum", zorder=5) + + # Highlight Windows + z_mask = (energy >= zlp_window[0]) & (energy <= zlp_window[1]) + t_mask = (energy >= total_window[0]) & (energy <= total_window[1]) + + ax.fill_between( + energy[z_mask], 0, mean_spec[z_mask], color="red", alpha=0.3, label="$I_0$ (ZLP)" + ) + ax.fill_between( + energy[t_mask], 0, mean_spec[t_mask], color="blue", alpha=0.1, label="$I_t$ (Total)" + ) + + ax.axvline(0, color="green", lw=1.5, ls=":", label="0 eV") + ax.set_title(f"QuantEM: Integration Windows ({dataset.name})", fontweight="bold") + ax.set_xlabel("Energy Loss (eV)") + ax.set_ylabel("Intensity (counts)") + ax.set_xlim(energy[0], total_window[1] + 20) + ax.legend() + + plt.tight_layout() + plt.show() + + return { + "zlp_idx": zlp_idx, + "total_idx": tot_idx, + "zlp_val": zlp_window, + "total_val": total_window, + } + + +def calculate_thickness_log_ratio(dataset, window_params, plot=True): + """ + Calculates the relative thickness map (t/lambda) using the Log-Ratio method. + """ + data = dataset.array + z_start, z_end = window_params["zlp_idx"] + t_start, t_end = window_params["total_idx"] + + print(f"QuantEM: Calculating thickness for {dataset.name}...") + + # 1. Vectorized Integration + I_zlp = np.sum(data[z_start : z_end + 1, :, :], axis=0) + I_total = np.sum(data[t_start : t_end + 1, :, :], axis=0) + + # 2. Log-Ratio Calculation (with epsilon to avoid log(0)) + epsilon = 1e-10 + t_over_lambda = np.log((I_total + epsilon) / (I_zlp + epsilon)) + + # 3. Data Cleaning + t_over_lambda = np.nan_to_num(t_over_lambda, nan=0.0, posinf=0.0, neginf=0.0) + t_over_lambda = np.clip(t_over_lambda, 0, 4.0) + + if plot: + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) + fig.suptitle(f"Thickness Analysis: {dataset.name}", fontsize=14) + + im = ax1.imshow(t_over_lambda, cmap="viridis", origin="lower") + ax1.set_title(r"Relative Thickness Map ($t/\lambda$)") + plt.colorbar(im, ax=ax1, label=r"$t/\lambda$") + + ax2.hist(t_over_lambda.flatten(), bins=50, color="steelblue", alpha=0.7, ec="k") + ax2.axvline( + np.mean(t_over_lambda), + color="red", + ls="--", + label=f"Mean: {np.mean(t_over_lambda):.2f}", + ) + ax2.set_title("Thickness Distribution") + ax2.set_xlabel(r"$t/\lambda$") + ax2.legend() + + plt.tight_layout() + plt.show() + + return t_over_lambda + + +def interpret_thickness_quality(t_over_lambda, dataset=None): + """ + Performs a scientific quality assessment on the calculated t/lambda map. + """ + name = dataset.name if dataset else "Dataset" + + # Classification Masks + vacuum = t_over_lambda < 0.3 + thin = (t_over_lambda >= 0.3) & (t_over_lambda < 1.0) + medium = (t_over_lambda >= 1.0) & (t_over_lambda < 2.0) + thick = t_over_lambda >= 2.0 + + print(f"\n{'=' * 20} QUANTEM INTERPRETATION: {name} {'=' * 20}") + for label, mask in [ + ("Vacuum (<0.3)", vacuum), + ("Thin (0.3-1.0)", thin), + ("Medium (1.0-2.0)", medium), + ("Thick (>2.0)", thick), + ]: + pct = 100 * np.sum(mask) / t_over_lambda.size + print(f" {label:20}: {pct:5.1f}%") + + # Plotting Classification + classified = np.zeros_like(t_over_lambda) + classified[thin] = 1 + classified[medium] = 2 + classified[thick] = 3 + + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) + + im1 = ax1.imshow(classified, cmap="RdYlGn_r", origin="lower") + ax1.set_title("Region Classification") + cbar = plt.colorbar(im1, ax=ax1, ticks=[0, 1, 2, 3]) + cbar.ax.set_yticklabels(["Vacuum", "Thin", "Medium", "Thick"]) + + t_masked = np.copy(t_over_lambda) + t_masked[vacuum] = np.nan + im2 = ax2.imshow(t_masked, cmap="viridis", origin="lower") + ax2.set_title("Sample-Only Thickness") + plt.colorbar(im2, ax=ax2, label=r"$t/\lambda$") + + plt.tight_layout() + plt.show() + + +def plot_absolute_thickness(t_lambda_map, mfp_nm, dataset=None): + """ + Converts relative thickness to nanometers and visualizes the absolute map. + """ + thickness_nm = t_lambda_map * mfp_nm + name = dataset.name if dataset else "Sample" + + # Mask vacuum for better visualization contrast + display_map = np.copy(thickness_nm) + display_map[t_lambda_map < 0.1] = np.nan + + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) + fig.suptitle(f"Physical Analysis: {name}", fontsize=14) + + im = ax1.imshow(display_map, cmap="magma", origin="lower") + ax1.set_title("Absolute Thickness (nm)") + plt.colorbar(im, ax=ax1, label="nm") + + valid_data = thickness_nm[t_lambda_map >= 0.1].flatten() + ax2.hist(valid_data, bins=50, color="firebrick", alpha=0.7, ec="k") + ax2.axvline( + np.nanmean(display_map), + color="blue", + ls="--", + label=f"Mean: {np.nanmean(display_map):.1f} nm", + ) + ax2.set_title("Physical Distribution") + ax2.set_xlabel("Thickness (nm)") + ax2.legend() + + plt.tight_layout() + plt.show() + + print( + f"\nQuantEM Absolute Report:\n Mean: {np.nanmean(display_map):.2f} nm\n MFP: {mfp_nm:.2f} nm" + ) + return thickness_nm From ccf1517ee7782e36d5c2485218b4394a25e79fdb Mon Sep 17 00:00:00 2001 From: yaeltsarfati Date: Mon, 23 Mar 2026 11:52:15 -0700 Subject: [PATCH 092/136] build: update uv.lock to match pyproject.toml --- uv.lock | 70 --------------------------------------------------------- 1 file changed, 70 deletions(-) diff --git a/uv.lock b/uv.lock index 9629bda4..05052285 100644 --- a/uv.lock +++ b/uv.lock @@ -1306,15 +1306,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "joblib" -version = "1.5.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, -] - [[package]] name = "json5" version = "0.13.0" @@ -2798,7 +2789,6 @@ dependencies = [ { name = "optuna" }, { name = "rosettasciio" }, { name = "scikit-image" }, - { name = "scikit-learn" }, { name = "scipy" }, { name = "tensorboard" }, { name = "torch" }, @@ -2842,7 +2832,6 @@ requires-dist = [ { name = "quantem-widget", marker = "extra == 'widgets'", editable = "widget" }, { name = "rosettasciio", specifier = ">=0.8.0" }, { name = "scikit-image", specifier = ">=0.25.2" }, - { name = "scikit-learn", specifier = ">=1.8.0" }, { name = "scipy" }, { name = "tensorboard", specifier = ">=2.19.0" }, { name = "torch", specifier = ">=2.7.0" }, @@ -3194,56 +3183,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/cd/4da01329b5a8d47ff7ec3c99a2b02465a8017b186027590dc7425cee0b56/scikit_image-0.26.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0608aa4a9ec39e0843de10d60edb2785a30c1c47819b67866dd223ebd149acaf", size = 11769501, upload-time = "2025-12-20T17:12:19.339Z" }, ] -[[package]] -name = "scikit-learn" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "joblib" }, - { name = "numpy" }, - { name = "scipy" }, - { name = "threadpoolctl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" }, - { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" }, - { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" }, - { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" }, - { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" }, - { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, - { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, - { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, - { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, - { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, - { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, - { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, - { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, - { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, - { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, - { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, - { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, - { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, - { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, - { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, - { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, - { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, - { url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" }, - { url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" }, - { url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" }, - { url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" }, - { url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" }, - { url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" }, - { url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" }, - { url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" }, - { url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" }, - { url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" }, -] - [[package]] name = "scipy" version = "1.17.1" @@ -3474,15 +3413,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, ] -[[package]] -name = "threadpoolctl" -version = "3.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, -] - [[package]] name = "tifffile" version = "2026.3.3" From baabca02a958699367bb8a9fd535b528499de272 Mon Sep 17 00:00:00 2001 From: Sangoda <87961379+Sangoda@users.noreply.github.com> Date: Mon, 6 Apr 2026 07:20:51 -0700 Subject: [PATCH 093/136] added modes to autoid, made changes to scoring for more accuracy (wip) --- src/quantem/spectroscopy/dataset3deds.py | 630 +++++++++++++----- .../spectroscopy/dataset3dspectroscopy.py | 47 +- 2 files changed, 501 insertions(+), 176 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index a5cb0898..e2a6ef20 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -223,10 +223,17 @@ def _line_shell(line_name: str) -> str: def _peak_confidence( snr_value: float, line_weight: float, distance_value: float, tolerance: float ) -> float: - quality = max(0.0, 1.0 - (distance_value / max(float(tolerance), 1e-9))) - return np.log1p(max(float(snr_value), 0.0)) * (0.5 + float(line_weight)) * ( - 0.5 + quality - ) + # Use a Gaussian distance likelihood: exp(-0.5 * (d/sigma)^2). + # sigma = tolerance / 3 so the hard cutoff sits at ~3sigma, + # giving a steep penalty for distance while tolerance stays as a + # generous search window. This prevents heavy-element Ma1 lines + # (weight 1.0) from overshadowing lighter-element K-lines at much + # closer distances (e.g. Os Ma1 vs P Ka1 near 2 keV). + sigma = max(float(tolerance) / 3.0, 1e-9) + snr_term = np.log1p(max(float(snr_value), 0.0)) + weight_term = max(float(line_weight), 0.0) + distance_term = np.exp(-0.5 * (float(distance_value) / sigma) ** 2) + return snr_term * weight_term * distance_term @staticmethod def _line_matches_selector(line_name: str, selector: str) -> bool: @@ -402,13 +409,14 @@ def Integrate(self, spec, width=0.15, return_maps=False, show=True, **kwargs): integrated_maps[selector] = arr[selector_mask, :, :].sum(axis=0) if show: + if "roi_px" in kwargs or "roi_cal" in kwargs: + raise ValueError("Use roi (pixel) or roi_units (calibrated). roi_px/roi_cal are not supported") if len(integrated_maps) == 1: selector = next(iter(integrated_maps.keys())) self.show_energy_window_map( energy_window=[energy_min, energy_max], roi=kwargs.pop("roi", None), - roi_px=kwargs.pop("roi_px", None), - roi_cal=kwargs.pop("roi_cal", None), + roi_units=kwargs.pop("roi_units", None), mask=selector_masks[selector], data_type=kwargs.pop("data_type", "eds"), cmap=kwargs.pop("cmap", "magma"), @@ -733,8 +741,7 @@ def clear_spectrum_images_pytorch(self): def peak_autoid( self, roi=None, - roi_px=None, - roi_cal=None, + roi_units=None, energy_range=None, elements=None, refline=None, @@ -742,6 +749,7 @@ def peak_autoid( ignore_range=None, threshold=5.0, tolerance=0.15, + min_line_weight=0.0, mask=None, show_text=True, snr_min=None, @@ -749,6 +757,7 @@ def peak_autoid( distance_threshold_for_sample=0.05, grid_peaks=None, peaks=15, + mode=None, return_details=False, ): """Auto-detect and label EDS peaks from the mean ROI spectrum. @@ -758,6 +767,20 @@ def peak_autoid( Parameters ---------- + min_line_weight : float, optional + Minimum X-ray line weight required for a line to be eligible during + matching and ranking. Set to 0 to allow all lines. + mode : str | None, optional + Controls how element constraints are applied: + - "autofill": auto-ID over all elements while optionally overlaying + requested/saved-model element references. + - "elements_preferred": keep autofill enabled, but bias final + matching toward requested/saved-model elements. + - "elements_only": if elements are provided explicitly or via saved + model, only those elements are considered during matching. + - None: choose context-aware default. If no requested/saved-model + elements are present, defaults to "autofill". If requested or + saved-model elements exist, defaults to "elements_only". return_details : bool, optional If True, return full internal results (matches/confidence/peaks). If False (default), return only figure and axes. @@ -794,10 +817,44 @@ def peak_autoid( if requested_edge_filters is not None: elements = list(requested_edge_filters.keys()) + requested_elements = set(elements) if elements is not None else None + + mode_normalized = None if mode is None else str(mode).strip().lower() + valid_modes = {"autofill", "elements_only", "elements_preferred"} + if mode_normalized is not None and mode_normalized not in valid_modes: + raise ValueError("mode must be one of: autofill, elements_only, elements_preferred") + + if mode_normalized is None: + mode_normalized = "elements_only" if requested_elements is not None else "autofill" + + if mode_normalized == "elements_only" and requested_elements is None: + raise ValueError("mode='elements_only' requires elements to be specified or saved") + if mode_normalized == "elements_preferred" and requested_elements is None: + raise ValueError("mode='elements_preferred' requires elements to be specified or saved") + + match_elements = requested_elements if mode_normalized == "elements_only" else None + preferred_elements_set = ( + set(str(element_name) for element_name in requested_elements) + if mode_normalized == "elements_preferred" and requested_elements is not None + else set() + ) + reference_elements = ( + requested_elements + if mode_normalized in {"elements_only", "autofill", "elements_preferred"} + and requested_elements is not None + else None + ) + if isinstance(ignore_elements, str): ignore_elements = [ignore_elements] if ignore_elements is not None and not isinstance(ignore_elements, (list, tuple, set)): raise TypeError("ignore_elements must be None, a string, or a sequence of strings") + try: + min_line_weight = float(min_line_weight) + except (TypeError, ValueError) as exc: + raise TypeError("min_line_weight must be a number") from exc + if min_line_weight < 0: + raise ValueError("min_line_weight must be >= 0") ignored_elements = ( {str(element_name) for element_name in ignore_elements} if ignore_elements is not None @@ -806,8 +863,7 @@ def peak_autoid( fig, (ax_img, ax_spec) = self.show_mean_spectrum( roi=roi, - roi_px=roi_px, - roi_cal=roi_cal, + roi_units=roi_units, energy_range=energy_range, mask=mask, data_type="eds", @@ -816,8 +872,7 @@ def peak_autoid( spec = self.calculate_mean_spectrum( roi=roi, - roi_px=roi_px, - roi_cal=roi_cal, + roi_units=roi_units, energy_range=energy_range, ignore_range=ignore_range, mask=mask, @@ -833,6 +888,12 @@ def peak_autoid( if ignore_range is None: ignore_range = [0, 0.25] + def _is_in_ignored_range(energy_value: float) -> bool: + if ignore_range is None or len(ignore_range) != 2: + return False + min_ignore, max_ignore = ignore_range + return float(min_ignore) <= float(energy_value) <= float(max_ignore) + peak_indices, peak_properties = find_peaks(spec, height=0, distance=5) peak_heights = peak_properties["peak_heights"] @@ -897,10 +958,8 @@ def peak_autoid( for peak_idx, height in zip(peak_indices, peak_heights): peak_energy = E[peak_idx] - if ignore_range is not None and len(ignore_range) == 2: - min_ignore, max_ignore = ignore_range - if min_ignore <= peak_energy <= max_ignore: - continue + if _is_in_ignored_range(peak_energy): + continue snr = height / background_std all_candidate_peaks.append((peak_idx, height, peak_energy, snr)) @@ -924,10 +983,21 @@ def _line_shell(line_name): return "M" return "?" + def _shell_preference_factor(shell_name): + # Keep K and L comparable; only downweight M lines because they + # are more prone to overlap-driven false assignments. + if shell_name in {"K", "L"}: + return 1.0 + if shell_name == "M": + return 0.72 + return 1.0 + def _peak_confidence(snr_value, line_weight, distance_value): - quality = max(0.0, 1.0 - (distance_value / max(tolerance, 1e-9))) - return ( - np.log1p(max(float(snr_value), 0.0)) * (0.5 + float(line_weight)) * (0.5 + quality) + return type(self)._peak_confidence( + snr_value=snr_value, + line_weight=line_weight, + distance_value=distance_value, + tolerance=tolerance, ) def _line_matches_selector(line_name: str, selector: str) -> bool: @@ -947,11 +1017,12 @@ def _line_allowed_for_element(element_name, line_name, edge_filters=None): return any(_line_matches_selector(line_name, token) for token in selectors) - def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): - best_distance = float("inf") + def _best_line_match(peak_energy, peak_snr, allowed_elements=None, edge_filters=None): + best_score = -float("inf") best_element = None best_line_name = None best_line_weight = 0.0 + best_distance = float("inf") if not all_info: return None @@ -966,30 +1037,36 @@ def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): line_energy = line_info["energy (keV)"] line_weight = line_info.get("weight", 0.5) distance = abs(peak_energy - line_energy) + shell = _line_shell(line_name) is_m_line = "M" in line_name and not ("Ma" in line_name or "Mb" in line_name) effective_tolerance = tolerance * 0.5 if is_m_line else tolerance if ( - line_weight > 0.3 + line_weight >= min_line_weight and distance <= effective_tolerance - and distance < best_distance ): - best_distance = distance - best_element = element_name - best_line_name = line_name - best_line_weight = line_weight + # Use score-based ranking: confidence with weight and distance + # This matches rematcher logic and avoids distance-driven artifacts + score = _peak_confidence(peak_snr, line_weight, distance) + score *= _shell_preference_factor(shell) + if score > best_score: + best_score = score + best_element = element_name + best_line_name = line_name + best_line_weight = line_weight + best_distance = distance if best_element is None: return None return best_element, best_line_name, best_line_weight, best_distance - search_elements = set(elements) if elements is not None else None + search_elements = match_elements for peak_idx, height, peak_energy, snr in display_peaks: best_match_info = _best_line_match( - peak_energy, search_elements, requested_edge_filters + peak_energy, snr, search_elements, requested_edge_filters ) if best_match_info is not None: best_element, best_line_name, best_line_weight, best_distance = best_match_info @@ -1016,6 +1093,7 @@ def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): detected_sample_peaks = {} element_confidence = {} element_stats = {} + line_evidence = {} for ( peak_idx, height, @@ -1034,6 +1112,7 @@ def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): continue shell = _line_shell(line_name) + line_label = f"{element_name} {line_name}" if element_name not in element_stats: element_stats[element_name] = { "raw_conf": 0.0, @@ -1049,6 +1128,15 @@ def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): "best_match_shell": "?", } + if line_label not in line_evidence: + line_evidence[line_label] = { + "match_count": 0, + "strong_matches": 0, + "best_conf": 0.0, + "best_snr": 0.0, + "energies": [], + } + element_stats[element_name]["raw_conf"] += float(match_confidence) element_stats[element_name]["shells"].add(shell) element_stats[element_name]["lines"].add(str(line_name)) @@ -1064,40 +1152,84 @@ def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): element_stats[element_name]["best_match_weight"] = float(line_weight) element_stats[element_name]["best_match_shell"] = shell + line_evidence[line_label]["match_count"] += 1 + line_evidence[line_label]["energies"].append(float(peak_energy)) + if snr > snr_threshold_for_sample and distance < distance_threshold_for_sample: + line_evidence[line_label]["strong_matches"] += 1 + if float(match_confidence) > float(line_evidence[line_label]["best_conf"]): + line_evidence[line_label]["best_conf"] = float(match_confidence) + line_evidence[line_label]["best_snr"] = float(snr) + for element_name, stats in element_stats.items(): valid_shells = {shell for shell in stats["shells"] if shell in {"K", "L", "M"}} num_shells = len(valid_shells) num_lines = len(stats["lines"]) has_major_shell = len(valid_shells.intersection({"K", "L"})) > 0 - shell_bonus = 1.0 + 0.45 * max(0, num_shells - 1) - line_bonus = 1.0 + 0.15 * max(0, min(num_lines, 4) - 1) - strong_bonus = 1.0 + 0.20 * stats["strong_matches"] - major_bonus = 1.15 if has_major_shell else 1.0 + # Shell bonus: each additional confirmed shell is independent evidence. + # Two independent shells give P(element|K∧L) ∝ P_K · P_L, so the + # log-likelihood adds linearly and the likelihood ratio scales as + # sqrt(n_shells) in the geometric-mean sense (Jaynes, Prob. Theory). + shell_bonus = float(np.sqrt(max(1, num_shells))) + + # Line bonus: each additional distinct matched line reduces the + # probability of a chance coincidence. Diminishing returns are + # captured by log1p (each doubling of evidence adds a fixed increment). + line_bonus = 1.0 + 0.30 * float(np.log1p(max(0, num_lines - 1))) + + # Strong-match bonus: the Poisson false-peak rate at SNR > T scales + # as erfc(T/√2); n independent strong peaks compound multiplicatively + # so log1p(n) correctly models the diminishing-returns likelihood ratio. + strong_bonus = 1.0 + 0.40 * float(np.log1p(stats["strong_matches"])) + + # K/L shell presence is strong physical prior: these are the primary + # transitions expected in any sample above Z≈10. + major_bonus = 1.20 if has_major_shell else 1.0 confidence = stats["raw_conf"] * shell_bonus * line_bonus * strong_bonus * major_bonus element_confidence[element_name] = float(confidence) if element_confidence: conf_values = np.array(list(element_confidence.values()), dtype=float) + # Absolute Poisson MDL floor (Currie 1968, Anal. Chem. 40:586): + # a peak is physically detectable only when SNR >= 3σ of background. + # The minimum confidence a single real peak can produce is: + # log1p(3.0) * 0.5 * exp(-0.5*(1σ distance)²) ≈ 0.334 + # (half-weight line, 1-sigma spatial offset, SNR=3). + # No element below this floor can have a statistically detectable peak. + poisson_mdl_snr = 3.0 + _mdl_conf_floor = ( + float(np.log1p(poisson_mdl_snr)) * 0.5 * float(np.exp(-0.5)) + ) confidence_cutoff = max( - np.percentile(conf_values, 45), 0.30 * float(conf_values.max()) + float(np.percentile(conf_values, 45)), + 0.30 * float(conf_values.max()), + _mdl_conf_floor, ) for element_name, confidence in element_confidence.items(): stats = element_stats[element_name] valid_shells = {shell for shell in stats["shells"] if shell in {"K", "L", "M"}} has_major_shell = len(valid_shells.intersection({"K", "L"})) > 0 + # is_supported: best peak must clear the 3σ Poisson MDL + # (Currie 1968) — any peak below SNR=3 cannot be distinguished + # from background noise regardless of fit quality. is_supported = ( confidence >= confidence_cutoff + and stats["best_match_snr"] >= poisson_mdl_snr and ( stats["strong_matches"] >= 1 or stats["best_match_snr"] >= max(min_snr, 0.6 * snr_threshold_for_sample) ) ) + # is_near_cutoff_but_consistent: two independent peaks above the + # 3σ MDL have a joint false-positive probability ≈ p₁·p₂ ≈ negligible + # (akin to requiring both the critical level Lc and detection limit Ld + # to be satisfied on independent lines — Currie 1968, §IV). is_near_cutoff_but_consistent = ( confidence >= 0.75 * confidence_cutoff + and stats["best_match_snr"] >= poisson_mdl_snr and stats["match_count"] >= 2 and has_major_shell and stats["best_match_snr"] >= max(min_snr, 0.5 * snr_threshold_for_sample) @@ -1140,6 +1272,109 @@ def _best_line_match(peak_energy, allowed_elements=None, edge_filters=None): if len(detected_elements) > 0 else 0.0 ) + dominant_elements = set() + if element_confidence: + conf_values = np.array(list(element_confidence.values()), dtype=float) + conf_median = float(np.median(conf_values)) if len(conf_values) > 0 else 0.0 + conf_p80 = float(np.percentile(conf_values, 80)) if len(conf_values) > 1 else 0.0 + conf_floor = max(conf_median, 1e-9) + + for element_name, confidence in element_confidence.items(): + stats = element_stats.get(str(element_name), {}) + has_repeat_support = ( + int(stats.get("match_count", 0)) >= 2 + or int(stats.get("strong_matches", 0)) >= 1 + ) + if ( + float(confidence) >= conf_p80 + and float(confidence) >= 1.8 * conf_floor + and has_repeat_support + ): + dominant_elements.add(str(element_name)) + + def _element_prior_factor(element_name, denom): + prior = float(element_confidence.get(element_name, 0.0)) / denom + prior_factor = 1.0 + 0.5 * prior + + # When an element is already strongly supported by the spectrum, + # bias nearby ambiguous peaks toward that same element. + if prior >= 0.90: + prior_factor *= 1.9 + elif prior >= 0.75: + prior_factor *= 1.5 + elif prior >= 0.55: + prior_factor *= 1.2 + + return prior, prior_factor + + def _line_consistency_boost(element_name, line_name, peak_energy, denom): + # Use dominant_elements membership rather than a normalized-prior + # threshold. The normalized prior can be suppressed when one or + # two elements have very large confidence (e.g. Au driving the + # denominator high), causing legitimate dominant elements like Cu + # to silently fail the old `prior >= 0.75` gate even though they + # clearly belong in the sample. + if str(element_name) not in dominant_elements: + return 1.0 + + line_label = f"{element_name} {line_name}" + evidence = line_evidence.get(line_label) + if evidence is None: + return 1.0 + + # Require support from another peak for this exact line so we + # don't self-reinforce a single spurious match. + has_other_peak_support = any( + abs(float(peak_energy) - float(prev_energy)) >= 0.04 + for prev_energy in evidence.get("energies", []) + ) + if not has_other_peak_support: + return 1.0 + + best_line_conf = float(evidence.get("best_conf", 0.0)) + best_line_snr = float(evidence.get("best_snr", 0.0)) + strong_line_matches = int(evidence.get("strong_matches", 0)) + + # Scale by line weight: high-weight primary lines (K/L α, weight + # ≈ 0.7–1.0) get proportionally stronger boosts than low-weight + # secondary lines (weight < 0.4). This prevents a low-weight + # line from an equally-dominant element from outscoring a + # confirmed primary line purely via prior amplification. + line_info_entry = (all_info or {}).get(str(element_name), {}).get(str(line_name), {}) + line_weight = float(line_info_entry.get("weight", 0.5)) if isinstance(line_info_entry, dict) else 0.5 + weight_tier = 1.0 + 0.7 * max(0.0, line_weight - 0.35) + + if strong_line_matches >= 1 and best_line_conf >= 1.4: + return min(3.2, 2.4 * weight_tier) + if best_line_conf >= 1.1 and best_line_snr >= max(min_snr, 0.75 * snr_threshold_for_sample): + return min(2.6, 1.9 * weight_tier) + if best_line_conf >= 0.8: + return min(2.0, 1.5 * weight_tier) + return min(1.5, 1.2 * weight_tier) + + def _dominant_element_boost(element_name, denom): + element_key = str(element_name) + if element_key not in dominant_elements: + return 1.0 + + prior, _ = _element_prior_factor(element_key, denom) + stats = element_stats.get(element_key, {}) + repeat_support = max( + int(stats.get("strong_matches", 0)), + max(0, int(stats.get("match_count", 0)) - 1), + ) + + if prior >= 0.90: + base_boost = 2.30 + elif prior >= 0.75: + base_boost = 1.85 + else: + base_boost = 1.45 + + if repeat_support >= 2: + base_boost *= 1.10 + + return min(base_boost, 2.60) def _best_supported_line_match_with_prior( peak_energy, snr, allowed_elements, edge_filters=None @@ -1149,14 +1384,16 @@ def _best_supported_line_match_with_prior( best_tuple = None best_score = -float("inf") + best_preferred_tuple = None + best_preferred_score = -float("inf") denom = max(float(max_detected_conf), 1e-9) for element_name, lines in all_info.items(): if element_name not in allowed_elements: continue - prior = float(element_confidence.get(element_name, 0.0)) / denom - prior_factor = 1.0 + 0.5 * prior + prior, prior_factor = _element_prior_factor(element_name, denom) + preferred_factor = 1.35 if str(element_name) in preferred_elements_set else 1.0 for line_name, line_info in lines.items(): if not _line_allowed_for_element(element_name, line_name, edge_filters): @@ -1169,22 +1406,55 @@ def _best_supported_line_match_with_prior( is_m_line = "M" in line_name and not ("Ma" in line_name or "Mb" in line_name) effective_tolerance = tolerance * 0.5 if is_m_line else tolerance - if line_weight <= 0.3 or distance > effective_tolerance: + if line_weight < min_line_weight or distance > effective_tolerance: continue local_conf = _peak_confidence(snr, line_weight, distance) anchor_boost = 1.0 - if element_name in anchor_elements and shell == "M" and peak_energy <= 3.0: - anchor_boost = 2.2 - elif element_name in anchor_elements and shell in {"K", "L"}: + if element_name in anchor_elements and shell in {"K", "L"}: anchor_boost = 1.15 - score = local_conf * prior_factor * anchor_boost + consistency_boost = _line_consistency_boost( + element_name, line_name, peak_energy, denom + ) + dominant_boost = _dominant_element_boost(element_name, denom) + + # M-lines are secondary transitions confirmed by K/L lines + # from the same element. The large cascade boosts designed + # for K/L cross-peak propagation must not override a + # better-fitting primary K/L line from another element + # (e.g. P Ka1 vs Pt Ma1 near 2.03 keV: P Ka1 has higher + # local_conf but Pt is dominant via La1 at 9.47 keV). + if shell == "M": + eff_prior_factor = 1.0 + 0.3 * prior + eff_consistency = 1.0 + eff_dominant = min(dominant_boost, 1.30) + else: + eff_prior_factor = prior_factor + eff_consistency = consistency_boost + eff_dominant = dominant_boost + + score = ( + local_conf + * eff_prior_factor + * anchor_boost + * preferred_factor + * eff_consistency + * eff_dominant + ) + score *= _shell_preference_factor(shell) + + if str(element_name) in preferred_elements_set and score > best_preferred_score: + best_preferred_score = score + best_preferred_tuple = (element_name, line_name, line_weight, distance) if score > best_score: best_score = score best_tuple = (element_name, line_name, line_weight, distance) + if mode_normalized == "elements_preferred" and best_preferred_tuple is not None: + return best_preferred_tuple + return best_tuple def _top_supported_line_matches_with_prior( @@ -1200,8 +1470,8 @@ def _top_supported_line_matches_with_prior( if allowed_elements is not None and element_name not in allowed_elements: continue - prior = float(element_confidence.get(element_name, 0.0)) / denom - prior_factor = 1.0 + 0.5 * prior + prior, prior_factor = _element_prior_factor(element_name, denom) + preferred_factor = 1.35 if str(element_name) in preferred_elements_set else 1.0 for line_name, line_info in lines.items(): if not _line_allowed_for_element(element_name, line_name, edge_filters): @@ -1214,22 +1484,53 @@ def _top_supported_line_matches_with_prior( is_m_line = "M" in line_name and not ("Ma" in line_name or "Mb" in line_name) effective_tolerance = tolerance * 0.5 if is_m_line else tolerance - if line_weight <= 0.3 or distance > effective_tolerance: + if line_weight < min_line_weight or distance > effective_tolerance: continue local_conf = _peak_confidence(snr, line_weight, distance) anchor_boost = 1.0 - if element_name in anchor_elements and shell == "M" and peak_energy <= 3.0: - anchor_boost = 2.2 - elif element_name in anchor_elements and shell in {"K", "L"}: + if element_name in anchor_elements and shell in {"K", "L"}: anchor_boost = 1.15 - score = local_conf * prior_factor * anchor_boost + consistency_boost = _line_consistency_boost( + element_name, line_name, peak_energy, denom + ) + dominant_boost = _dominant_element_boost(element_name, denom) + + if shell == "M": + eff_prior_factor = 1.0 + 0.3 * prior + eff_consistency = 1.0 + eff_dominant = min(dominant_boost, 1.30) + else: + eff_prior_factor = prior_factor + eff_consistency = consistency_boost + eff_dominant = dominant_boost + + score = ( + local_conf + * eff_prior_factor + * anchor_boost + * preferred_factor + * eff_consistency + * eff_dominant + ) + score *= _shell_preference_factor(shell) scored_matches.append( (float(score), str(element_name), str(line_name), float(line_weight), float(distance)) ) scored_matches.sort(key=lambda item: item[0], reverse=True) + + if mode_normalized == "elements_preferred" and preferred_elements_set: + preferred_scored_matches = [ + item for item in scored_matches if str(item[1]) in preferred_elements_set + ] + if len(preferred_scored_matches) > 0: + non_preferred_scored_matches = [ + item for item in scored_matches if str(item[1]) not in preferred_elements_set + ] + scored_matches = preferred_scored_matches + non_preferred_scored_matches + unique = [] seen_labels = set() for score, element_name, line_name, line_weight, distance in scored_matches: @@ -1272,12 +1573,22 @@ def _top_supported_line_matches_with_prior( return selected + first_pass_elements = { + str(match[4]) + for match in peak_matches + if len(match) > 4 and str(match[4]) not in ignored_elements + } + rematch_allowed_elements = set(str(element_name) for element_name in detected_elements) + rematch_allowed_elements.update(first_pass_elements) + if preferred_elements_set: + rematch_allowed_elements.update(preferred_elements_set) + for peak_idx, height, peak_energy, snr in display_peaks: match = raw_match_by_idx.get(int(peak_idx)) - if detected_elements: + if rematch_allowed_elements: alt_match_info = _best_supported_line_match_with_prior( - peak_energy, snr, detected_elements, requested_edge_filters + peak_energy, snr, rematch_allowed_elements, requested_edge_filters ) if alt_match_info is not None: alt_element, alt_line_name, alt_line_weight, alt_distance = alt_match_info @@ -1311,6 +1622,12 @@ def _top_supported_line_matches_with_prior( for element_name in detected_elements if str(element_name) in matched_elements } + if mode_normalized == "elements_preferred" and preferred_elements_set: + detected_elements.update( + str(element_name) + for element_name in preferred_elements_set + if str(element_name) in matched_elements + ) if ignored_elements: detected_elements = { str(element_name) @@ -1320,14 +1637,49 @@ def _top_supported_line_matches_with_prior( refined_match_by_idx = {int(match[0]): match for match in peak_matches} + final_matches_by_element: dict[str, set[str]] = {} + for ( + _peak_idx, + _height, + _peak_energy, + _snr, + element_name, + _match_str, + _distance, + line_name, + _line_weight, + _match_confidence, + ) in peak_matches: + element_key = str(element_name) + if element_key in ignored_elements: + continue + final_matches_by_element.setdefault(element_key, set()).add(str(line_name)) + displayed_peak_count = len(display_peaks) total_over_snr_peak_count = len(significant_peaks) candidate_elements = sorted( str(element_name) - for element_name in set(element_stats.keys()) + for element_name in final_matches_by_element if str(element_name) not in detected_elements ) + possible_elements_set = set(candidate_elements) + possible_line_labels = { + f"{str(element_name)} {str(line_name)}" + for ( + _, + _, + _, + _, + element_name, + _, + _, + line_name, + _, + _, + ) in peak_matches + if str(element_name) in possible_elements_set + } def _format_saved_model_elements(edge_filters): if edge_filters is None: @@ -1348,13 +1700,17 @@ def _format_saved_model_elements(edge_filters): def _format_elements_with_lines(element_names): formatted = [] for element_name in sorted(str(name) for name in element_names): - stats = element_stats.get(str(element_name), {}) - lines = stats.get("lines", set()) - line_names = sorted(str(line_name) for line_name in lines) + line_names = sorted(str(line_name) for line_name in final_matches_by_element.get( + str(element_name), set() + )) + confidence_value = float(element_confidence.get(str(element_name), 0.0)) + confidence_suffix = f" conf={confidence_value:.2f}" if len(line_names) > 0: - formatted.append(f"{element_name} [{', '.join(line_names)}]") + formatted.append( + f"{element_name} [{', '.join(line_names)}]{confidence_suffix}" + ) else: - formatted.append(str(element_name)) + formatted.append(f"{str(element_name)}{confidence_suffix}") return ", ".join(formatted) model_elements_header = "Saved Model Elements (Plotted):\n" @@ -1366,6 +1722,17 @@ def _format_elements_with_lines(element_names): print(f"\nAutodetected: {_format_elements_with_lines(detected_elements)}") else: print("\nAutodetected: None") + if dominant_elements: + dominant_sorted = sorted( + dominant_elements, + key=lambda el: element_confidence.get(str(el), 0.0), + reverse=True, + ) + dominant_str = ", ".join( + f"{el} (conf={element_confidence.get(str(el), 0.0):.2f})" + for el in dominant_sorted + ) + print(f"Dominant (strong prior): {dominant_str}") if candidate_elements: print(f"Possible: {_format_elements_with_lines(candidate_elements)}") else: @@ -1414,6 +1781,11 @@ def _mark_autodetected_label(label_text): return f"{label_text}*" return label_text + def _format_label_with_score(label_text, score_value): + if score_value is None: + return str(label_text) + return f"{label_text} ({float(score_value):.3f})" + for peak_idx, height, peak_energy, snr in display_peaks: match = refined_match_by_idx.get(int(peak_idx)) if match is not None: @@ -1435,20 +1807,40 @@ def _mark_autodetected_label(label_text): requested_edge_filters, top_k=3, ) - best_match = _mark_autodetected_label(str(match[5])) + best_label = f"{str(match[4])} {str(match[7])}" + score_by_label = { + f"{element_name} {line_name}".lower(): float(score) + for element_name, line_name, _, _, score in top_matches + } + best_score = score_by_label.get(str(best_label).lower()) + best_match = _mark_autodetected_label( + _format_label_with_score(best_label, best_score) + ) alt_2 = "-" alt_3 = "-" if len(top_matches) > 0: - ranked_labels = [ - f"{element_name} {line_name}" - for element_name, line_name, _, _, _ in top_matches + ranked_entries = [ + (f"{element_name} {line_name}", float(score)) + for element_name, line_name, _, _, score in top_matches + ] + ranked_entries = [ + (label, score) + for label, score in ranked_entries + if str(label).lower() != str(best_label).lower() ] - best_match = _mark_autodetected_label(ranked_labels[0]) - if len(ranked_labels) > 1: - alt_2 = _mark_autodetected_label(ranked_labels[1]) - if len(ranked_labels) > 2: - alt_3 = _mark_autodetected_label(ranked_labels[2]) + if len(ranked_entries) > 0: + alt_2 = _mark_autodetected_label( + _format_label_with_score( + ranked_entries[0][0], ranked_entries[0][1] + ) + ) + if len(ranked_entries) > 1: + alt_3 = _mark_autodetected_label( + _format_label_with_score( + ranked_entries[1][0], ranked_entries[1][1] + ) + ) table_rows.append((peak_energy, height, snr, best_match, alt_2, alt_3)) matched_row_count += 1 @@ -1499,12 +1891,12 @@ def _mark_autodetected_label(label_text): y_dot = -0.04 * y_scale def _infer_requested_element_for_color(peak_energy): - if search_elements is None or not all_info: + if reference_elements is None or not all_info: return None best_element = None best_distance = float("inf") - for element_name in search_elements: + for element_name in reference_elements: element_key = str(element_name) lines_info = all_info.get(element_key, {}) if not isinstance(lines_info, dict): @@ -1530,9 +1922,11 @@ def _infer_requested_element_for_color(peak_energy): return best_element for peak_idx, height, peak_energy, snr in display_peaks: + if _is_in_ignored_range(peak_energy): + continue is_sample = detected_sample_peaks.get(peak_energy, False) match = refined_match_by_idx.get(int(peak_idx)) - is_possible = match is not None and str(match[4]) not in detected_elements + is_possible = match is not None and str(match[4]) in possible_elements_set if match is not None: peak_element = match[4] line_color = element_color_map.get(peak_element, "red") @@ -1598,13 +1992,12 @@ def _infer_requested_element_for_color(peak_energy): # If elements were explicitly requested, overlay reference X-ray lines from the # database even when they are not peak-matched by auto-id. all_label_candidates = [] - if search_elements is not None: + unified_axes_top_label_y = 0.92 + if reference_elements is not None: energy_min = float(np.min(E)) energy_max = float(np.max(E)) displayed_peak_energies = [float(peak_energy) for _, _, peak_energy, _ in display_peaks] display_peak_tolerance = max(0.05, 0.5 * tolerance) - possible_elements_set = set(str(element_name) for element_name in candidate_elements) - reference_label_counts = {} existing_matches_by_element = {} for ( peak_idx, @@ -1623,12 +2016,8 @@ def _infer_requested_element_for_color(peak_energy): existing_matches_by_element[element_key] = [] existing_matches_by_element[element_key].append(float(peak_energy)) - for element_name in sorted(search_elements): + for element_name in sorted(reference_elements): element_key = str(element_name) - is_reference_only = ( - element_key not in detected_elements - and element_key not in possible_elements_set - ) lines_info = all_info.get(element_key, {}) if all_info is not None else {} if not isinstance(lines_info, dict) or len(lines_info) == 0: continue @@ -1669,10 +2058,16 @@ def _infer_requested_element_for_color(peak_energy): )[:6] for line_name, line_energy, line_weight in filtered_lines: + if _is_in_ignored_range(line_energy): + continue + + line_label = f"{element_key} {line_name}" + is_possible_line = line_label in possible_line_labels + # For possible elements, draw guides only near peaks that already # passed snr_min. For reference-only requested elements, keep # explicit refline guides even without nearby peaks. - if not is_reference_only: + if is_possible_line: if not any( abs(line_energy - peak_energy) <= display_peak_tolerance for peak_energy in displayed_peak_energies @@ -1687,8 +2082,8 @@ def _infer_requested_element_for_color(peak_energy): continue line_color = element_color_map.get(element_key, "black") - line_style = ":" if is_reference_only else "--" - line_alpha = 0.35 if is_reference_only else 0.3 + line_style = "--" if is_possible_line else ":" + line_alpha = 0.3 if is_possible_line else 0.35 ax_spec.axvline( line_energy, color=line_color, @@ -1696,17 +2091,13 @@ def _infer_requested_element_for_color(peak_energy): alpha=line_alpha, linewidth=1.2, ) - label_index = reference_label_counts.get(element_key, 0) - reference_label_counts[element_key] = label_index + 1 - # Keep dashed reference labels inside the axes near the top border. - y_label_axes = 0.98 - 0.06 * (label_index % 3) all_label_candidates.append( ( float(line_energy), f"{element_key} {line_name}", line_color, line_style, - float(y_label_axes), + float(unified_axes_top_label_y), "axes_top", 8, "normal", @@ -1734,7 +2125,7 @@ def _infer_requested_element_for_color(peak_energy): is_detected_peak = element_name in detected_elements and detected_sample_peaks.get( peak_energy, False ) - is_possible_peak = element_name not in detected_elements + is_possible_peak = str(element_name) in possible_elements_set if not (is_detected_peak or is_possible_peak): continue @@ -1748,6 +2139,8 @@ def _infer_requested_element_for_color(peak_energy): label_vertical_offset = max(0.03 * y_scale, 0.08) for peak_energy, label_text, color, height, linestyle in labels_to_plot: + if _is_in_ignored_range(peak_energy): + continue if linestyle == "--": all_label_candidates.append( ( @@ -1755,7 +2148,7 @@ def _infer_requested_element_for_color(peak_energy): label_text, color, linestyle, - 0.90, + float(unified_axes_top_label_y), "axes_top", 9, "normal", @@ -1780,33 +2173,18 @@ def _infer_requested_element_for_color(peak_energy): if show_text and all_label_candidates: all_label_candidates.sort(key=lambda item: item[0]) - overlap_threshold = max(0.10, 0.7 * float(tolerance)) - same_label_overlap_threshold = max(0.16, 1.1 * float(tolerance)) - same_color_overlap_threshold = max(0.22, 1.6 * float(tolerance)) + overlap_threshold = max(0.16, 1.10 * float(tolerance)) grouped_labels = [] current_group = [] - def _same_color(c1, c2): - try: - return np.allclose(np.asarray(c1), np.asarray(c2)) - except Exception: - return str(c1) == str(c2) - for label in all_label_candidates: if not current_group: current_group.append(label) continue prev_energy = current_group[-1][0] - prev_text = current_group[-1][1] - prev_color = current_group[-1][2] energy_delta = abs(label[0] - prev_energy) - should_group = energy_delta <= overlap_threshold - if not should_group and label[1] == prev_text: - should_group = energy_delta <= same_label_overlap_threshold - if not should_group and _same_color(label[2], prev_color): - should_group = energy_delta <= same_color_overlap_threshold if should_group: current_group.append(label) @@ -1883,56 +2261,10 @@ def _same_color(c1, c2): fontsize=8, title="Overlapping Labels", ) - - style_legend_handles = [ - Line2D( - [0], - [0], - color="gray", - linestyle="None", - marker="|", - markersize=8, - markeredgewidth=1.5, - label="Gray tick: above snr_min, unmatched", - ), - Line2D( - [0], - [0], - color="black", - linestyle="--", - linewidth=1.5, - label="Dashed line: possible", - ), - Line2D( - [0], - [0], - color="black", - linestyle=":", - linewidth=1.5, - label="Dotted line: requested refline", - ), - Line2D( - [0], - [0], - color="black", - linestyle="-", - linewidth=1.5, - label="Solid line: autodetected", - ), - ] - style_legend = ax_spec.legend( - handles=style_legend_handles, - loc="upper center", - bbox_to_anchor=(0.5, -0.16), - ncol=4, - fontsize=8, - frameon=True, - title="Peak Marker Guide", - ) if overlap_legend is not None: ax_spec.add_artist(overlap_legend) - fig.tight_layout(rect=[0, 0.08, 1, 1]) + fig.tight_layout() plt.show() print( diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 0cbf773e..e9ce20ba 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -608,24 +608,22 @@ def _validate_roi_bounds(self, y, x, dy, dx): if errs: raise ValueError("Invalid ROI:\n - " + "\n - ".join(errs)) - def _resolve_roi_px(self, roi=None, roi_px=None, roi_cal=None): - selector_count = int(roi is not None) + int(roi_px is not None) + int(roi_cal is not None) + def _resolve_roi(self, roi=None, roi_units=None): + selector_count = int(roi is not None) + int(roi_units is not None) if selector_count > 1: - raise ValueError("Use only one ROI selector: roi, roi_px, or roi_cal") + raise ValueError("Use only one ROI selector: roi or roi_units") - if roi_px is not None: - roi_spec = roi_px - elif roi is not None: + if roi is not None: roi_spec = roi - elif roi_cal is not None: - if len(roi_cal) == 2: - y_cal, x_cal = roi_cal + elif roi_units is not None: + if len(roi_units) == 2: + y_cal, x_cal = roi_units roi_spec = [ self._calibrated_position_to_pixel(y_cal, axis=1), self._calibrated_position_to_pixel(x_cal, axis=2), ] - elif len(roi_cal) == 4: - y_cal, x_cal, dy_cal, dx_cal = roi_cal + elif len(roi_units) == 4: + y_cal, x_cal, dy_cal, dx_cal = roi_units roi_spec = [ self._calibrated_position_to_pixel(y_cal, axis=1), self._calibrated_position_to_pixel(x_cal, axis=2), @@ -633,7 +631,7 @@ def _resolve_roi_px(self, roi=None, roi_px=None, roi_cal=None): self._calibrated_span_to_pixels(dx_cal, axis=2), ] else: - raise ValueError("roi_cal must be [y, x] or [y, x, dy, dx]") + raise ValueError("roi_units must be [y, x] or [y, x, dy, dx]") else: roi_spec = None @@ -650,7 +648,7 @@ def _resolve_roi_px(self, roi=None, roi_px=None, roi_cal=None): dx = int(self.shape[2]) - x if dx_val is None else int(dx_val) else: raise ValueError( - "ROI must be None, [y, x], or [y, x, dy, dx]. Use one selector: roi, roi_px, roi_cal" + "ROI must be None, [y, x], or [y, x, dy, dx]. Use one selector: roi or roi_units" ) self._validate_roi_bounds(y, x, dy, dx) @@ -663,10 +661,9 @@ def calculate_mean_spectrum( ignore_range=None, mask=None, attach_mean_spectrum=True, - roi_px=None, - roi_cal=None, + roi_units=None, ): - y, x, dy, dx = self._resolve_roi_px(roi=roi, roi_px=roi_px, roi_cal=roi_cal) + y, x, dy, dx = self._resolve_roi(roi=roi, roi_units=roi_units) # SPECTRUM CALCULATION -------------------------------------------------------------- @@ -742,8 +739,7 @@ def calculate_mean_spectrum( def show_mean_spectrum( self, roi=None, - roi_px=None, - roi_cal=None, + roi_units=None, energy_range=None, mask=None, data_type=None, @@ -802,12 +798,11 @@ def show_mean_spectrum( # CALCULATE MEAN SPECTRUM FOR GIVEN ROI AND ENERGY RANGE -------------------------- - y, x, dy, dx = self._resolve_roi_px(roi=roi, roi_px=roi_px, roi_cal=roi_cal) + y, x, dy, dx = self._resolve_roi(roi=roi, roi_units=roi_units) spec = self.calculate_mean_spectrum( roi=roi, - roi_px=roi_px, - roi_cal=roi_cal, + roi_units=roi_units, energy_range=energy_range, mask=mask, ) @@ -986,8 +981,7 @@ def show_energy_window_map( self, energy_window=None, roi=None, - roi_px=None, - roi_cal=None, + roi_units=None, mask=None, data_type="eds", cmap="viridis", @@ -1022,8 +1016,8 @@ def show_energy_window_map( tuple ``(fig, (ax_map, ax_spec), energy_map)`` where ``energy_map`` is the integrated 2D array. """ - y, x, dy, dx = self._resolve_roi_px(roi=roi, roi_px=roi_px, roi_cal=roi_cal) - has_roi_overlay = any(val is not None for val in (roi, roi_px, roi_cal)) + y, x, dy, dx = self._resolve_roi(roi=roi, roi_units=roi_units) + has_roi_overlay = any(val is not None for val in (roi, roi_units)) dE = float(self.sampling[0]) E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 @@ -1060,8 +1054,7 @@ def show_energy_window_map( spec = self.calculate_mean_spectrum( roi=roi, - roi_px=roi_px, - roi_cal=roi_cal, + roi_units=roi_units, mask=mask, attach_mean_spectrum=False, ) From fccb83bd7b88adc91cb448238bca0b490718bd89 Mon Sep 17 00:00:00 2001 From: smribet Date: Mon, 6 Apr 2026 15:28:03 -0700 Subject: [PATCH 094/136] fixing mean_spectrum --- src/quantem/spectroscopy/dataset3deds.py | 74 ++++++----- src/quantem/spectroscopy/dataset3deels.py | 2 +- .../spectroscopy/dataset3dspectroscopy.py | 119 +++++++++--------- 3 files changed, 104 insertions(+), 91 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index e2a6ef20..78b44b51 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -72,6 +72,8 @@ def __init__( _token=_token, ) + self.dataset_type = "eds" + @staticmethod def _normalize_specs(specs, param_name="spec", allow_none=False): if specs is None and allow_none: @@ -401,8 +403,7 @@ def Integrate(self, spec, width=0.15, return_maps=False, show=True, **kwargs): if not np.any(selector_mask): raise ValueError( - f"No energy channels selected for selector '{selector}'. " - "Try increasing width." + f"No energy channels selected for selector '{selector}'. Try increasing width." ) selector_masks[selector] = selector_mask @@ -410,7 +411,9 @@ def Integrate(self, spec, width=0.15, return_maps=False, show=True, **kwargs): if show: if "roi_px" in kwargs or "roi_cal" in kwargs: - raise ValueError("Use roi (pixel) or roi_units (calibrated). roi_px/roi_cal are not supported") + raise ValueError( + "Use roi (pixel) or roi_units (calibrated). roi_px/roi_cal are not supported" + ) if len(integrated_maps) == 1: selector = next(iter(integrated_maps.keys())) self.show_energy_window_map( @@ -830,7 +833,9 @@ def peak_autoid( if mode_normalized == "elements_only" and requested_elements is None: raise ValueError("mode='elements_only' requires elements to be specified or saved") if mode_normalized == "elements_preferred" and requested_elements is None: - raise ValueError("mode='elements_preferred' requires elements to be specified or saved") + raise ValueError( + "mode='elements_preferred' requires elements to be specified or saved" + ) match_elements = requested_elements if mode_normalized == "elements_only" else None preferred_elements_set = ( @@ -1042,10 +1047,7 @@ def _best_line_match(peak_energy, peak_snr, allowed_elements=None, edge_filters= is_m_line = "M" in line_name and not ("Ma" in line_name or "Mb" in line_name) effective_tolerance = tolerance * 0.5 if is_m_line else tolerance - if ( - line_weight >= min_line_weight - and distance <= effective_tolerance - ): + if line_weight >= min_line_weight and distance <= effective_tolerance: # Use score-based ranking: confidence with weight and distance # This matches rematcher logic and avoids distance-driven artifacts score = _peak_confidence(peak_snr, line_weight, distance) @@ -1198,9 +1200,7 @@ def _best_line_match(peak_energy, peak_snr, allowed_elements=None, edge_filters= # (half-weight line, 1-sigma spatial offset, SNR=3). # No element below this floor can have a statistically detectable peak. poisson_mdl_snr = 3.0 - _mdl_conf_floor = ( - float(np.log1p(poisson_mdl_snr)) * 0.5 * float(np.exp(-0.5)) - ) + _mdl_conf_floor = float(np.log1p(poisson_mdl_snr)) * 0.5 * float(np.exp(-0.5)) confidence_cutoff = max( float(np.percentile(conf_values, 45)), 0.30 * float(conf_values.max()), @@ -1219,8 +1219,7 @@ def _best_line_match(peak_energy, peak_snr, allowed_elements=None, edge_filters= and stats["best_match_snr"] >= poisson_mdl_snr and ( stats["strong_matches"] >= 1 - or stats["best_match_snr"] - >= max(min_snr, 0.6 * snr_threshold_for_sample) + or stats["best_match_snr"] >= max(min_snr, 0.6 * snr_threshold_for_sample) ) ) # is_near_cutoff_but_consistent: two independent peaks above the @@ -1341,12 +1340,18 @@ def _line_consistency_boost(element_name, line_name, peak_energy, denom): # line from an equally-dominant element from outscoring a # confirmed primary line purely via prior amplification. line_info_entry = (all_info or {}).get(str(element_name), {}).get(str(line_name), {}) - line_weight = float(line_info_entry.get("weight", 0.5)) if isinstance(line_info_entry, dict) else 0.5 + line_weight = ( + float(line_info_entry.get("weight", 0.5)) + if isinstance(line_info_entry, dict) + else 0.5 + ) weight_tier = 1.0 + 0.7 * max(0.0, line_weight - 0.35) if strong_line_matches >= 1 and best_line_conf >= 1.4: return min(3.2, 2.4 * weight_tier) - if best_line_conf >= 1.1 and best_line_snr >= max(min_snr, 0.75 * snr_threshold_for_sample): + if best_line_conf >= 1.1 and best_line_snr >= max( + min_snr, 0.75 * snr_threshold_for_sample + ): return min(2.6, 1.9 * weight_tier) if best_line_conf >= 0.8: return min(2.0, 1.5 * weight_tier) @@ -1444,7 +1449,10 @@ def _best_supported_line_match_with_prior( ) score *= _shell_preference_factor(shell) - if str(element_name) in preferred_elements_set and score > best_preferred_score: + if ( + str(element_name) in preferred_elements_set + and score > best_preferred_score + ): best_preferred_score = score best_preferred_tuple = (element_name, line_name, line_weight, distance) @@ -1516,7 +1524,13 @@ def _top_supported_line_matches_with_prior( ) score *= _shell_preference_factor(shell) scored_matches.append( - (float(score), str(element_name), str(line_name), float(line_weight), float(distance)) + ( + float(score), + str(element_name), + str(line_name), + float(line_weight), + float(distance), + ) ) scored_matches.sort(key=lambda item: item[0], reverse=True) @@ -1527,7 +1541,9 @@ def _top_supported_line_matches_with_prior( ] if len(preferred_scored_matches) > 0: non_preferred_scored_matches = [ - item for item in scored_matches if str(item[1]) not in preferred_elements_set + item + for item in scored_matches + if str(item[1]) not in preferred_elements_set ] scored_matches = preferred_scored_matches + non_preferred_scored_matches @@ -1700,9 +1716,10 @@ def _format_saved_model_elements(edge_filters): def _format_elements_with_lines(element_names): formatted = [] for element_name in sorted(str(name) for name in element_names): - line_names = sorted(str(line_name) for line_name in final_matches_by_element.get( - str(element_name), set() - )) + line_names = sorted( + str(line_name) + for line_name in final_matches_by_element.get(str(element_name), set()) + ) confidence_value = float(element_confidence.get(str(element_name), 0.0)) confidence_suffix = f" conf={confidence_value:.2f}" if len(line_names) > 0: @@ -1729,8 +1746,7 @@ def _format_elements_with_lines(element_names): reverse=True, ) dominant_str = ", ".join( - f"{el} (conf={element_confidence.get(str(el), 0.0):.2f})" - for el in dominant_sorted + f"{el} (conf={element_confidence.get(str(el), 0.0):.2f})" for el in dominant_sorted ) print(f"Dominant (strong prior): {dominant_str}") if candidate_elements: @@ -1831,15 +1847,11 @@ def _format_label_with_score(label_text, score_value): ] if len(ranked_entries) > 0: alt_2 = _mark_autodetected_label( - _format_label_with_score( - ranked_entries[0][0], ranked_entries[0][1] - ) + _format_label_with_score(ranked_entries[0][0], ranked_entries[0][1]) ) if len(ranked_entries) > 1: alt_3 = _mark_autodetected_label( - _format_label_with_score( - ranked_entries[1][0], ranked_entries[1][1] - ) + _format_label_with_score(ranked_entries[1][0], ranked_entries[1][1]) ) table_rows.append((peak_energy, height, snr, best_match, alt_2, alt_3)) @@ -1996,7 +2008,9 @@ def _infer_requested_element_for_color(peak_energy): if reference_elements is not None: energy_min = float(np.min(E)) energy_max = float(np.max(E)) - displayed_peak_energies = [float(peak_energy) for _, _, peak_energy, _ in display_peaks] + displayed_peak_energies = [ + float(peak_energy) for _, _, peak_energy, _ in display_peaks + ] display_peak_tolerance = max(0.05, 0.5 * tolerance) existing_matches_by_element = {} for ( diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index b2ae959c..be3b0de0 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -63,7 +63,7 @@ def __init__( _token=_token, ) self._virtual_images = {} - self.dataset_type = "EELS" + self.dataset_type = "eels" def calculate_background_iterative(self, spectrum): """ diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index e9ce20ba..bef9eb45 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -14,42 +14,8 @@ from quantem.spectroscopy.utils import load_xray_lines_database - -def _run_pca(data: NDArray | Any, n_components: int): - array = np.asarray(data, dtype=float) - n_samples, n_features = array.shape - max_components = min(n_samples, n_features) - if not 1 <= n_components <= max_components: - raise ValueError(f"n_components={n_components} must be between 1 and {max_components}") - - mean = np.mean(array, axis=0) - centered = torch.as_tensor(array - mean, dtype=torch.float64) - _, s, vh = torch.linalg.svd(centered, full_matrices=False) - - components = vh[:n_components].cpu().numpy() - loadings = (centered @ vh[:n_components].T).cpu().numpy() - - denom = max(n_samples - 1, 1) - explained_variance = ((s[:n_components] ** 2) / denom).cpu().numpy() - total_variance = torch.sum((s**2) / denom).item() - explained_variance_ratio = ( - explained_variance / total_variance - if total_variance > 0 - else np.zeros_like(explained_variance) - ) - reconstructed = loadings @ components + mean - - return components, loadings, explained_variance, explained_variance_ratio, reconstructed - - class Dataset3dspectroscopy(Dataset3d): # stores the element line info so you don't need to reload each time - element_info = None - element_info_path = "x_ray_lines.csv" - atomic_weights = None - atomic_weights_path = "atomic_weights.csv" - dataset_type = "EDS" - def __init__( self, array: NDArray | Any, @@ -70,23 +36,22 @@ def __init__( _token=type(self)._token if _token is None else _token, ) - # Initialize model elements storage self.model_elements = None - # Initialize spectra storage self.attached_spectra = None + self.element_info = None # loads elemental information @classmethod def load_element_info(cls): """Load element database for EDS (X-ray lines) or EELS (binding energies).""" - if cls.element_info is not None: + if hasattr(cls, "element_info"): return class_type = str(getattr(cls, "dataset_type", "")).strip().lower() path = ( "eels_binding_energies.json" if class_type == "eels" - else getattr(cls, "element_info_path", "x_ray_lines.csv") + else getattr(cls, "x_ray_lines.csv", "x_ray_lines.csv") ) full_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), path) @@ -102,9 +67,8 @@ def load_atomic_weights(cls): if cls.atomic_weights is not None: return - full_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), cls.atomic_weights_path - ) + atomic_weights_path = "atomic_weights.csv" + full_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), atomic_weights_path) data = {} with open(full_path, "r", newline="") as f: reader = csv.reader(f) @@ -113,7 +77,7 @@ def load_atomic_weights(cls): continue if len(row) < 2: raise ValueError( - f"{cls.atomic_weights_path} row {row_index} must contain element symbol and weight" + f"{atomic_weights_path} row {row_index} must contain element symbol and weight" ) symbol = str(row[0]).strip() weight_raw = str(row[1]).strip() @@ -123,12 +87,12 @@ def load_atomic_weights(cls): weight = float(weight_raw) except ValueError as exc: raise ValueError( - f"{cls.atomic_weights_path} row {row_index} has invalid weight: {weight_raw!r}" + f"{atomic_weights_path} row {row_index} has invalid weight: {weight_raw!r}" ) from exc data[symbol] = weight if not data: - raise ValueError(f"{cls.atomic_weights_path} did not contain any atomic weights") + raise ValueError(f"{atomic_weights_path} did not contain any atomic weights") cls.atomic_weights = data @@ -223,7 +187,9 @@ def add_elements_to_model(self, elements): self.model_elements[element_key] = existing added_keys = [ - line_name for line_name in selected_lines.keys() if line_name not in existing_keys_before + line_name + for line_name in selected_lines.keys() + if line_name not in existing_keys_before ] if added_keys: if element_key not in added_this_call: @@ -235,7 +201,9 @@ def add_elements_to_model(self, elements): if added_this_call: print("Added to model:") for element_key in sorted(added_this_call.keys()): - unique_lines = sorted(set(str(line_name) for line_name in added_this_call[element_key])) + unique_lines = sorted( + set(str(line_name) for line_name in added_this_call[element_key]) + ) print(f" - {element_key}: {', '.join(unique_lines)}") else: print("Added to model: nothing new") @@ -331,7 +299,6 @@ def plot_attached_spectrum(self, data_type="eds", spectrum_index=0): plt.show() ## PCA ANALYSIS METHODS - def perform_pca( self, n_components: int = 10, @@ -402,7 +369,7 @@ def perform_pca( explained_variance, explained_variance_ratio, reconstructed, - ) = _run_pca(data_processed, n_components) + ) = self._run_pca(data_processed, n_components) # Reconstruct data if standardize: @@ -423,14 +390,14 @@ def perform_pca( n_show=min(4, n_components), ) - if self.dataset_type == "EDS": + if self.dataset_type == "eds": reconstructed_data3d = Dataset3deds.from_array( array=reconstructed.T.reshape(n_energy, ny, nx), sampling=self.sampling, origin=self.origin, units=self.units, ) - elif self.dataset_type == "EELS": + elif self.dataset_type == "eels": reconstructed_data3d = Dataset3deels.from_array( array=reconstructed.T.reshape(n_energy, ny, nx), sampling=self.sampling, @@ -451,6 +418,40 @@ def perform_pca( "reconstructed": reconstructed_data3d if mask is None else reconstructed_data3d, } + def _run_pca(self, data: NDArray | Any, n_components: int): + array = np.asarray(data, dtype=float) + n_samples, n_features = array.shape + max_components = min(n_samples, n_features) + if not 1 <= n_components <= max_components: + raise ValueError( + f"n_components={n_components} must be between 1 and {max_components}" + ) + + mean = np.mean(array, axis=0) + centered = torch.as_tensor(array - mean, dtype=torch.float64) + _, s, vh = torch.linalg.svd(centered, full_matrices=False) + + components = vh[:n_components].cpu().numpy() + loadings = (centered @ vh[:n_components].T).cpu().numpy() + + denom = max(n_samples - 1, 1) + explained_variance = ((s[:n_components] ** 2) / denom).cpu().numpy() + total_variance = torch.sum((s**2) / denom).item() + explained_variance_ratio = ( + explained_variance / total_variance + if total_variance > 0 + else np.zeros_like(explained_variance) + ) + reconstructed = loadings @ components + mean + + return ( + components, + loadings, + explained_variance, + explained_variance_ratio, + reconstructed, + ) + def _plot_pca_results( self, components: NDArray, @@ -742,8 +743,8 @@ def show_mean_spectrum( roi_units=None, energy_range=None, mask=None, - data_type=None, - show=True, + intensity_range=None, + **kwargs, ): """ Plot the mean spectrum from a spatial ROI in a 3D spectroscopy cube (E, Y, X). @@ -763,11 +764,8 @@ def show_mean_spectrum( Energy range to display as [min_energy, max_energy] in keV. mask : array, optional Boolean mask for pixel selection. - show : bool, optional - If True, display the plot with ``plt.show()``. Set False to add overlays before showing. - data_type : str, optional - Type of spectroscopy data. Options: 'eds' (default) or 'eels'. - + intensity_range : 2-tuple, None + If not None, sets intensity range on spectrum plot Returns ------- (fig, ax) : tuple @@ -841,6 +839,7 @@ def show_mean_spectrum( "sampling": float(self.sampling[1]), "units": str(self.units[1]), }, + **kwargs, ) ax_img.set_xlabel("X (pixels)") ax_img.set_ylabel("Y (pixels)") @@ -853,17 +852,17 @@ def show_mean_spectrum( # RIGHT PLOT: Show spectrum ax_spec.plot(E, spec, linewidth=1.5) - if data_type == "eds": + if self.dataset_type == "eds": ax_spec.set_xlabel("Energy (keV)") else: ax_spec.set_xlabel("Energy (eV)") ax_spec.set_ylabel("Intensity") ax_spec.set_title(f"Spectrum from ROI [{y}:{y + dy}, {x}:{x + dx}]") ax_spec.grid(True, alpha=0.1) + if intensity_range is not None: + ax_spec.set_ylim([intensity_range[0], intensity_range[1]]) fig.tight_layout() - if show: - plt.show() return fig, (ax_img, ax_spec) def refline( From afd8dceeaf5856ec9a6517705e871432b83eb067 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Tue, 7 Apr 2026 10:42:54 -0700 Subject: [PATCH 095/136] porting alignment/thickness functions to 3deels, removing widget and sklearn dependencies --- src/quantem/spectroscopy/dataset3deels.py | 280 ++++++++++++++++++ src/quantem/spectroscopy/eels_alignment.py | 101 ------- .../spectroscopy/eels_visualization.py | 163 ---------- src/quantem/spectroscopy/thickness.py | 198 ------------- 4 files changed, 280 insertions(+), 462 deletions(-) delete mode 100644 src/quantem/spectroscopy/eels_alignment.py delete mode 100644 src/quantem/spectroscopy/eels_visualization.py delete mode 100644 src/quantem/spectroscopy/thickness.py diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index be3b0de0..23145b80 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -336,3 +336,283 @@ def calibrate_zero_loss_peak(self, center_guess=None, search_window=10): origin=self.origin, units=self.units, ) + + def align_dual_eels_universal(ll, hl, approach="smooth", sigma=1.2): + """ + Aligns ZLP jitter across the spatial map and synchronizes Dual-EELS pairs. + """ + print(f"QuantEM: Aligning {ll.name} and syncing {hl.name}...") + + # 1. Map the drift via argmax + zlp_indices = np.argmax(ll.array, axis=0) + ref_idx = int(np.median(zlp_indices)) + shifts = zlp_indices - ref_idx + + # 2. Apply internal QuantEM calibration + ll.calibrate_zero_loss_peak() + + # 3. Synchronize High-Loss energy origin based on median shift + shift_ev = np.median(shifts) * ll.sampling[0] + hl.origin[0] -= shift_ev + + print("QuantEM: Alignment and Dual-EELS sync complete.") + return ll, hl, shifts + + def calibrate_energy_axis(ll, hl): + """ + Fine-tunes the origin so the absolute peak position is exactly 0.0 eV. + """ + # Find the peak of the average spectrum + current_peak_idx = np.argmax(np.mean(ll.array, axis=(1, 2))) + peak_ev = ll.origin[0] + (current_peak_idx * ll.sampling[0]) + + # Apply global shift to both datasets + ll.origin[0] -= peak_ev + hl.origin[0] -= peak_ev + + print(f"QuantEM: Final calibration shift of {peak_ev:.4f} eV applied.") + + def plot_absolute_zlp_shift(dataset, search_window=(-10, 10)): + """ + Calculates the ZLP shift per pixel and plots the absolute deviation from 0.0 eV. + """ + data = dataset.array + n_e = data.shape[0] + + # Generate energy axis + energies = dataset.origin[0] + np.arange(n_e) * dataset.sampling[0] + + # Mask energy window for peak finding + mask = (energies > search_window[0]) & (energies < search_window[1]) + search_energies = energies[mask] + + # Calculate peak map and absolute deviation + peak_indices = np.argmax(data[mask, :, :], axis=0) + zlp_map_ev = search_energies[peak_indices] + absolute_shift = np.abs(zlp_map_ev) + + # Visualization + fig, ax = plt.subplots(figsize=(8, 6)) + im = ax.imshow(absolute_shift, cmap="magma", origin="lower") + + plt.colorbar(im, ax=ax, label="Absolute Shift (eV)") + ax.set_title(f"Absolute ZLP Deviation: {dataset.name}") + ax.set_xlabel("X (pixels)") + ax.set_ylabel("Y (pixels)") + + plt.tight_layout() + plt.show() + + return absolute_shift + + def plot_alignment_verification(dataset, shift_map, coords=(9, 9)): + """ + Plots the drift map and a specific spectrum to verify alignment quality. + """ + y, x = coords + spec = dataset.array[:, y, x] + energies = dataset.origin[0] + np.arange(len(spec)) * dataset.sampling[0] + + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5)) + + # Drift Map + im = ax1.imshow(shift_map, cmap="RdBu_r", origin="lower") + ax1.plot(x, y, "yo", markeredgecolor="k") + ax1.set_title("Drift Map") + plt.colorbar(im, ax=ax1, label="Relative Shift") + + # Spectrum Verification + ax2.plot(energies, spec, color="black", label="Aligned Spec") + ax2.axvline(0, color="red", linestyle="--", alpha=0.7, label="0.0 eV Target") + ax2.set_xlim(-5, 5) + ax2.set_title(f"ZLP Detail at Pixel ({x}, {y})") + ax2.set_xlabel("Energy Loss (eV)") + ax2.legend() + + plt.tight_layout() + plt.show() + + def visualize_thickness_windows(dataset, zlp_window=(-3.0, 3.0), total_window=(-3.0, 75.0)): + """ + Visualizes integration windows for I0 (ZLP) and It (Total). + Returns a configuration dictionary for the calculation step. + """ + # 1. Extract Energy and Mean Spectrum + data = dataset.array + mean_spec = np.mean(data, axis=(1, 2)) + + # Use built-in energy axis if available, else generate from metadata + if hasattr(dataset, "energy_axis"): + energy = dataset.energy_axis + else: + energy = dataset.origin[0] + np.arange(dataset.shape[0]) * dataset.sampling[0] + + # 2. Find indices for the windows + zlp_idx = ( + np.argmin(np.abs(energy - zlp_window[0])), + np.argmin(np.abs(energy - zlp_window[1])), + ) + tot_idx = ( + np.argmin(np.abs(energy - total_window[0])), + np.argmin(np.abs(energy - total_window[1])), + ) + + # 3. Create the Visualization + fig, ax = plt.subplots(figsize=(10, 5)) + ax.plot(energy, mean_spec, "k-", lw=1.5, label="Mean Spectrum", zorder=5) + + # Highlight Windows + z_mask = (energy >= zlp_window[0]) & (energy <= zlp_window[1]) + t_mask = (energy >= total_window[0]) & (energy <= total_window[1]) + + ax.fill_between( + energy[z_mask], 0, mean_spec[z_mask], color="red", alpha=0.3, label="$I_0$ (ZLP)" + ) + ax.fill_between( + energy[t_mask], 0, mean_spec[t_mask], color="blue", alpha=0.1, label="$I_t$ (Total)" + ) + + ax.axvline(0, color="green", lw=1.5, ls=":", label="0 eV") + ax.set_title(f"QuantEM: Integration Windows ({dataset.name})", fontweight="bold") + ax.set_xlabel("Energy Loss (eV)") + ax.set_ylabel("Intensity (counts)") + ax.set_xlim(energy[0], total_window[1] + 20) + ax.legend() + + plt.tight_layout() + plt.show() + + return { + "zlp_idx": zlp_idx, + "total_idx": tot_idx, + "zlp_val": zlp_window, + "total_val": total_window, + } + + def calculate_thickness_log_ratio(dataset, window_params, plot=True): + """ + Calculates the relative thickness map (t/lambda) using the Log-Ratio method. + """ + data = dataset.array + z_start, z_end = window_params["zlp_idx"] + t_start, t_end = window_params["total_idx"] + + print(f"QuantEM: Calculating thickness for {dataset.name}...") + + # 1. Vectorized Integration + I_zlp = np.sum(data[z_start : z_end + 1, :, :], axis=0) + I_total = np.sum(data[t_start : t_end + 1, :, :], axis=0) + + # 2. Log-Ratio Calculation (with epsilon to avoid log(0)) + epsilon = 1e-10 + t_over_lambda = np.log((I_total + epsilon) / (I_zlp + epsilon)) + + # 3. Data Cleaning + t_over_lambda = np.nan_to_num(t_over_lambda, nan=0.0, posinf=0.0, neginf=0.0) + t_over_lambda = np.clip(t_over_lambda, 0, 4.0) + + if plot: + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) + fig.suptitle(f"Thickness Analysis: {dataset.name}", fontsize=14) + + im = ax1.imshow(t_over_lambda, cmap="viridis", origin="lower") + ax1.set_title(r"Relative Thickness Map ($t/\lambda$)") + plt.colorbar(im, ax=ax1, label=r"$t/\lambda$") + + ax2.hist(t_over_lambda.flatten(), bins=50, color="steelblue", alpha=0.7, ec="k") + ax2.axvline( + np.mean(t_over_lambda), + color="red", + ls="--", + label=f"Mean: {np.mean(t_over_lambda):.2f}", + ) + ax2.set_title("Thickness Distribution") + ax2.set_xlabel(r"$t/\lambda$") + ax2.legend() + + plt.tight_layout() + plt.show() + + return t_over_lambda + + def interpret_thickness_quality(t_over_lambda, dataset=None): + """ + Performs a scientific quality assessment on the calculated t/lambda map. + """ + name = dataset.name if dataset else "Dataset" + + # Classification Masks + vacuum = t_over_lambda < 0.3 + thin = (t_over_lambda >= 0.3) & (t_over_lambda < 1.0) + medium = (t_over_lambda >= 1.0) & (t_over_lambda < 2.0) + thick = t_over_lambda >= 2.0 + + print(f"\n{'=' * 20} QUANTEM INTERPRETATION: {name} {'=' * 20}") + for label, mask in [ + ("Vacuum (<0.3)", vacuum), + ("Thin (0.3-1.0)", thin), + ("Medium (1.0-2.0)", medium), + ("Thick (>2.0)", thick), + ]: + pct = 100 * np.sum(mask) / t_over_lambda.size + print(f" {label:20}: {pct:5.1f}%") + + # Plotting Classification + classified = np.zeros_like(t_over_lambda) + classified[thin] = 1 + classified[medium] = 2 + classified[thick] = 3 + + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) + + im1 = ax1.imshow(classified, cmap="RdYlGn_r", origin="lower") + ax1.set_title("Region Classification") + cbar = plt.colorbar(im1, ax=ax1, ticks=[0, 1, 2, 3]) + cbar.ax.set_yticklabels(["Vacuum", "Thin", "Medium", "Thick"]) + + t_masked = np.copy(t_over_lambda) + t_masked[vacuum] = np.nan + im2 = ax2.imshow(t_masked, cmap="viridis", origin="lower") + ax2.set_title("Sample-Only Thickness") + plt.colorbar(im2, ax=ax2, label=r"$t/\lambda$") + + plt.tight_layout() + plt.show() + + def plot_absolute_thickness(t_lambda_map, mfp_nm, dataset=None): + """ + Converts relative thickness to nanometers and visualizes the absolute map. + """ + thickness_nm = t_lambda_map * mfp_nm + name = dataset.name if dataset else "Sample" + + # Mask vacuum for better visualization contrast + display_map = np.copy(thickness_nm) + display_map[t_lambda_map < 0.1] = np.nan + + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) + fig.suptitle(f"Physical Analysis: {name}", fontsize=14) + + im = ax1.imshow(display_map, cmap="magma", origin="lower") + ax1.set_title("Absolute Thickness (nm)") + plt.colorbar(im, ax=ax1, label="nm") + + valid_data = thickness_nm[t_lambda_map >= 0.1].flatten() + ax2.hist(valid_data, bins=50, color="firebrick", alpha=0.7, ec="k") + ax2.axvline( + np.nanmean(display_map), + color="blue", + ls="--", + label=f"Mean: {np.nanmean(display_map):.1f} nm", + ) + ax2.set_title("Physical Distribution") + ax2.set_xlabel("Thickness (nm)") + ax2.legend() + + plt.tight_layout() + plt.show() + + print( + f"\nQuantEM Absolute Report:\n Mean: {np.nanmean(display_map):.2f} nm\n MFP: {mfp_nm:.2f} nm" + ) + return thickness_nm diff --git a/src/quantem/spectroscopy/eels_alignment.py b/src/quantem/spectroscopy/eels_alignment.py deleted file mode 100644 index 0bb60495..00000000 --- a/src/quantem/spectroscopy/eels_alignment.py +++ /dev/null @@ -1,101 +0,0 @@ -import matplotlib.pyplot as plt -import numpy as np - - -def align_dual_eels_universal(ll, hl, approach="smooth", sigma=1.2): - """ - Aligns ZLP jitter across the spatial map and synchronizes Dual-EELS pairs. - """ - print(f"QuantEM: Aligning {ll.name} and syncing {hl.name}...") - - # 1. Map the drift via argmax - zlp_indices = np.argmax(ll.array, axis=0) - ref_idx = int(np.median(zlp_indices)) - shifts = zlp_indices - ref_idx - - # 2. Apply internal QuantEM calibration - ll.calibrate_zero_loss_peak() - - # 3. Synchronize High-Loss energy origin based on median shift - shift_ev = np.median(shifts) * ll.sampling[0] - hl.origin[0] -= shift_ev - - print("QuantEM: Alignment and Dual-EELS sync complete.") - return ll, hl, shifts - - -def calibrate_energy_axis(ll, hl): - """ - Fine-tunes the origin so the absolute peak position is exactly 0.0 eV. - """ - # Find the peak of the average spectrum - current_peak_idx = np.argmax(np.mean(ll.array, axis=(1, 2))) - peak_ev = ll.origin[0] + (current_peak_idx * ll.sampling[0]) - - # Apply global shift to both datasets - ll.origin[0] -= peak_ev - hl.origin[0] -= peak_ev - - print(f"QuantEM: Final calibration shift of {peak_ev:.4f} eV applied.") - - -def plot_absolute_zlp_shift(dataset, search_window=(-10, 10)): - """ - Calculates the ZLP shift per pixel and plots the absolute deviation from 0.0 eV. - """ - data = dataset.array - n_e = data.shape[0] - - # Generate energy axis - energies = dataset.origin[0] + np.arange(n_e) * dataset.sampling[0] - - # Mask energy window for peak finding - mask = (energies > search_window[0]) & (energies < search_window[1]) - search_energies = energies[mask] - - # Calculate peak map and absolute deviation - peak_indices = np.argmax(data[mask, :, :], axis=0) - zlp_map_ev = search_energies[peak_indices] - absolute_shift = np.abs(zlp_map_ev) - - # Visualization - fig, ax = plt.subplots(figsize=(8, 6)) - im = ax.imshow(absolute_shift, cmap="magma", origin="lower") - - plt.colorbar(im, ax=ax, label="Absolute Shift (eV)") - ax.set_title(f"Absolute ZLP Deviation: {dataset.name}") - ax.set_xlabel("X (pixels)") - ax.set_ylabel("Y (pixels)") - - plt.tight_layout() - plt.show() - - return absolute_shift - - -def plot_alignment_verification(dataset, shift_map, coords=(9, 9)): - """ - Plots the drift map and a specific spectrum to verify alignment quality. - """ - y, x = coords - spec = dataset.array[:, y, x] - energies = dataset.origin[0] + np.arange(len(spec)) * dataset.sampling[0] - - fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5)) - - # Drift Map - im = ax1.imshow(shift_map, cmap="RdBu_r", origin="lower") - ax1.plot(x, y, "yo", markeredgecolor="k") - ax1.set_title("Drift Map") - plt.colorbar(im, ax=ax1, label="Relative Shift") - - # Spectrum Verification - ax2.plot(energies, spec, color="black", label="Aligned Spec") - ax2.axvline(0, color="red", linestyle="--", alpha=0.7, label="0.0 eV Target") - ax2.set_xlim(-5, 5) - ax2.set_title(f"ZLP Detail at Pixel ({x}, {y})") - ax2.set_xlabel("Energy Loss (eV)") - ax2.legend() - - plt.tight_layout() - plt.show() diff --git a/src/quantem/spectroscopy/eels_visualization.py b/src/quantem/spectroscopy/eels_visualization.py deleted file mode 100644 index 509ee971..00000000 --- a/src/quantem/spectroscopy/eels_visualization.py +++ /dev/null @@ -1,163 +0,0 @@ -import matplotlib.pyplot as plt -import numpy as np -from ipywidgets import Checkbox, IntSlider, interact -from scipy.stats import norm - - -def plot_dual_eels_picker(ll, hl, coords=(9, 9), title="QuantEM: Dual-EELS Analysis"): - """ - Interactive picker for side-by-side Low-Loss and High-Loss EELS analysis. - """ - # 1. Pre-calculate sum images for spatial maps - sum_ll = np.sum(ll.array, axis=0) - sum_hl = np.sum(hl.array, axis=0) - - # 2. Generate energy axes using dataset metadata - energy_ll = ll.origin[0] + np.arange(ll.shape[0]) * ll.sampling[0] - energy_hl = hl.origin[0] + np.arange(hl.shape[0]) * hl.sampling[0] - - def _update_plot(x, y, log_scale): - fig, axes = plt.subplots(2, 2, figsize=(14, 9)) - fig.suptitle(title, fontsize=16) - - # --- LOW LOSS ROW (Top) --- - im_ll = axes[0, 0].imshow(sum_ll, cmap="viridis", origin="lower") - axes[0, 0].plot(x, y, "r+", markersize=12, markeredgewidth=2) - axes[0, 0].set_title("Low-Loss Map (Integrated)") - fig.colorbar(im_ll, ax=axes[0, 0], label="Counts") - - axes[0, 1].plot(energy_ll, ll.array[:, y, x], color="tab:blue", lw=1.5) - axes[0, 1].set_title(f"LL Spectrum at ({x}, {y})") - axes[0, 1].set_ylabel("Intensity") - if log_scale: - axes[0, 1].set_yscale("log") - - # --- HIGH LOSS ROW (Bottom) --- - im_hl = axes[1, 0].imshow(sum_hl, cmap="magma", origin="lower") - axes[1, 0].plot(x, y, "r+", markersize=12, markeredgewidth=2) - axes[1, 0].set_title("High-Loss Map (Integrated)") - fig.colorbar(im_hl, ax=axes[1, 0], label="Counts") - - axes[1, 1].plot(energy_hl, hl.array[:, y, x], color="tab:red", lw=1.5) - axes[1, 1].set_title(f"HL Spectrum at ({x}, {y})") - axes[1, 1].set_xlabel("Energy Loss (eV)") - axes[1, 1].set_ylabel("Intensity") - if log_scale: - axes[1, 1].set_yscale("log") - - plt.tight_layout() - plt.show() - - # Standardized sliders with continuous_update=False for performance - interact( - _update_plot, - x=IntSlider(min=0, max=ll.shape[2] - 1, step=1, value=coords[1], continuous_update=False), - y=IntSlider(min=0, max=ll.shape[1] - 1, step=1, value=coords[0], continuous_update=False), - log_scale=Checkbox(value=False, description="Log Y-Axis"), - ) - - -def plot_quantem_diagnostic(dataset, zlp_window=5.0, title_suffix=""): - """ - QuantEM Diagnostic Dashboard: Visualizes mean spectra, spatial variation, and ZLP accuracy. - """ - data = dataset.array - energy = dataset.origin[0] + np.arange(data.shape[0]) * dataset.sampling[0] - - mean_spec = np.mean(data, axis=(1, 2)) - zlp_idx = np.argmax(mean_spec) - zlp_pos = energy[zlp_idx] - sum_img = np.sum(data, axis=0) - - fig = plt.figure(figsize=(15, 10)) - gs = fig.add_gridspec(2, 2, hspace=0.3, wspace=0.3) - fig.suptitle(f"QuantEM Diagnostic: {dataset.name} {title_suffix}", fontsize=16) - - # 1. Mean Spectrum with Alignment Targets - ax1 = fig.add_subplot(gs[0, 0]) - ax1.plot(energy, mean_spec, color="black", label="Mean Spectrum") - ax1.axvline(zlp_pos, color="red", ls="--", alpha=0.6, label=f"Peak: {zlp_pos:.2f} eV") - ax1.axvline(0, color="green", ls=":", lw=2, label="Target (0 eV)") - ax1.set_title("Global Average Spectrum") - ax1.set_xlabel("Energy Loss (eV)") - ax1.legend() - - # 2. Spatial Variability (Sampled 3x3 Grid) - ax2 = fig.add_subplot(gs[0, 1]) - yy = np.linspace(0, data.shape[1] - 1, 3, dtype=int) - xx = np.linspace(0, data.shape[2] - 1, 3, dtype=int) - for y in yy: - for x in xx: - ax2.plot(energy, data[:, y, x], alpha=0.4, lw=1) - ax2.set_title("Spatial Variation (Sampled Pixels)") - ax2.set_xlabel("Energy Loss (eV)") - - # 3. Integrated Intensity Map - ax3 = fig.add_subplot(gs[1, 0]) - im = ax3.imshow(sum_img, cmap="viridis", origin="lower") - fig.colorbar(im, ax=ax3, label="Total Counts") - ax3.set_title("Summed Intensity Map") - ax3.set_xlabel("X (pixels)") - ax3.set_ylabel("Y (pixels)") - - # 4. ZLP Zoom-in Detail - ax4 = fig.add_subplot(gs[1, 1]) - mask = (energy > zlp_pos - zlp_window) & (energy < zlp_pos + zlp_window) - ax4.plot(energy[mask], mean_spec[mask], color="blue", lw=2) - ax4.axvline(0, color="green", ls=":", lw=2, label="0 eV Target") - ax4.set_title(f"ZLP Alignment Detail (±{zlp_window} eV)") - ax4.set_xlabel("Energy Loss (eV)") - ax4.legend() - - plt.show() - - -def plot_zlp_drift_diagnostics(dataset, title="ZLP Drift Analysis"): - """ - QuantEM Diagnostic: Maps the ZLP position and calculates the drift distribution. - """ - data = dataset.array - energy = dataset.origin[0] + np.arange(data.shape[0]) * dataset.sampling[0] - - # 1. Mask and find peak per pixel (Vectorized for speed) - search_mask = (energy > -2.0) & (energy < 2.0) - search_energies = energy[search_mask] - peak_indices = np.argmax(data[search_mask, :, :], axis=0) - zlp_map = search_energies[peak_indices] - - # 2. Setup Figure - fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 6)) - fig.suptitle(f"QuantEM: {dataset.name} - {title}", fontsize=16) - - # Plot A: Spatial Map of ZLP Shifts - im = ax1.imshow(zlp_map, cmap="RdYlBu_r", origin="lower") - ax1.set_title("Spatial Map of ZLP Positions") - ax1.set_xlabel("X (pixels)") - ax1.set_ylabel("Y (pixels)") - cbar = fig.colorbar(im, ax=ax1) - cbar.set_label("Energy Shift (eV)", rotation=270, labelpad=15) - - # Plot B: Histogram + Gaussian Fit - flat_pos = zlp_map.flatten() - mu, std = norm.fit(flat_pos) - - ax2.hist(flat_pos, bins=30, density=True, alpha=0.6, color="skyblue", ec="white") - - # Fit line display - x_range = np.linspace(np.min(flat_pos), np.max(flat_pos), 100) - ax2.plot( - x_range, - norm.pdf(x_range, mu, std), - color="darkred", - lw=2.5, - label=f"Fit: μ={mu:.3f} eV\nσ={std:.3f} eV", - ) - - ax2.set_title("ZLP Drift Distribution") - ax2.set_xlabel("Energy (eV)") - ax2.set_ylabel("Density") - ax2.legend() - ax2.grid(True, alpha=0.15) - - plt.tight_layout() - plt.show() diff --git a/src/quantem/spectroscopy/thickness.py b/src/quantem/spectroscopy/thickness.py deleted file mode 100644 index 6c28b7e2..00000000 --- a/src/quantem/spectroscopy/thickness.py +++ /dev/null @@ -1,198 +0,0 @@ -""" -QuantEM EELS Thickness Module -============================= -Tools for calculating specimen thickness from Low-Loss EELS using the Log-Ratio method. -t/λ = ln(I_total / I_ZLP) -""" - -import matplotlib.pyplot as plt -import numpy as np - - -def visualize_thickness_windows(dataset, zlp_window=(-3.0, 3.0), total_window=(-3.0, 75.0)): - """ - Visualizes integration windows for I0 (ZLP) and It (Total). - Returns a configuration dictionary for the calculation step. - """ - # 1. Extract Energy and Mean Spectrum - data = dataset.array - mean_spec = np.mean(data, axis=(1, 2)) - - # Use built-in energy axis if available, else generate from metadata - if hasattr(dataset, "energy_axis"): - energy = dataset.energy_axis - else: - energy = dataset.origin[0] + np.arange(dataset.shape[0]) * dataset.sampling[0] - - # 2. Find indices for the windows - zlp_idx = ( - np.argmin(np.abs(energy - zlp_window[0])), - np.argmin(np.abs(energy - zlp_window[1])), - ) - tot_idx = ( - np.argmin(np.abs(energy - total_window[0])), - np.argmin(np.abs(energy - total_window[1])), - ) - - # 3. Create the Visualization - fig, ax = plt.subplots(figsize=(10, 5)) - ax.plot(energy, mean_spec, "k-", lw=1.5, label="Mean Spectrum", zorder=5) - - # Highlight Windows - z_mask = (energy >= zlp_window[0]) & (energy <= zlp_window[1]) - t_mask = (energy >= total_window[0]) & (energy <= total_window[1]) - - ax.fill_between( - energy[z_mask], 0, mean_spec[z_mask], color="red", alpha=0.3, label="$I_0$ (ZLP)" - ) - ax.fill_between( - energy[t_mask], 0, mean_spec[t_mask], color="blue", alpha=0.1, label="$I_t$ (Total)" - ) - - ax.axvline(0, color="green", lw=1.5, ls=":", label="0 eV") - ax.set_title(f"QuantEM: Integration Windows ({dataset.name})", fontweight="bold") - ax.set_xlabel("Energy Loss (eV)") - ax.set_ylabel("Intensity (counts)") - ax.set_xlim(energy[0], total_window[1] + 20) - ax.legend() - - plt.tight_layout() - plt.show() - - return { - "zlp_idx": zlp_idx, - "total_idx": tot_idx, - "zlp_val": zlp_window, - "total_val": total_window, - } - - -def calculate_thickness_log_ratio(dataset, window_params, plot=True): - """ - Calculates the relative thickness map (t/lambda) using the Log-Ratio method. - """ - data = dataset.array - z_start, z_end = window_params["zlp_idx"] - t_start, t_end = window_params["total_idx"] - - print(f"QuantEM: Calculating thickness for {dataset.name}...") - - # 1. Vectorized Integration - I_zlp = np.sum(data[z_start : z_end + 1, :, :], axis=0) - I_total = np.sum(data[t_start : t_end + 1, :, :], axis=0) - - # 2. Log-Ratio Calculation (with epsilon to avoid log(0)) - epsilon = 1e-10 - t_over_lambda = np.log((I_total + epsilon) / (I_zlp + epsilon)) - - # 3. Data Cleaning - t_over_lambda = np.nan_to_num(t_over_lambda, nan=0.0, posinf=0.0, neginf=0.0) - t_over_lambda = np.clip(t_over_lambda, 0, 4.0) - - if plot: - fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) - fig.suptitle(f"Thickness Analysis: {dataset.name}", fontsize=14) - - im = ax1.imshow(t_over_lambda, cmap="viridis", origin="lower") - ax1.set_title(r"Relative Thickness Map ($t/\lambda$)") - plt.colorbar(im, ax=ax1, label=r"$t/\lambda$") - - ax2.hist(t_over_lambda.flatten(), bins=50, color="steelblue", alpha=0.7, ec="k") - ax2.axvline( - np.mean(t_over_lambda), - color="red", - ls="--", - label=f"Mean: {np.mean(t_over_lambda):.2f}", - ) - ax2.set_title("Thickness Distribution") - ax2.set_xlabel(r"$t/\lambda$") - ax2.legend() - - plt.tight_layout() - plt.show() - - return t_over_lambda - - -def interpret_thickness_quality(t_over_lambda, dataset=None): - """ - Performs a scientific quality assessment on the calculated t/lambda map. - """ - name = dataset.name if dataset else "Dataset" - - # Classification Masks - vacuum = t_over_lambda < 0.3 - thin = (t_over_lambda >= 0.3) & (t_over_lambda < 1.0) - medium = (t_over_lambda >= 1.0) & (t_over_lambda < 2.0) - thick = t_over_lambda >= 2.0 - - print(f"\n{'=' * 20} QUANTEM INTERPRETATION: {name} {'=' * 20}") - for label, mask in [ - ("Vacuum (<0.3)", vacuum), - ("Thin (0.3-1.0)", thin), - ("Medium (1.0-2.0)", medium), - ("Thick (>2.0)", thick), - ]: - pct = 100 * np.sum(mask) / t_over_lambda.size - print(f" {label:20}: {pct:5.1f}%") - - # Plotting Classification - classified = np.zeros_like(t_over_lambda) - classified[thin] = 1 - classified[medium] = 2 - classified[thick] = 3 - - fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) - - im1 = ax1.imshow(classified, cmap="RdYlGn_r", origin="lower") - ax1.set_title("Region Classification") - cbar = plt.colorbar(im1, ax=ax1, ticks=[0, 1, 2, 3]) - cbar.ax.set_yticklabels(["Vacuum", "Thin", "Medium", "Thick"]) - - t_masked = np.copy(t_over_lambda) - t_masked[vacuum] = np.nan - im2 = ax2.imshow(t_masked, cmap="viridis", origin="lower") - ax2.set_title("Sample-Only Thickness") - plt.colorbar(im2, ax=ax2, label=r"$t/\lambda$") - - plt.tight_layout() - plt.show() - - -def plot_absolute_thickness(t_lambda_map, mfp_nm, dataset=None): - """ - Converts relative thickness to nanometers and visualizes the absolute map. - """ - thickness_nm = t_lambda_map * mfp_nm - name = dataset.name if dataset else "Sample" - - # Mask vacuum for better visualization contrast - display_map = np.copy(thickness_nm) - display_map[t_lambda_map < 0.1] = np.nan - - fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) - fig.suptitle(f"Physical Analysis: {name}", fontsize=14) - - im = ax1.imshow(display_map, cmap="magma", origin="lower") - ax1.set_title("Absolute Thickness (nm)") - plt.colorbar(im, ax=ax1, label="nm") - - valid_data = thickness_nm[t_lambda_map >= 0.1].flatten() - ax2.hist(valid_data, bins=50, color="firebrick", alpha=0.7, ec="k") - ax2.axvline( - np.nanmean(display_map), - color="blue", - ls="--", - label=f"Mean: {np.nanmean(display_map):.1f} nm", - ) - ax2.set_title("Physical Distribution") - ax2.set_xlabel("Thickness (nm)") - ax2.legend() - - plt.tight_layout() - plt.show() - - print( - f"\nQuantEM Absolute Report:\n Mean: {np.nanmean(display_map):.2f} nm\n MFP: {mfp_nm:.2f} nm" - ) - return thickness_nm From b23d2550e913dac43f099e2497850e9d1a438d34 Mon Sep 17 00:00:00 2001 From: yaeltsarfati Date: Tue, 7 Apr 2026 11:59:58 -0700 Subject: [PATCH 096/136] Update spectroscopy init by removing imports of deleted modules. --- src/quantem/spectroscopy/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/quantem/spectroscopy/__init__.py b/src/quantem/spectroscopy/__init__.py index 89842ba3..9b87e017 100644 --- a/src/quantem/spectroscopy/__init__.py +++ b/src/quantem/spectroscopy/__init__.py @@ -8,7 +8,3 @@ from quantem.spectroscopy.dataset3deds import ( Dataset3deds as Dataset3deds, ) - -from quantem.spectroscopy import thickness as thickness -from quantem.spectroscopy import eels_alignment as eels_alignment -from quantem.spectroscopy import eels_visualization as eels_visualization From 3c2e59331ae3332bad2265a35776fc74bb4cbca2 Mon Sep 17 00:00:00 2001 From: yaeltsarfati Date: Tue, 7 Apr 2026 16:03:38 -0700 Subject: [PATCH 097/136] Added EELS visualization functions to monitor ZLP calibratrion, alignment and drift. --- src/quantem/spectroscopy/dataset3deels.py | 222 +++++++++++++++++++++- 1 file changed, 217 insertions(+), 5 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index 23145b80..f5fda015 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -6,6 +6,7 @@ from scipy.interpolate import interp1d from scipy.ndimage import median_filter from scipy.optimize import curve_fit +from scipy.stats import norm from quantem.spectroscopy.dataset3dspectroscopy import Dataset3dspectroscopy @@ -535,17 +536,42 @@ def calculate_thickness_log_ratio(dataset, window_params, plot=True): return t_over_lambda - def interpret_thickness_quality(t_over_lambda, dataset=None): + def interpret_thickness_quality(t_over_lambda, a=0.3, b=1, c=2, dataset=None): """ Performs a scientific quality assessment on the calculated t/lambda map. + + The Physical Meaning of the ThresholdsThe t/lambda value represents the average number of inelastic scattering events + an electron undergoes. + Vacuum (< a): + (default a = 0.3) + In pure vacuum, t/lambda should be 0. In practice, values up to ~0.3 often indicate the presence of thin carbon support films, + surface contamination, or detector noise. Measurements in this regime are highly sensitive to ZLP (Zero Loss Peak) estimation errors. + + Thin (a c): + The "Multiple Scattering Regime. + " Most electrons have undergone three or more scattering events, resulting in a "spectral soup" + where fine-structure details and high-resolution chemical information are significantly broadened or lost. """ + name = dataset.name if dataset else "Dataset" # Classification Masks - vacuum = t_over_lambda < 0.3 - thin = (t_over_lambda >= 0.3) & (t_over_lambda < 1.0) - medium = (t_over_lambda >= 1.0) & (t_over_lambda < 2.0) - thick = t_over_lambda >= 2.0 + vacuum = t_over_lambda < a + thin = (t_over_lambda >= a) & (t_over_lambda < b) + medium = (t_over_lambda >= b) & (t_over_lambda < c) + thick = t_over_lambda >= c print(f"\n{'=' * 20} QUANTEM INTERPRETATION: {name} {'=' * 20}") for label, mask in [ @@ -616,3 +642,189 @@ def plot_absolute_thickness(t_lambda_map, mfp_nm, dataset=None): f"\nQuantEM Absolute Report:\n Mean: {np.nanmean(display_map):.2f} nm\n MFP: {mfp_nm:.2f} nm" ) return thickness_nm + + +def plot_dual_eels_picker(ll, hl, coords=None, title="QuantEM: Dual-EELS Analysis"): + """ + Dual-EELS Picker with starting coordinates. + """ + # 1. Setup Data + sum_ll = np.sum(ll.array, axis=0) + sum_hl = np.sum(hl.array, axis=0) + energy_ll = ll.origin[0] + np.arange(ll.shape[0]) * ll.sampling[0] + energy_hl = hl.origin[0] + np.arange(hl.shape[0]) * hl.sampling[0] + + # 2. Handle Initial Coordinates + if coords is not None: + cx, cy = coords + else: + cx, cy = ll.shape[2] // 2, ll.shape[1] // 2 + + # 3. Create Figure + fig, axes = plt.subplots(2, 2, figsize=(14, 9)) + fig.suptitle(f"{title}\n(Click on maps to update spectra)", fontsize=16) + ax_map_ll, ax_spec_ll = axes[0, 0], axes[0, 1] + ax_map_hl, ax_spec_hl = axes[1, 0], axes[1, 1] + + # Plot Maps & Markers + ax_map_ll.imshow(sum_ll, cmap="viridis", origin="lower") + (marker_ll,) = ax_map_ll.plot(cx, cy, "r+", ms=15, mew=2) + + ax_map_hl.imshow(sum_hl, cmap="magma", origin="lower") + (marker_hl,) = ax_map_hl.plot(cx, cy, "r+", ms=15, mew=2) + + # Plot Initial Spectra + (line_ll,) = ax_spec_ll.plot(energy_ll, ll.array[:, cy, cx], color="tab:blue") + (line_hl,) = ax_spec_hl.plot(energy_hl, hl.array[:, cy, cx], color="tab:red") + + def update_plots(x, y): + marker_ll.set_data([x], [y]) + marker_hl.set_data([x], [y]) + + new_ll = ll.array[:, y, x] + new_hl = hl.array[:, y, x] + line_ll.set_ydata(new_ll) + line_hl.set_ydata(new_hl) + + # Rescale + ax_spec_ll.set_ylim(0, np.max(new_ll) * 1.1) + ax_spec_hl.set_ylim(0, np.max(new_hl) * 1.1) + + ax_spec_ll.set_title(f"LL Spectrum at ({x}, {y})") + ax_spec_hl.set_title(f"HL Spectrum at ({x}, {y})") + fig.canvas.draw_idle() + + def on_click(event): + if event.inaxes in [ax_map_ll, ax_map_hl]: + ix, iy = int(round(event.xdata)), int(round(event.ydata)) + if 0 <= ix < ll.shape[2] and 0 <= iy < ll.shape[1]: + update_plots(ix, iy) + + fig.canvas.mpl_connect("button_press_event", on_click) + + ax_spec_ll.set_title(f"LL Spectrum at ({cx}, {cy})") + ax_spec_hl.set_title(f"HL Spectrum at ({cx}, {cy})") + + plt.tight_layout() + plt.show() + plt.close(fig) # Prevents double-plotting in VS Code + return fig + + +def plot_quantem_diagnostic(dataset, zlp_window=5.0, title_suffix=""): + """ + QuantEM Diagnostic Dashboard: Visualizes mean spectra, spatial variation, + and Zero Loss Peak (ZLP) centering accuracy. + + 1. Global Average Spectrum (Top Left): Shows the mean intensity across the entire scan. + It is used to check the signal-to-noise ratio and see if the Zero Loss Peak (ZLP) is roughly centered at 0 eV. + 2. Spatial Variation (Top Right): Plots spectra from a 5x5 grid of pixels across your sample. + This helps you see if the energy shift or intensity changes drastically from one side of the scan to the other + (e.g., due to sample thickness changes or beam drift). + 3. Integrated Intensity Map (Bottom Left): A spatial image of the total counts. + This is your "search image" to help you correlate the spectral data with the physical structure of your sample. + 4. ZLP Alignment Detail (Bottom Right): A high-zoom view of the energy region around 0 eV of the Mean Spectrum. + It includes a dashed green line at the "Target 0" to show exactly how much residual calibration error remains + after your alignment. + + Parameters: + ----------- + dataset : QuantEM Object + The EELS dataset containing .array, .origin, and .sampling attributes. + zlp_window : float, optional + The energy range (± eV) to display in the ZLP zoom plot. Default is 5.0. + title_suffix : str, optional + Additional text to append to the figure title (e.g., "(RAW)" or "(Aligned)"). + + Returns: + -------- + fig : matplotlib.figure.Figure + The figure object for further manipulation or saving. + """ + data = dataset.array + energy = dataset.origin[0] + np.arange(data.shape[0]) * dataset.sampling[0] + + mean_spec = np.mean(data, axis=(1, 2)) + zlp_pos = energy[np.argmax(mean_spec)] + sum_img = np.sum(data, axis=0) + + fig = plt.figure(figsize=(14, 9)) + gs = fig.add_gridspec(2, 2, hspace=0.3, wspace=0.2) + fig.suptitle(f"QuantEM Diagnostic: {dataset.name} {title_suffix}", fontsize=16) + + # 1. Mean Spectrum + ax1 = fig.add_subplot(gs[0, 0]) + ax1.plot(energy, mean_spec, color="black", label="Mean") + ax1.axvline(0, color="green", ls=":", label="Target") + ax1.set_title("Global Average Spectrum") + ax1.legend() + + # 2. Spatial Variability + ax2 = fig.add_subplot(gs[0, 1]) + # Take a 5x5 grid for better representation than 3x3 + yy, xx = np.meshgrid( + np.linspace(0, data.shape[1] - 1, 5, dtype=int), + np.linspace(0, data.shape[2] - 1, 5, dtype=int), + ) + for y, x in zip(yy.flatten(), xx.flatten()): + ax2.plot(energy, data[:, y, x], alpha=0.3, lw=0.5) + ax2.set_title("Spatial Variation (Grid Samples)") + + # 3. Map + ax3 = fig.add_subplot(gs[1, 0]) + im = ax3.imshow(sum_img, cmap="viridis", origin="lower") + plt.colorbar(im, ax=ax3) + ax3.set_title("Integrated Intensity") + + # 4. ZLP Zoom + ax4 = fig.add_subplot(gs[1, 1]) + mask = (energy > zlp_pos - zlp_window) & (energy < zlp_pos + zlp_window) + ax4.plot(energy[mask], mean_spec[mask], lw=2) + ax4.axvline(0, color="green", ls=":") + ax4.set_title("ZLP Alignment Detail") + plt.close(fig) + + return fig + + +def plot_zlp_drift_diagnostics(dataset, title="ZLP Drift Analysis"): + """ + QuantEM Diagnostic: Maps the ZLP position and calculates the drift distribution. + Uses scipy.stats for Gaussian fitting. + """ + data = dataset.array + energy = dataset.origin[0] + np.arange(data.shape[0]) * dataset.sampling[0] + + # 1. Mask and find peak per pixel + search_mask = (energy > -2.0) & (energy < 2.0) + search_energies = energy[search_mask] + peak_indices = np.argmax(data[search_mask, :, :], axis=0) + zlp_map = search_energies[peak_indices] + + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 6)) + fig.suptitle(f"QuantEM: {dataset.name} - {title}", fontsize=16) + + # Plot A: Map + im = ax1.imshow(zlp_map, cmap="RdYlBu_r", origin="lower") + plt.colorbar(im, ax=ax1, label="Energy Shift (eV)") + + # Plot B: Histogram + Scipy Fit + flat_pos = zlp_map.flatten() + mu, std = norm.fit(flat_pos) # Professional scipy fitting + + ax2.hist(flat_pos, bins=30, density=True, alpha=0.6, color="skyblue") + x_range = np.linspace(np.min(flat_pos), np.max(flat_pos), 100) + ax2.plot( + x_range, + norm.pdf(x_range, mu, std), + color="darkred", + lw=2, + label=f"Fit: μ={mu:.3f} eV, σ={std:.3f} eV", + ) + ax2.legend() + + plt.tight_layout() + + plt.close(fig) + + return fig From 14888e17aef9282d148f200d36730223612bf978 Mon Sep 17 00:00:00 2001 From: yaeltsarfati Date: Tue, 7 Apr 2026 16:16:45 -0700 Subject: [PATCH 098/136] tab correct --- src/quantem/spectroscopy/dataset3deels.py | 365 +++++++++++----------- 1 file changed, 181 insertions(+), 184 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index f5fda015..273efa97 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -643,188 +643,185 @@ def plot_absolute_thickness(t_lambda_map, mfp_nm, dataset=None): ) return thickness_nm + def plot_dual_eels_picker(ll, hl, coords=None, title="QuantEM: Dual-EELS Analysis"): + """ + Dual-EELS Picker with starting coordinates. + """ + # 1. Setup Data + sum_ll = np.sum(ll.array, axis=0) + sum_hl = np.sum(hl.array, axis=0) + energy_ll = ll.origin[0] + np.arange(ll.shape[0]) * ll.sampling[0] + energy_hl = hl.origin[0] + np.arange(hl.shape[0]) * hl.sampling[0] + + # 2. Handle Initial Coordinates + if coords is not None: + cx, cy = coords + else: + cx, cy = ll.shape[2] // 2, ll.shape[1] // 2 -def plot_dual_eels_picker(ll, hl, coords=None, title="QuantEM: Dual-EELS Analysis"): - """ - Dual-EELS Picker with starting coordinates. - """ - # 1. Setup Data - sum_ll = np.sum(ll.array, axis=0) - sum_hl = np.sum(hl.array, axis=0) - energy_ll = ll.origin[0] + np.arange(ll.shape[0]) * ll.sampling[0] - energy_hl = hl.origin[0] + np.arange(hl.shape[0]) * hl.sampling[0] - - # 2. Handle Initial Coordinates - if coords is not None: - cx, cy = coords - else: - cx, cy = ll.shape[2] // 2, ll.shape[1] // 2 - - # 3. Create Figure - fig, axes = plt.subplots(2, 2, figsize=(14, 9)) - fig.suptitle(f"{title}\n(Click on maps to update spectra)", fontsize=16) - ax_map_ll, ax_spec_ll = axes[0, 0], axes[0, 1] - ax_map_hl, ax_spec_hl = axes[1, 0], axes[1, 1] - - # Plot Maps & Markers - ax_map_ll.imshow(sum_ll, cmap="viridis", origin="lower") - (marker_ll,) = ax_map_ll.plot(cx, cy, "r+", ms=15, mew=2) - - ax_map_hl.imshow(sum_hl, cmap="magma", origin="lower") - (marker_hl,) = ax_map_hl.plot(cx, cy, "r+", ms=15, mew=2) - - # Plot Initial Spectra - (line_ll,) = ax_spec_ll.plot(energy_ll, ll.array[:, cy, cx], color="tab:blue") - (line_hl,) = ax_spec_hl.plot(energy_hl, hl.array[:, cy, cx], color="tab:red") - - def update_plots(x, y): - marker_ll.set_data([x], [y]) - marker_hl.set_data([x], [y]) - - new_ll = ll.array[:, y, x] - new_hl = hl.array[:, y, x] - line_ll.set_ydata(new_ll) - line_hl.set_ydata(new_hl) - - # Rescale - ax_spec_ll.set_ylim(0, np.max(new_ll) * 1.1) - ax_spec_hl.set_ylim(0, np.max(new_hl) * 1.1) - - ax_spec_ll.set_title(f"LL Spectrum at ({x}, {y})") - ax_spec_hl.set_title(f"HL Spectrum at ({x}, {y})") - fig.canvas.draw_idle() - - def on_click(event): - if event.inaxes in [ax_map_ll, ax_map_hl]: - ix, iy = int(round(event.xdata)), int(round(event.ydata)) - if 0 <= ix < ll.shape[2] and 0 <= iy < ll.shape[1]: - update_plots(ix, iy) - - fig.canvas.mpl_connect("button_press_event", on_click) - - ax_spec_ll.set_title(f"LL Spectrum at ({cx}, {cy})") - ax_spec_hl.set_title(f"HL Spectrum at ({cx}, {cy})") - - plt.tight_layout() - plt.show() - plt.close(fig) # Prevents double-plotting in VS Code - return fig - - -def plot_quantem_diagnostic(dataset, zlp_window=5.0, title_suffix=""): - """ - QuantEM Diagnostic Dashboard: Visualizes mean spectra, spatial variation, - and Zero Loss Peak (ZLP) centering accuracy. - - 1. Global Average Spectrum (Top Left): Shows the mean intensity across the entire scan. - It is used to check the signal-to-noise ratio and see if the Zero Loss Peak (ZLP) is roughly centered at 0 eV. - 2. Spatial Variation (Top Right): Plots spectra from a 5x5 grid of pixels across your sample. - This helps you see if the energy shift or intensity changes drastically from one side of the scan to the other - (e.g., due to sample thickness changes or beam drift). - 3. Integrated Intensity Map (Bottom Left): A spatial image of the total counts. - This is your "search image" to help you correlate the spectral data with the physical structure of your sample. - 4. ZLP Alignment Detail (Bottom Right): A high-zoom view of the energy region around 0 eV of the Mean Spectrum. - It includes a dashed green line at the "Target 0" to show exactly how much residual calibration error remains - after your alignment. - - Parameters: - ----------- - dataset : QuantEM Object - The EELS dataset containing .array, .origin, and .sampling attributes. - zlp_window : float, optional - The energy range (± eV) to display in the ZLP zoom plot. Default is 5.0. - title_suffix : str, optional - Additional text to append to the figure title (e.g., "(RAW)" or "(Aligned)"). - - Returns: - -------- - fig : matplotlib.figure.Figure - The figure object for further manipulation or saving. - """ - data = dataset.array - energy = dataset.origin[0] + np.arange(data.shape[0]) * dataset.sampling[0] - - mean_spec = np.mean(data, axis=(1, 2)) - zlp_pos = energy[np.argmax(mean_spec)] - sum_img = np.sum(data, axis=0) - - fig = plt.figure(figsize=(14, 9)) - gs = fig.add_gridspec(2, 2, hspace=0.3, wspace=0.2) - fig.suptitle(f"QuantEM Diagnostic: {dataset.name} {title_suffix}", fontsize=16) - - # 1. Mean Spectrum - ax1 = fig.add_subplot(gs[0, 0]) - ax1.plot(energy, mean_spec, color="black", label="Mean") - ax1.axvline(0, color="green", ls=":", label="Target") - ax1.set_title("Global Average Spectrum") - ax1.legend() - - # 2. Spatial Variability - ax2 = fig.add_subplot(gs[0, 1]) - # Take a 5x5 grid for better representation than 3x3 - yy, xx = np.meshgrid( - np.linspace(0, data.shape[1] - 1, 5, dtype=int), - np.linspace(0, data.shape[2] - 1, 5, dtype=int), - ) - for y, x in zip(yy.flatten(), xx.flatten()): - ax2.plot(energy, data[:, y, x], alpha=0.3, lw=0.5) - ax2.set_title("Spatial Variation (Grid Samples)") - - # 3. Map - ax3 = fig.add_subplot(gs[1, 0]) - im = ax3.imshow(sum_img, cmap="viridis", origin="lower") - plt.colorbar(im, ax=ax3) - ax3.set_title("Integrated Intensity") - - # 4. ZLP Zoom - ax4 = fig.add_subplot(gs[1, 1]) - mask = (energy > zlp_pos - zlp_window) & (energy < zlp_pos + zlp_window) - ax4.plot(energy[mask], mean_spec[mask], lw=2) - ax4.axvline(0, color="green", ls=":") - ax4.set_title("ZLP Alignment Detail") - plt.close(fig) - - return fig - - -def plot_zlp_drift_diagnostics(dataset, title="ZLP Drift Analysis"): - """ - QuantEM Diagnostic: Maps the ZLP position and calculates the drift distribution. - Uses scipy.stats for Gaussian fitting. - """ - data = dataset.array - energy = dataset.origin[0] + np.arange(data.shape[0]) * dataset.sampling[0] - - # 1. Mask and find peak per pixel - search_mask = (energy > -2.0) & (energy < 2.0) - search_energies = energy[search_mask] - peak_indices = np.argmax(data[search_mask, :, :], axis=0) - zlp_map = search_energies[peak_indices] - - fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 6)) - fig.suptitle(f"QuantEM: {dataset.name} - {title}", fontsize=16) - - # Plot A: Map - im = ax1.imshow(zlp_map, cmap="RdYlBu_r", origin="lower") - plt.colorbar(im, ax=ax1, label="Energy Shift (eV)") - - # Plot B: Histogram + Scipy Fit - flat_pos = zlp_map.flatten() - mu, std = norm.fit(flat_pos) # Professional scipy fitting - - ax2.hist(flat_pos, bins=30, density=True, alpha=0.6, color="skyblue") - x_range = np.linspace(np.min(flat_pos), np.max(flat_pos), 100) - ax2.plot( - x_range, - norm.pdf(x_range, mu, std), - color="darkred", - lw=2, - label=f"Fit: μ={mu:.3f} eV, σ={std:.3f} eV", - ) - ax2.legend() - - plt.tight_layout() - - plt.close(fig) - - return fig + # 3. Create Figure + fig, axes = plt.subplots(2, 2, figsize=(14, 9)) + fig.suptitle(f"{title}\n(Click on maps to update spectra)", fontsize=16) + ax_map_ll, ax_spec_ll = axes[0, 0], axes[0, 1] + ax_map_hl, ax_spec_hl = axes[1, 0], axes[1, 1] + + # Plot Maps & Markers + ax_map_ll.imshow(sum_ll, cmap="viridis", origin="lower") + (marker_ll,) = ax_map_ll.plot(cx, cy, "r+", ms=15, mew=2) + + ax_map_hl.imshow(sum_hl, cmap="magma", origin="lower") + (marker_hl,) = ax_map_hl.plot(cx, cy, "r+", ms=15, mew=2) + + # Plot Initial Spectra + (line_ll,) = ax_spec_ll.plot(energy_ll, ll.array[:, cy, cx], color="tab:blue") + (line_hl,) = ax_spec_hl.plot(energy_hl, hl.array[:, cy, cx], color="tab:red") + + def update_plots(x, y): + marker_ll.set_data([x], [y]) + marker_hl.set_data([x], [y]) + + new_ll = ll.array[:, y, x] + new_hl = hl.array[:, y, x] + line_ll.set_ydata(new_ll) + line_hl.set_ydata(new_hl) + + # Rescale + ax_spec_ll.set_ylim(0, np.max(new_ll) * 1.1) + ax_spec_hl.set_ylim(0, np.max(new_hl) * 1.1) + + ax_spec_ll.set_title(f"LL Spectrum at ({x}, {y})") + ax_spec_hl.set_title(f"HL Spectrum at ({x}, {y})") + fig.canvas.draw_idle() + + def on_click(event): + if event.inaxes in [ax_map_ll, ax_map_hl]: + ix, iy = int(round(event.xdata)), int(round(event.ydata)) + if 0 <= ix < ll.shape[2] and 0 <= iy < ll.shape[1]: + update_plots(ix, iy) + + fig.canvas.mpl_connect("button_press_event", on_click) + + ax_spec_ll.set_title(f"LL Spectrum at ({cx}, {cy})") + ax_spec_hl.set_title(f"HL Spectrum at ({cx}, {cy})") + + plt.tight_layout() + plt.show() + plt.close(fig) # Prevents double-plotting in VS Code + return fig + + def plot_quantem_diagnostic(dataset, zlp_window=5.0, title_suffix=""): + """ + QuantEM Diagnostic Dashboard: Visualizes mean spectra, spatial variation, + and Zero Loss Peak (ZLP) centering accuracy. + + 1. Global Average Spectrum (Top Left): Shows the mean intensity across the entire scan. + It is used to check the signal-to-noise ratio and see if the Zero Loss Peak (ZLP) is roughly centered at 0 eV. + 2. Spatial Variation (Top Right): Plots spectra from a 5x5 grid of pixels across your sample. + This helps you see if the energy shift or intensity changes drastically from one side of the scan to the other + (e.g., due to sample thickness changes or beam drift). + 3. Integrated Intensity Map (Bottom Left): A spatial image of the total counts. + This is your "search image" to help you correlate the spectral data with the physical structure of your sample. + 4. ZLP Alignment Detail (Bottom Right): A high-zoom view of the energy region around 0 eV of the Mean Spectrum. + It includes a dashed green line at the "Target 0" to show exactly how much residual calibration error remains + after your alignment. + + Parameters: + ----------- + dataset : QuantEM Object + The EELS dataset containing .array, .origin, and .sampling attributes. + zlp_window : float, optional + The energy range (± eV) to display in the ZLP zoom plot. Default is 5.0. + title_suffix : str, optional + Additional text to append to the figure title (e.g., "(RAW)" or "(Aligned)"). + + Returns: + -------- + fig : matplotlib.figure.Figure + The figure object for further manipulation or saving. + """ + data = dataset.array + energy = dataset.origin[0] + np.arange(data.shape[0]) * dataset.sampling[0] + + mean_spec = np.mean(data, axis=(1, 2)) + zlp_pos = energy[np.argmax(mean_spec)] + sum_img = np.sum(data, axis=0) + + fig = plt.figure(figsize=(14, 9)) + gs = fig.add_gridspec(2, 2, hspace=0.3, wspace=0.2) + fig.suptitle(f"QuantEM Diagnostic: {dataset.name} {title_suffix}", fontsize=16) + + # 1. Mean Spectrum + ax1 = fig.add_subplot(gs[0, 0]) + ax1.plot(energy, mean_spec, color="black", label="Mean") + ax1.axvline(0, color="green", ls=":", label="Target") + ax1.set_title("Global Average Spectrum") + ax1.legend() + + # 2. Spatial Variability + ax2 = fig.add_subplot(gs[0, 1]) + # Take a 5x5 grid for better representation than 3x3 + yy, xx = np.meshgrid( + np.linspace(0, data.shape[1] - 1, 5, dtype=int), + np.linspace(0, data.shape[2] - 1, 5, dtype=int), + ) + for y, x in zip(yy.flatten(), xx.flatten()): + ax2.plot(energy, data[:, y, x], alpha=0.3, lw=0.5) + ax2.set_title("Spatial Variation (Grid Samples)") + + # 3. Map + ax3 = fig.add_subplot(gs[1, 0]) + im = ax3.imshow(sum_img, cmap="viridis", origin="lower") + plt.colorbar(im, ax=ax3) + ax3.set_title("Integrated Intensity") + + # 4. ZLP Zoom + ax4 = fig.add_subplot(gs[1, 1]) + mask = (energy > zlp_pos - zlp_window) & (energy < zlp_pos + zlp_window) + ax4.plot(energy[mask], mean_spec[mask], lw=2) + ax4.axvline(0, color="green", ls=":") + ax4.set_title("ZLP Alignment Detail") + plt.close(fig) + + return fig + + def plot_zlp_drift_diagnostics(dataset, title="ZLP Drift Analysis"): + """ + QuantEM Diagnostic: Maps the ZLP position and calculates the drift distribution. + Uses scipy.stats for Gaussian fitting. + """ + data = dataset.array + energy = dataset.origin[0] + np.arange(data.shape[0]) * dataset.sampling[0] + + # 1. Mask and find peak per pixel + search_mask = (energy > -2.0) & (energy < 2.0) + search_energies = energy[search_mask] + peak_indices = np.argmax(data[search_mask, :, :], axis=0) + zlp_map = search_energies[peak_indices] + + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 6)) + fig.suptitle(f"QuantEM: {dataset.name} - {title}", fontsize=16) + + # Plot A: Map + im = ax1.imshow(zlp_map, cmap="RdYlBu_r", origin="lower") + plt.colorbar(im, ax=ax1, label="Energy Shift (eV)") + + # Plot B: Histogram + Scipy Fit + flat_pos = zlp_map.flatten() + mu, std = norm.fit(flat_pos) # Professional scipy fitting + + ax2.hist(flat_pos, bins=30, density=True, alpha=0.6, color="skyblue") + x_range = np.linspace(np.min(flat_pos), np.max(flat_pos), 100) + ax2.plot( + x_range, + norm.pdf(x_range, mu, std), + color="darkred", + lw=2, + label=f"Fit: μ={mu:.3f} eV, σ={std:.3f} eV", + ) + ax2.legend() + + plt.tight_layout() + + plt.close(fig) + + return fig From 96616e8fc12d33fd291663c43c02be54d4641f26 Mon Sep 17 00:00:00 2001 From: yaeltsarfati Date: Tue, 7 Apr 2026 16:27:27 -0700 Subject: [PATCH 099/136] Double plotting corection in plot dual eels picker function. --- src/quantem/spectroscopy/dataset3deels.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index 273efa97..95ef9c59 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -705,7 +705,6 @@ def on_click(event): ax_spec_hl.set_title(f"HL Spectrum at ({cx}, {cy})") plt.tight_layout() - plt.show() plt.close(fig) # Prevents double-plotting in VS Code return fig From 457933f914497de2effedb92e2577db69f079200 Mon Sep 17 00:00:00 2001 From: smribet Date: Fri, 17 Apr 2026 14:42:10 -0700 Subject: [PATCH 100/136] fix for spec file readers --- src/quantem/core/io/file_readers.py | 89 +++++++++-------------------- 1 file changed, 28 insertions(+), 61 deletions(-) diff --git a/src/quantem/core/io/file_readers.py b/src/quantem/core/io/file_readers.py index ec1031c5..64e15e82 100644 --- a/src/quantem/core/io/file_readers.py +++ b/src/quantem/core/io/file_readers.py @@ -122,7 +122,9 @@ def read_4dstem( return dataset -def read_3d_spectroscopy(file_path: str, file_type: str, data_type: str, dataset_index: int | None = None) -> Dataset3dspectroscopy: +def read_3d_spectroscopy( + file_path: str, file_type: str, data_type: str, dataset_index: int | None = None +) -> Dataset3dspectroscopy: """ File reader for 3D spectroscopy data @@ -141,7 +143,7 @@ def read_3d_spectroscopy(file_path: str, file_type: str, data_type: str, dataset """ file_reader = importlib.import_module(f"rsciio.{file_type}").file_reader # type: ignore data_list = file_reader(file_path) - + # If specific index provided, use it if dataset_index is not None: imported_data = data_list[dataset_index] @@ -161,80 +163,45 @@ def read_3d_spectroscopy(file_path: str, file_type: str, data_type: str, dataset raise ValueError("No 3D dataset found in file") dataset_index, imported_data = three_d_datasets[0] - + dataset_indices = [] for entry in three_d_datasets: dataset_indices.append(entry[0]) - if len(data_list) > 1: print( f"File contains {len(data_list)} dataset(s) and {len(three_d_datasets)} 3D dataset(s) at indices {', '.join(map(str, dataset_indices))}. Using dataset {dataset_index} with shape {imported_data['data'].shape}" ) imported_axes = imported_data["axes"] - # imported_data[0], + axis_order = (0, 1, 2) if file_type == "digitalmicrograph" else (2, 0, 1) + array = ( + imported_data["data"] + if file_type == "digitalmicrograph" + else imported_data["data"].transpose(axis_order) + ) + ordered_axes = [imported_axes[idx] for idx in axis_order] + sampling = [ax.get("scale", 1) for ax in ordered_axes] + origin = [ax.get("offset", 0) for ax in ordered_axes] + units = [ + "pixels" if ax.get("units", "1") == "1" else ax.get("units", "pixels") + for ax in ordered_axes + ] + if data_type == "EELS": - if file_type == "digitalmicrograph": - dataset = Dataset3deels.from_array( - array=imported_data["data"], - sampling=[ - imported_data["axes"][0]["scale"], - imported_data["axes"][1]["scale"], - imported_data["axes"][2]["scale"], - ], - origin=[ - imported_data["axes"][0]["offset"], - imported_data["axes"][1]["offset"], - imported_data["axes"][2]["offset"], - ], - units=[ - imported_data["axes"][0]["units"], - imported_data["axes"][1]["units"], - imported_data["axes"][2]["units"], - ], - ) - else: - dataset = Dataset3deels.from_array( - array=imported_data["data"].transpose((2, 0, 1)), - sampling=[ - imported_data["axes"][2]["scale"], - imported_data["axes"][0]["scale"], - imported_data["axes"][1]["scale"], - ], - origin=[ - imported_data["axes"][2]["offset"], - imported_data["axes"][0]["offset"], - imported_data["axes"][1]["offset"], - ], - units=[ - imported_data["axes"][2]["units"], - imported_data["axes"][0]["units"], - imported_data["axes"][1]["units"], - ], - ) + dataset_cls = Dataset3deels elif data_type == "EDS": - dataset = Dataset3deds.from_array( - array=imported_data["data"].transpose((2, 0, 1)), - sampling=[ - imported_data["axes"][2]["scale"], - imported_data["axes"][0]["scale"], - imported_data["axes"][1]["scale"], - ], - origin=[ - imported_data["axes"][2]["offset"], - imported_data["axes"][0]["offset"], - imported_data["axes"][1]["offset"], - ], - units=[ - imported_data["axes"][2]["units"], - imported_data["axes"][0]["units"], - imported_data["axes"][1]["units"], - ], - ) + dataset_cls = Dataset3deds else: raise ValueError(f"`data_type` must be `EDS` or `EELS` not `{data_type}`") + dataset = dataset_cls.from_array( + array=array, + sampling=sampling, + origin=origin, + units=units, + ) + return dataset From 7803d74d7fbeb3adc8611a180ae29a2e39d7c43a Mon Sep 17 00:00:00 2001 From: smribet Date: Fri, 17 Apr 2026 15:17:18 -0700 Subject: [PATCH 101/136] fix for load element info --- src/quantem/spectroscopy/dataset3deds.py | 3 ++ .../spectroscopy/dataset3dspectroscopy.py | 39 ++++++++++--------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 78b44b51..d3c0da98 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -33,6 +33,9 @@ class Dataset3deds(Dataset3dspectroscopy): """ + element_info = None + element_info_path = "x_ray_lines.csv" + def __init__( self, array: NDArray | Any, diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index bef9eb45..6c2852e3 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -16,6 +16,10 @@ class Dataset3dspectroscopy(Dataset3d): # stores the element line info so you don't need to reload each time + element_info = None + element_info_path = None + atomic_weights = None + def __init__( self, array: NDArray | Any, @@ -38,21 +42,19 @@ def __init__( self.model_elements = None self.attached_spectra = None - self.element_info = None # loads elemental information @classmethod def load_element_info(cls): """Load element database for EDS (X-ray lines) or EELS (binding energies).""" - if hasattr(cls, "element_info"): - return + if cls.element_info is not None: + return cls.element_info - class_type = str(getattr(cls, "dataset_type", "")).strip().lower() - path = ( - "eels_binding_energies.json" - if class_type == "eels" - else getattr(cls, "x_ray_lines.csv", "x_ray_lines.csv") - ) + path = getattr(cls, "element_info_path", None) + if path is None: + raise NotImplementedError( + f"{cls.__name__} must define `element_info_path` to load element metadata." + ) full_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), path) if path.lower().endswith(".csv"): @@ -60,12 +62,18 @@ def load_element_info(cls): else: with open(full_path, "r", encoding="utf-8") as f: cls.element_info = json.load(f)["elements"] + return cls.element_info + + @classmethod + def _ensure_element_info(cls): + """Load and return the cached element metadata.""" + return cls.load_element_info() or {} @classmethod def load_atomic_weights(cls): """Load atomic weights table from CSV once per class.""" if cls.atomic_weights is not None: - return + return cls.atomic_weights atomic_weights_path = "atomic_weights.csv" full_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), atomic_weights_path) @@ -95,6 +103,7 @@ def load_atomic_weights(cls): raise ValueError(f"{atomic_weights_path} did not contain any atomic weights") cls.atomic_weights = data + return cls.atomic_weights @staticmethod def _normalize_element_specs(specs): @@ -145,11 +154,7 @@ def add_elements_to_model(self, elements): - 'Te La' (only Te La line) - ['Au Ma', 'Te La', 'Si'] """ - # Load element info if not already loaded - if type(self).element_info is None: - type(self).load_element_info() - - all_info = type(self).element_info + all_info = type(self)._ensure_element_info() if all_info is None: return @@ -885,9 +890,7 @@ def refline( if energy is not None and energy_range is not None: raise ValueError("Specify either energy or energy_range, not both") - if type(self).element_info is None: - type(self).load_element_info() - all_info = type(self).element_info or {} + all_info = type(self)._ensure_element_info() specs = type(self)._normalize_element_specs(elements) if len(specs) == 0: From 16802bf42481997753bfae0786f162cb5989cc9d Mon Sep 17 00:00:00 2001 From: Sangoda <87961379+Sangoda@users.noreply.github.com> Date: Mon, 20 Apr 2026 08:22:55 -0700 Subject: [PATCH 102/136] fix for roi and auto_id --- src/quantem/spectroscopy/dataset3deds.py | 2687 +++++------------ .../spectroscopy/dataset3dspectroscopy.py | 30 +- 2 files changed, 799 insertions(+), 1918 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index d3c0da98..c4fb6100 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -43,28 +43,9 @@ def __init__( origin: NDArray | tuple | list | float | int, sampling: NDArray | tuple | list | float | int, units: list[str] | tuple | list, - signal_units: str = "arb. units", + signal_units: str = 'arb. units', _token: object | None = None, ): - """Initialize a 3D EDS dataset. - - Parameters - ---------- - array : NDArray | Any - The underlying 3D array data - name : str - A descriptive name for the dataset - origin : NDArray | tuple | list | float | int - The origin coordinates for each dimension - sampling : NDArray | tuple | list | float | int - The sampling rate/spacing for each dimension - units : list[str] | tuple | list - Units for each dimension - signal_units : str, optional - Units for the array values, by default "arb. units" - _token : object | None, optional - Token to prevent direct instantiation, by default None - """ super().__init__( array=array, name=name, @@ -74,118 +55,93 @@ def __init__( signal_units=signal_units, _token=_token, ) - - self.dataset_type = "eds" + self.dataset_type = 'eds' @staticmethod - def _normalize_specs(specs, param_name="spec", allow_none=False): - if specs is None and allow_none: - return None + def _normalize_specs(specs, param_name='spec', allow_none=False): + if specs is None: + if allow_none: + return None + raise TypeError(f'{param_name} must be a string or sequence of strings') if isinstance(specs, str): - return [s.strip() for s in specs.split(",") if s.strip()] + return [s.strip() for s in specs.split(',') if s.strip()] if isinstance(specs, (list, tuple, set)): - out = [] - for item in specs: - out.extend([s.strip() for s in str(item).split(",") if s.strip()]) - return out - raise TypeError( - f"{param_name} must be {'None, ' if allow_none else ''}a string or a sequence of strings" - ) + return [s.strip() for item in specs for s in str(item).split(',') if s.strip()] + raise TypeError(f'{param_name} must be a string or sequence of strings') @staticmethod def _normalize_token(text): - return re.sub(r"[^a-z0-9]", "", str(text).lower()) + return re.sub(r'[^a-z0-9]', '', str(text).lower()) @staticmethod def _ordered_element_keys(all_info): - return sorted([str(key) for key in all_info], key=lambda k: (-len(k), k)) + return sorted(map(str, all_info), key=lambda k: (-len(k), k)) @classmethod def _resolve_element_from_label(cls, label, ordered_elements): - label_str = str(label) - for element_name in ordered_elements: - if label_str.startswith(element_name): - return element_name - m = re.match(r"^[A-Z][a-z]?", label_str) + label = str(label) + for element in ordered_elements: + if label.startswith(element): + return element + m = re.match(r'^[A-Z][a-z]?', label) return m.group(0) if m else None @classmethod def _ensure_element_info(cls): - """Load and return the element->lines info dict.""" if cls.element_info is None: cls.load_element_info() return cls.element_info or {} @classmethod - def _parse_element_selectors(cls, specs, *, allow_none=False, param_name="spec"): - """Parse selectors like 'Fe', 'FeK', 'FeKa1' into {element: None|set[tokens]}.""" + def _parse_element_selectors(cls, specs, *, allow_none=False, param_name='spec'): tokens = cls._normalize_specs(specs, param_name=param_name, allow_none=allow_none) if tokens is None: return None - info = cls._ensure_element_info() - ordered = cls._ordered_element_keys(info) + ordered = cls._ordered_element_keys(cls._ensure_element_info()) out: dict[str, set[str] | None] = {} - for raw in tokens: - compact = re.sub(r"[\s_-]+", "", str(raw).strip()) + compact = re.sub(r'[\s_-]+', '', str(raw).strip()) if not compact: continue - element = next((k for k in ordered if compact.lower().startswith(k.lower())), None) if element is None: raise ValueError(f"Could not resolve element from specifier '{raw}'") - - suffix = compact[len(element) :] - if not suffix: - out[element] = None - else: - out.setdefault(element, set()) - if out[element] is not None: - out[element].add(str(suffix)) - + suffix = compact[len(element):] + out.setdefault(element, None if not suffix else set()) + if suffix and out[element] is not None: + out[element].add(suffix) return out or None @staticmethod def _canonical_line_name(line_name: str) -> str: - return str(line_name).split("__", 1)[0] + return str(line_name).split('__', 1)[0] @classmethod def _iter_selected_lines(cls, element: str, suffix: str, *, raw_spec: str): - """Yield (line_name, line_info) for an element based on suffix matching.""" - info = cls._ensure_element_info() - lines = info.get(element) or {} - if not isinstance(lines, dict) or not lines: + lines = cls._ensure_element_info().get(element) or {} + if not lines: raise ValueError(f"No X-ray lines found for element '{element}'") - if not suffix: yield from lines.items() return - suffix_norm = cls._normalize_token(suffix) - if not suffix_norm: - raise ValueError(f"Could not parse line/edge token from '{raw_spec}'") - + suffix = cls._normalize_token(suffix) exact, prefix = [], [] - for ln, li in lines.items(): - base = cls._canonical_line_name(str(ln)) - ln_norm = cls._normalize_token(base) - if ln_norm == suffix_norm: - exact.append((ln, li)) - if ln_norm.startswith(suffix_norm): - prefix.append((ln, li)) - - chosen = exact or prefix - if not chosen: - raise ValueError( - f"No X-ray lines matched specifier '{raw_spec}' for element '{element}'" - ) - yield from chosen + for line_name, line_info in lines.items(): + token = cls._normalize_token(cls._canonical_line_name(line_name)) + if token == suffix: + exact.append((line_name, line_info)) + if token.startswith(suffix): + prefix.append((line_name, line_info)) + matches = exact or prefix + if not matches: + raise ValueError(f"No X-ray lines matched specifier '{raw_spec}' for element '{element}'") + yield from matches @classmethod def _group_labels_by_element(cls, labels: list[str]): - info = cls._ensure_element_info() - ordered = cls._ordered_element_keys(info) + ordered = cls._ordered_element_keys(cls._ensure_element_info()) grouped: dict[str, list[str]] = {} for lbl in sorted(map(str, labels)): element = cls._resolve_element_from_label(lbl, ordered) @@ -194,560 +150,373 @@ def _group_labels_by_element(cls, labels: list[str]): return grouped @classmethod - def _select_labels( - cls, selector: str, *, labels: list[str], labels_by_element: dict[str, list[str]] - ): - """Select labels from available spectrum-image labels using selector semantics.""" - sel = str(selector).strip() - if not sel: + def _select_labels(cls, selector: str, *, labels: list[str], labels_by_element: dict[str, list[str]]): + selector = str(selector).strip() + if not selector: return [] lower_map = {lbl.lower(): lbl for lbl in labels} - if sel.lower() in lower_map: - return [lower_map[sel.lower()]] + if selector.lower() in lower_map: + return [lower_map[selector.lower()]] elem_map = {elem.lower(): elem for elem in labels_by_element} - if sel.lower() in elem_map: - return list(labels_by_element[elem_map[sel.lower()]]) + if selector.lower() in elem_map: + return list(labels_by_element[elem_map[selector.lower()]]) - compact = cls._normalize_token(sel) - return [lbl for lbl in labels if cls._normalize_token(lbl).startswith(compact)] + token = cls._normalize_token(selector) + return [lbl for lbl in labels if cls._normalize_token(lbl).startswith(token)] @staticmethod def _line_shell(line_name: str) -> str: - line_upper = str(line_name).upper() - if line_upper.startswith("K"): - return "K" - if line_upper.startswith("L"): - return "L" - if line_upper.startswith("M"): - return "M" - return "?" + line_name = str(line_name).upper() + return 'K' if line_name.startswith('K') else 'L' if line_name.startswith('L') else 'M' if line_name.startswith('M') else '?' @staticmethod - def _peak_confidence( - snr_value: float, line_weight: float, distance_value: float, tolerance: float - ) -> float: - # Use a Gaussian distance likelihood: exp(-0.5 * (d/sigma)^2). - # sigma = tolerance / 3 so the hard cutoff sits at ~3sigma, - # giving a steep penalty for distance while tolerance stays as a - # generous search window. This prevents heavy-element Ma1 lines - # (weight 1.0) from overshadowing lighter-element K-lines at much - # closer distances (e.g. Os Ma1 vs P Ka1 near 2 keV). + def _peak_confidence(snr_value: float, line_weight: float, distance_value: float, tolerance: float) -> float: sigma = max(float(tolerance) / 3.0, 1e-9) - snr_term = np.log1p(max(float(snr_value), 0.0)) - weight_term = max(float(line_weight), 0.0) - distance_term = np.exp(-0.5 * (float(distance_value) / sigma) ** 2) - return snr_term * weight_term * distance_term + return np.log1p(max(float(snr_value), 0.0)) * max(float(line_weight), 0.0) * np.exp( + -0.5 * (float(distance_value) / sigma) ** 2 + ) @staticmethod def _line_matches_selector(line_name: str, selector: str) -> bool: line = str(line_name).strip().lower() - token = str(selector).strip().lower() - if token in {"k", "l", "m"}: - return line.startswith(token) - return token in line + selector = str(selector).strip().lower() + return line.startswith(selector) if selector in {'k', 'l', 'm'} else selector in line @classmethod - def _line_allowed_for_element( - cls, element_name: str, line_name: str, edge_filters=None - ) -> bool: - if edge_filters is None: - return True - selectors = edge_filters.get(str(element_name)) - if selectors is None: - return True - return any(cls._line_matches_selector(line_name, token) for token in selectors) - - def x_ray_lookup( - self, spec: str | list[str] | tuple[str, ...] | set[str] - ) -> tuple[np.ndarray, np.ndarray, list[str]]: - """Lookup EDS X-ray lines for element, shell, or specific line specifiers.""" + def _line_allowed_for_element(cls, element_name: str, line_name: str, edge_filters=None) -> bool: + selectors = None if edge_filters is None else edge_filters.get(str(element_name)) + return selectors is None or any(cls._line_matches_selector(line_name, token) for token in selectors) + + def _get_spectrum_images(self, method='integration'): + return { + 'integration': getattr(self, '_spectrum_images', None), + 'fit': getattr(self, '_spectrum_images_pytorch', None), + }.get(method) + + @staticmethod + def _shell_preference_factor(shell_name: str) -> float: + return 0.72 if shell_name == 'M' else 1.0 + + @staticmethod + def _merge_edge_filters(requested, saved): + if requested and saved: + merged = dict(saved) + for element, selectors in requested.items(): + current = merged.get(element) + merged[element] = None if current is None or selectors is None else set(current).union(selectors) + return merged + return requested or saved + + @staticmethod + def _estimate_snr_thresholds(snr_values, peaks, snr_min=None, snr_threshold=None): + snr_values = np.asarray(snr_values, dtype=float) + snr_values = snr_values[np.isfinite(snr_values)] + + if snr_min is None: + if snr_values.size: + sorted_snrs = np.sort(snr_values) + target_rank = min(sorted_snrs.size, int(np.clip(2 * int(peaks), 12, 64))) + rank_cutoff = float(sorted_snrs[-target_rank]) + q30, q40, q50 = np.percentile(sorted_snrs, [30, 40, 50]) + snr_min = float(np.clip(min(q50, max(q30, 0.35 * rank_cutoff, 0.9 * q40)), 7.0, 14.0)) + else: + snr_min = 8.0 + else: + snr_min = float(snr_min) + + if snr_threshold is None: + if snr_values.size: + high = snr_values[snr_values >= snr_min] + high = high if high.size else snr_values + high = np.sort(high)[::-1] + anchor = high[: min(high.size, int(np.clip(int(peaks), 10, 40)))] + med, q75, q90 = np.percentile(anchor, [50, 75, 90]) + snr_threshold = float(np.clip(max(med, 0.7 * q75, 2.5 * snr_min), max(2.5 * snr_min, snr_min), q90)) + else: + snr_threshold = max(4.0 * snr_min, 30.0) + else: + snr_threshold = float(snr_threshold) + + return snr_min, snr_threshold + + def x_ray_lookup(self, spec: str | list[str] | tuple[str, ...] | set[str]) -> tuple[np.ndarray, np.ndarray, list[str]]: info = type(self)._ensure_element_info() ordered = type(self)._ordered_element_keys(info) - specs = type(self)._normalize_specs(spec, param_name="spec") + specs = type(self)._normalize_specs(spec, param_name='spec') rows: list[tuple[str, float, float]] = [] for raw in specs: - compact = re.sub(r"[\s_-]+", "", str(raw).strip()) + compact = re.sub(r'[\s_-]+', '', str(raw).strip()) if not compact: continue - element = next((k for k in ordered if compact.lower().startswith(k.lower())), None) if element is None: raise ValueError(f"Could not resolve element from specifier '{raw}'") - - suffix = compact[len(element) :] - for line_name, line_info in type(self)._iter_selected_lines( - element, suffix, raw_spec=str(raw) - ): + suffix = compact[len(element):] + for line_name, line_info in type(self)._iter_selected_lines(element, suffix, raw_spec=str(raw)): if not isinstance(line_info, dict): continue - - energy_raw = line_info.get("energy (keV)", line_info.get("energy")) try: - energy = float(energy_raw) + energy = float(line_info.get('energy (keV)', line_info.get('energy'))) except (TypeError, ValueError): continue - try: - weight = float(line_info.get("weight", 0.0)) + weight = float(line_info.get('weight', 0.0)) except (TypeError, ValueError): weight = 0.0 - - canonical = type(self)._canonical_line_name(str(line_name)) - rows.append((f"{element}{canonical}", energy, weight)) + rows.append((f'{element}{type(self)._canonical_line_name(line_name)}', energy, weight)) if not rows: - raise ValueError(f"No X-ray lines matched specifier(s): {specs}") - - rows.sort(key=lambda t: (t[1], -t[2], t[0])) - seen = set() - unique = [] - for lbl, e, w in rows: - k = (lbl, round(float(e), 12), round(float(w), 12)) - if k in seen: - continue - seen.add(k) - unique.append((lbl, e, w)) + raise ValueError(f'No X-ray lines matched specifier(s): {specs}') - energies = np.asarray([e for _, e, _ in unique], dtype=float) - weights = np.asarray([w for _, _, w in unique], dtype=float) - labels = [lbl for lbl, _, _ in unique] - return energies, weights, labels + unique = sorted( + {(lbl, round(float(e), 12), round(float(w), 12)) for lbl, e, w in rows}, + key=lambda t: (t[1], -t[2], t[0]), + ) + return ( + np.asarray([e for _, e, _ in unique], dtype=float), + np.asarray([w for _, _, w in unique], dtype=float), + [lbl for lbl, _, _ in unique], + ) def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False, show=True): if elements is None: if self.model_elements is None: - raise ValueError("elements must be specified") - elements = list(self.model_elements.keys()) - print(f"using model_elements {elements}") + raise ValueError('elements must be specified') + elements = list(self.model_elements) energies, _, labels = self.x_ray_lookup(elements) - energy_max = self.energy_axis.max() - energy_min = self.energy_axis.min() - ind = np.logical_and(energies > energy_min, energies < energy_max) - energies = energies[ind] - labels = [label for label, keep in zip(labels, ind) if keep] - - energy_axis = self.energy_axis.copy() - energy_axis_2d = energy_axis[:, None] - energies_2d = (energies)[None, :] + keep = (energies > self.energy_axis.min()) & (energies < self.energy_axis.max()) + energies = energies[keep] + labels = [label for label, ok in zip(labels, keep) if ok] - mask = (energy_axis_2d > energies_2d - width) & (energy_axis_2d < energies_2d + width) - - N, H, W = self.array.shape - K = mask.shape[1] - eds2 = self.array.reshape(N, -1) - w = mask.astype(self.array.dtype) - - maps = (w.T @ eds2).reshape(K, H, W) - - existing = getattr(self, "_spectrum_images", {}) - self._spectrum_images = {**existing, **dict(zip(labels, maps))} + mask = (self.energy_axis[:, None] > energies[None, :] - width) & (self.energy_axis[:, None] < energies[None, :] + width) + n, h, w = self.array.shape + maps = (mask.astype(self.array.dtype).T @ self.array.reshape(n, -1)).reshape(mask.shape[1], h, w) + self._spectrum_images = {**getattr(self, '_spectrum_images', {}), **dict(zip(labels, maps))} if show: self.show_spectrum_images(x_ray_lines=elements) - if return_maps: return maps, labels def Integrate(self, spec, width=0.15, return_maps=False, show=True, **kwargs): - """Integrate selected X-ray lines and return combined map(s). - - This method builds per-selector energy masks (union of line windows) - and integrates the dataset over those masks. For single-selector - plotting it reuses ``show_energy_window_map`` with the computed mask. - - Parameters - ---------- - spec : str | list[str] | tuple[str, ...] | set[str] - Selector(s) like ``"Au"``, ``"AuK"``, ``"AuKa1"``. - Element selectors (e.g. ``"Au"``) integrate all available lines. - width : float, optional - Half-width in keV for line-window integration, by default 0.15. - return_maps : bool, optional - If True, always return ``dict[selector, map]``. - If False and a single selector is provided, return only that 2D map. - show : bool, optional - If True, display the integrated map(s). - """ - try: - width = float(width) - except (TypeError, ValueError): - raise ValueError("width must be a positive finite number") - if not np.isfinite(width) or width <= 0: - raise ValueError("width must be a positive finite number") - - specs = type(self)._normalize_specs(spec, param_name="spec") + width = float(width) + specs = type(self)._normalize_specs(spec, param_name='spec') arr = np.asarray(self.array, dtype=float) energy_axis = np.asarray(self.energy_axis, dtype=float) - energy_min = float(np.min(energy_axis)) - energy_max = float(np.max(energy_axis)) - - integrated_maps = {} - selector_masks = {} - for raw in specs: - selector = str(raw).strip() - line_energies, _, _ = self.x_ray_lookup(selector) - in_range = np.logical_and(line_energies >= energy_min, line_energies <= energy_max) - line_energies = np.asarray(line_energies[in_range], dtype=float) - if line_energies.size == 0: - raise ValueError( - f"No X-ray lines for selector '{selector}' are within the dataset energy range" - ) - - selector_mask = np.zeros(energy_axis.shape, dtype=bool) - for line_energy in line_energies: - selector_mask |= np.logical_and( - energy_axis >= (float(line_energy) - width), - energy_axis <= (float(line_energy) + width), - ) - - if not np.any(selector_mask): - raise ValueError( - f"No energy channels selected for selector '{selector}'. Try increasing width." - ) - - selector_masks[selector] = selector_mask - integrated_maps[selector] = arr[selector_mask, :, :].sum(axis=0) + energy_min, energy_max = float(energy_axis.min()), float(energy_axis.max()) + + selector_masks, integrated_maps = {}, {} + for selector in map(str, specs): + line_energies, _, _ = self.x_ray_lookup(selector.strip()) + line_energies = line_energies[(line_energies >= energy_min) & (line_energies <= energy_max)] + if not len(line_energies): + raise ValueError(f"No X-ray lines for selector '{selector}' are within the dataset energy range") + + mask = np.any( + (energy_axis[:, None] >= line_energies[None, :] - width) + & (energy_axis[:, None] <= line_energies[None, :] + width), + axis=1, + ) + selector_masks[selector] = mask + integrated_maps[selector] = arr[mask].sum(axis=0) if show: - if "roi_px" in kwargs or "roi_cal" in kwargs: - raise ValueError( - "Use roi (pixel) or roi_units (calibrated). roi_px/roi_cal are not supported" - ) + cmap = kwargs.pop('cmap', 'magma') if len(integrated_maps) == 1: - selector = next(iter(integrated_maps.keys())) + selector = next(iter(integrated_maps)) self.show_energy_window_map( energy_window=[energy_min, energy_max], - roi=kwargs.pop("roi", None), - roi_units=kwargs.pop("roi_units", None), + roi=kwargs.pop('roi', None), + roi_cal=kwargs.pop('roi_cal', None), mask=selector_masks[selector], - data_type=kwargs.pop("data_type", "eds"), - cmap=kwargs.pop("cmap", "magma"), + data_type=kwargs.pop('data_type', 'eds'), + cmap=cmap, show=True, ) else: show_2d( list(integrated_maps.values()), - title=list(integrated_maps.keys()), - cmap=kwargs.pop("cmap", "magma"), - scalebar={"sampling": self.sampling[1], "units": self.units[1]}, + title=list(integrated_maps), + cmap=cmap, + scalebar={'sampling': self.sampling[1], 'units': self.units[1]}, **kwargs, ) - if return_maps or len(integrated_maps) != 1: - return integrated_maps - return next(iter(integrated_maps.values())) + return integrated_maps if return_maps or len(integrated_maps) != 1 else next(iter(integrated_maps.values())) def integrate(self, spec, width=0.15, return_maps=False, show=True, **kwargs): - """Lowercase alias for :meth:`Integrate`.""" - return self.Integrate( - spec=spec, - width=width, - return_maps=return_maps, - show=show, - **kwargs, - ) + return self.Integrate(spec=spec, width=width, return_maps=return_maps, show=show, **kwargs) - def show_spectrum_images( - self, x_ray_lines=None, return_fig=False, method="integration", **kwargs - ): - """Plot cached spectrum-image maps.""" - spectrum_images = ( - getattr(self, "_spectrum_images", None) - if method == "integration" - else getattr(self, "_spectrum_images_pytorch", None) - if method == "fit" - else None - ) - if spectrum_images is None: - raise ValueError( - f"Method {method!r} is not supported, please choose 'integration' or 'fit'" - ) - if not isinstance(spectrum_images, dict) or not spectrum_images: - raise ValueError("No spectrum images found. Run generage_spectrum_images(...) first.") + def show_spectrum_images(self, x_ray_lines=None, return_fig=False, method='integration', **kwargs): + spectrum_images = self._get_spectrum_images(method) + if not spectrum_images: + raise ValueError('No spectrum images found. Run generage_spectrum_images(...) first.') line_map = {str(k): np.asarray(v) for k, v in spectrum_images.items()} labels = list(line_map) labels_by_element = type(self)._group_labels_by_element(labels) - def _sum_maps(lbls): - return np.sum(np.stack([line_map[lbl] for lbl in lbls], axis=0), axis=0) - - specs = type(self)._normalize_specs(x_ray_lines, param_name="x_ray_lines", allow_none=True) - images, titles = [], [] + def sum_maps(lbls): + return np.sum([line_map[lbl] for lbl in lbls], axis=0) + specs = type(self)._normalize_specs(x_ray_lines, param_name='x_ray_lines', allow_none=True) if not specs: - for element in sorted(labels_by_element): - lbls = labels_by_element[element] - if lbls: - images.append(_sum_maps(lbls)) - titles.append(element) + titles = sorted(labels_by_element) + images = [sum_maps(labels_by_element[t]) for t in titles] else: - for raw in specs: - selected = type(self)._select_labels( - str(raw), labels=labels, labels_by_element=labels_by_element - ) - if not selected: - raise ValueError( - f"No spectrum images matched selector '{raw}'. " - f"Available examples: {', '.join(sorted(labels)[:10])}" - ) - images.append(line_map[selected[0]] if len(selected) == 1 else _sum_maps(selected)) - titles.append(selected[0] if len(selected) == 1 else str(raw).strip()) - - if not images: - raise ValueError("No spectrum images selected for plotting") - - cmap = kwargs.pop("cmap", "magma") + selected = [type(self)._select_labels(str(raw), labels=labels, labels_by_element=labels_by_element) for raw in specs] + if any(not s for s in selected): + bad = next(raw for raw, s in zip(specs, selected) if not s) + raise ValueError(f"No spectrum images matched selector '{bad}'") + images = [line_map[s[0]] if len(s) == 1 else sum_maps(s) for s in selected] + titles = [s[0] if len(s) == 1 else str(raw).strip() for raw, s in zip(specs, selected)] + fig, ax = show_2d( images, title=titles, - cmap=cmap, - scalebar={"sampling": self.sampling[1], "units": self.units[1]}, + cmap=kwargs.pop('cmap', 'magma'), + scalebar={'sampling': self.sampling[1], 'units': self.units[1]}, returnfig=True, **kwargs, ) if return_fig: return fig, ax - def _build_pytorch_spectrum_images( - self, abundance_maps: np.ndarray, element_names: list[str] | tuple[str, ...] - ) -> dict[str, np.ndarray]: - """Build per-line maps from fitted per-element abundance maps.""" + def _build_pytorch_spectrum_images(self, abundance_maps: np.ndarray, element_names: list[str] | tuple[str, ...]) -> dict[str, np.ndarray]: maps = np.asarray(abundance_maps) if maps.ndim != 3: return {} line_maps = {} - for element_index, element_name in enumerate(element_names): - if element_index >= maps.shape[0]: + for i, element_name in enumerate(element_names): + if i >= maps.shape[0]: break - - element_map = np.asarray(maps[element_index], dtype=float) try: _, line_weights, line_labels = self.x_ray_lookup(str(element_name)) except ValueError: continue - + element_map = np.asarray(maps[i], dtype=float) for weight, label in zip(line_weights, line_labels): - try: - weight_value = float(weight) - except (TypeError, ValueError): - continue - line_maps[str(label)] = element_map * weight_value - + line_maps[str(label)] = element_map * float(weight) return line_maps - def quantify_composition_cliff_lorimer( - self, - k_factors, - method="integration", - return_maps=False, - verbose=True, - ): - """Quantify composition from cached spectrum maps using Cliff-Lorimer. - - Parameters - ---------- - k_factors : dict - Mapping of selector -> k-factor. - method : {"integration", "fit"}, optional - Source map set to use. - return_maps : bool, optional - If True, include per-element/per-selector map outputs. - verbose : bool, optional - If True, print a small scalar text table (no maps). - """ - if not isinstance(k_factors, dict) or not k_factors: - raise ValueError("k_factors must be a non-empty dict") - - spectrum_images = ( - getattr(self, "_spectrum_images", None) - if method == "integration" - else getattr(self, "_spectrum_images_pytorch", None) - if method == "fit" - else None - ) - if spectrum_images is None: - raise ValueError( - f"Method {method!r} is not supported, please choose 'integration' or 'fit'" - ) - if not isinstance(spectrum_images, dict) or not spectrum_images: - raise ValueError("No spectrum images available for quantification") - - type(self)._ensure_element_info() - ordered_elements = type(self)._ordered_element_keys(type(self).element_info or {}) + def quantify_composition_cliff_lorimer(self, k_factors, method='integration', return_maps=False, verbose=True): + if not k_factors: + raise ValueError('k_factors must be a non-empty dict') + spectrum_images = self._get_spectrum_images(method) + if not spectrum_images: + raise ValueError('No spectrum images available for quantification') + ordered_elements = type(self)._ordered_element_keys(type(self)._ensure_element_info()) line_map = {str(k): np.asarray(v, dtype=float) for k, v in spectrum_images.items()} labels = list(line_map) labels_by_element = type(self)._group_labels_by_element(labels) - def _match(selector: str) -> list[str]: - return type(self)._select_labels( - selector, labels=labels, labels_by_element=labels_by_element - ) + def match(selector: str) -> list[str]: + return type(self)._select_labels(selector, labels=labels, labels_by_element=labels_by_element) - intensities: dict[str, float] = {} - weighted_intensities: dict[str, float] = {} + intensities, weighted_intensities = {}, {} selector_maps = {} if return_maps else None intensity_maps = {} if return_maps else None weighted_intensity_maps = {} if return_maps else None for selector, k_raw in k_factors.items(): - try: - k_val = float(k_raw) - except (TypeError, ValueError): - raise ValueError(f"k_factors[{selector!r}] must be numeric") - if not np.isfinite(k_val) or k_val <= 0: - raise ValueError(f"k_factors[{selector!r}] must be a positive finite number") - - sel_labels = _match(str(selector).strip()) + k_val = float(k_raw) + sel_labels = match(str(selector).strip()) if not sel_labels: - raise ValueError( - f"No spectrum images matched selector {selector!r}. " - f"Available examples: {', '.join(sorted(labels)[:10])}" - ) + raise ValueError(f'No spectrum images matched selector {selector!r}') - matched_elements = { - type(self)._resolve_element_from_label(lbl, ordered_elements) for lbl in sel_labels - } - matched_elements = {e for e in matched_elements if e is not None} + matched_elements = {type(self)._resolve_element_from_label(lbl, ordered_elements) for lbl in sel_labels} - {None} if len(matched_elements) != 1: - raise ValueError( - f"Selector {selector!r} matched multiple elements: {sorted(matched_elements)}. " - "Use selectors like 'AuK' or 'AuKa1'." - ) + raise ValueError(f'Selector {selector!r} matched multiple elements: {sorted(matched_elements)}') element = next(iter(matched_elements)) - grouped_map = np.sum(np.stack([line_map[lbl] for lbl in sel_labels], axis=0), axis=0) - intensity = float(np.sum(grouped_map)) + grouped_map = np.sum([line_map[lbl] for lbl in sel_labels], axis=0) + intensity = float(grouped_map.sum()) weighted = float(k_val * intensity) - - intensities[element] = float(intensities.get(element, 0.0)) + intensity - weighted_intensities[element] = ( - float(weighted_intensities.get(element, 0.0)) + weighted - ) + intensities[element] = intensities.get(element, 0.0) + intensity + weighted_intensities[element] = weighted_intensities.get(element, 0.0) + weighted if return_maps: weighted_map = grouped_map * k_val selector_maps[str(selector)] = grouped_map - if element in intensity_maps: - intensity_maps[element] = intensity_maps[element] + grouped_map - weighted_intensity_maps[element] = ( - weighted_intensity_maps[element] + weighted_map - ) - else: - intensity_maps[element] = grouped_map.copy() - weighted_intensity_maps[element] = weighted_map.copy() + intensity_maps[element] = intensity_maps.get(element, 0) + grouped_map + weighted_intensity_maps[element] = weighted_intensity_maps.get(element, 0) + weighted_map if len(weighted_intensities) < 2: - raise ValueError("At least two elements are required for Cliff-Lorimer quantification") + raise ValueError('At least two elements are required for Cliff-Lorimer quantification') - weighted_sum = float(sum(weighted_intensities.values())) - atomic_percent = { - el: (100.0 * val / weighted_sum if weighted_sum > 0 else 0.0) - for el, val in weighted_intensities.items() - } + weighted_sum = sum(weighted_intensities.values()) + atomic_percent = {el: 100.0 * val / weighted_sum if weighted_sum > 0 else 0.0 for el, val in weighted_intensities.items()} if type(self).atomic_weights is None: type(self).load_atomic_weights() atomic_weights = type(self).atomic_weights or {} missing = [el for el in atomic_percent if el not in atomic_weights] if missing: - raise ValueError(f"Atomic weights not found for elements: {missing}") + raise ValueError(f'Atomic weights not found for elements: {missing}') - weight_sum = sum( - (atomic_percent[el] / 100.0) * float(atomic_weights[el]) for el in atomic_percent - ) + weight_sum = sum((atomic_percent[el] / 100.0) * float(atomic_weights[el]) for el in atomic_percent) weight_percent = { - el: (((atomic_percent[el] / 100.0) * float(atomic_weights[el]) / weight_sum) * 100.0) - if weight_sum > 0 - else 0.0 + el: (atomic_percent[el] / 100.0) * float(atomic_weights[el]) / weight_sum * 100.0 if weight_sum > 0 else 0.0 for el in atomic_percent } + ordered = sorted(weighted_intensities, key=weighted_intensities.get, reverse=True) + table_text = '\n'.join([ + 'Element Intensity Weighted Intensity Atomic % Weight %', + '------- ------------- -------------------- ---------- ----------', + *[ + f'{el:<7} {intensities[el]:>13.3f} {weighted_intensities[el]:>20.3f} {atomic_percent[el]:>10.3f} {weight_percent[el]:>10.3f}' + for el in ordered + ], + ]) result = { - "intensities": intensities, - "weighted_intensities": weighted_intensities, - "atomic_percent": atomic_percent, - "weight_percent": weight_percent, + 'intensities': intensities, + 'weighted_intensities': weighted_intensities, + 'atomic_percent': atomic_percent, + 'weight_percent': weight_percent, + 'summary_table': table_text, } - - ordered_elements = sorted( - weighted_intensities.keys(), - key=lambda element_name: weighted_intensities[element_name], - reverse=True, - ) - table_lines = [ - "Element Intensity Weighted Intensity Atomic % Weight %", - "------- ------------- -------------------- ---------- ----------", - ] - for element_name in ordered_elements: - table_lines.append( - f"{element_name:<7} " - f"{intensities[element_name]:>13.3f} " - f"{weighted_intensities[element_name]:>20.3f} " - f"{atomic_percent[element_name]:>10.3f} " - f"{weight_percent[element_name]:>10.3f}" - ) - table_text = "\n".join(table_lines) - result["summary_table"] = table_text if verbose: print(table_text) if return_maps: weighted_stack = np.stack(list(weighted_intensity_maps.values()), axis=0) - weighted_sum_map = np.sum(weighted_stack, axis=0) + weighted_sum_map = weighted_stack.sum(axis=0) atomic_percent_maps = { - el: np.divide( - wmap * 100.0, - weighted_sum_map, - out=np.zeros_like(weighted_sum_map, dtype=float), - where=weighted_sum_map > 0, - ) + el: np.divide(wmap * 100.0, weighted_sum_map, out=np.zeros_like(weighted_sum_map, dtype=float), where=weighted_sum_map > 0) for el, wmap in weighted_intensity_maps.items() } - mass_maps = { - el: (atomic_percent_maps[el] / 100.0) * float(atomic_weights[el]) - for el in atomic_percent_maps - } + mass_maps = {el: atomic_percent_maps[el] / 100.0 * float(atomic_weights[el]) for el in atomic_percent_maps} mass_sum_map = np.sum(np.stack(list(mass_maps.values()), axis=0), axis=0) weight_percent_maps = { - el: np.divide( - mmap * 100.0, - mass_sum_map, - out=np.zeros_like(mass_sum_map, dtype=float), - where=mass_sum_map > 0, - ) + el: np.divide(mmap * 100.0, mass_sum_map, out=np.zeros_like(mass_sum_map, dtype=float), where=mass_sum_map > 0) for el, mmap in mass_maps.items() } - result.update( - { - "selector_maps": selector_maps, - "intensity_maps": intensity_maps, - "weighted_intensity_maps": weighted_intensity_maps, - "atomic_percent_maps": atomic_percent_maps, - "weight_percent_maps": weight_percent_maps, - } - ) - + result.update({ + 'selector_maps': selector_maps, + 'intensity_maps': intensity_maps, + 'weighted_intensity_maps': weighted_intensity_maps, + 'atomic_percent_maps': atomic_percent_maps, + 'weight_percent_maps': weight_percent_maps, + }) return result def clear_spectrum_images(self): - if self._spectrum_images is not None: - self._spectrum_images = {} + self._spectrum_images = {} def clear_spectrum_images_pytorch(self): - if self._spectrum_images_pytorch is not None: - self._spectrum_images = {} + self._spectrum_images_pytorch = {} def peak_autoid( self, roi=None, - roi_units=None, + roi_cal=None, energy_range=None, elements=None, refline=None, @@ -766,1552 +535,664 @@ def peak_autoid( mode=None, return_details=False, ): - """Auto-detect and label EDS peaks from the mean ROI spectrum. - - This method calls ``show_mean_spectrum`` to generate the baseline plot, then - overlays peak markers and element labels from X-ray line matching. - - Parameters - ---------- - min_line_weight : float, optional - Minimum X-ray line weight required for a line to be eligible during - matching and ranking. Set to 0 to allow all lines. - mode : str | None, optional - Controls how element constraints are applied: - - "autofill": auto-ID over all elements while optionally overlaying - requested/saved-model element references. - - "elements_preferred": keep autofill enabled, but bias final - matching toward requested/saved-model elements. - - "elements_only": if elements are provided explicitly or via saved - model, only those elements are considered during matching. - - None: choose context-aware default. If no requested/saved-model - elements are present, defaults to "autofill". If requested or - saved-model elements exist, defaults to "elements_only". - return_details : bool, optional - If True, return full internal results (matches/confidence/peaks). - If False (default), return only figure and axes. - """ type(self)._ensure_element_info() - - if grid_peaks is None: - grid_peaks = {} - - requested_edge_filters = type(self)._parse_element_selectors( - elements, allow_none=True, param_name="elements" - ) - saved_model_edge_filters = { + all_info = type(self).element_info or {} + grid_peaks = grid_peaks or {} + ignore_range = [0, 0.25] if ignore_range is None else ignore_range + ignored_elements = set(map(str, type(self)._normalize_specs(ignore_elements, allow_none=True) or [])) + min_line_weight = max(float(min_line_weight), 0.0) + + requested = type(self)._parse_element_selectors(elements, allow_none=True, param_name='elements') + saved = { str(k): (set(map(str, v.keys())) if isinstance(v, dict) and v else None) - for k, v in (getattr(self, "model_elements", {}) or {}).items() + for k, v in (getattr(self, 'model_elements', {}) or {}).items() } or None + edge_filters = type(self)._merge_edge_filters(requested, saved) + requested_elements = set(edge_filters) if edge_filters else None - if saved_model_edge_filters is not None and requested_edge_filters is not None: - merged_edge_filters = dict(saved_model_edge_filters) - for element_name, selectors in requested_edge_filters.items(): - if element_name not in merged_edge_filters: - merged_edge_filters[element_name] = selectors - continue - - existing = merged_edge_filters[element_name] - if existing is None or selectors is None: - merged_edge_filters[element_name] = None - else: - merged_edge_filters[element_name] = set(existing).union(set(selectors)) - requested_edge_filters = merged_edge_filters - elif requested_edge_filters is None and saved_model_edge_filters is not None: - requested_edge_filters = saved_model_edge_filters - - if requested_edge_filters is not None: - elements = list(requested_edge_filters.keys()) - - requested_elements = set(elements) if elements is not None else None - - mode_normalized = None if mode is None else str(mode).strip().lower() - valid_modes = {"autofill", "elements_only", "elements_preferred"} - if mode_normalized is not None and mode_normalized not in valid_modes: - raise ValueError("mode must be one of: autofill, elements_only, elements_preferred") - - if mode_normalized is None: - mode_normalized = "elements_only" if requested_elements is not None else "autofill" - - if mode_normalized == "elements_only" and requested_elements is None: - raise ValueError("mode='elements_only' requires elements to be specified or saved") - if mode_normalized == "elements_preferred" and requested_elements is None: - raise ValueError( - "mode='elements_preferred' requires elements to be specified or saved" - ) - - match_elements = requested_elements if mode_normalized == "elements_only" else None - preferred_elements_set = ( - set(str(element_name) for element_name in requested_elements) - if mode_normalized == "elements_preferred" and requested_elements is not None - else set() - ) - reference_elements = ( - requested_elements - if mode_normalized in {"elements_only", "autofill", "elements_preferred"} - and requested_elements is not None - else None - ) - - if isinstance(ignore_elements, str): - ignore_elements = [ignore_elements] - if ignore_elements is not None and not isinstance(ignore_elements, (list, tuple, set)): - raise TypeError("ignore_elements must be None, a string, or a sequence of strings") - try: - min_line_weight = float(min_line_weight) - except (TypeError, ValueError) as exc: - raise TypeError("min_line_weight must be a number") from exc - if min_line_weight < 0: - raise ValueError("min_line_weight must be >= 0") - ignored_elements = ( - {str(element_name) for element_name in ignore_elements} - if ignore_elements is not None - else set() - ) + mode = (str(mode).strip().lower() if mode is not None else None) or ('elements_only' if requested_elements else 'autofill') + search_elements = requested_elements if mode == 'elements_only' else None + preferred_elements = set(map(str, requested_elements or [])) if mode == 'elements_preferred' else set() + reference_elements = requested_elements fig, (ax_img, ax_spec) = self.show_mean_spectrum( roi=roi, - roi_units=roi_units, + roi_cal=roi_cal, energy_range=energy_range, mask=mask, - data_type="eds", + data_type='eds', show=False, ) - spec = self.calculate_mean_spectrum( roi=roi, - roi_units=roi_units, + roi_cal=roi_cal, energy_range=energy_range, ignore_range=ignore_range, mask=mask, ) - dE = float(self.sampling[0]) - E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 - E = E0 + dE * np.arange(self.shape[0]) - + E = float(self.origin[0]) + float(self.sampling[0]) * np.arange(self.shape[0]) if energy_range is not None: - indices = np.where((energy_range[0] <= E) & (energy_range[1] >= E))[0] - E = E[indices] + keep = (energy_range[0] <= E) & (E <= energy_range[1]) + E = E[keep] + spec = spec[keep] - if ignore_range is None: - ignore_range = [0, 0.25] - - def _is_in_ignored_range(energy_value: float) -> bool: - if ignore_range is None or len(ignore_range) != 2: - return False - min_ignore, max_ignore = ignore_range - return float(min_ignore) <= float(energy_value) <= float(max_ignore) - - peak_indices, peak_properties = find_peaks(spec, height=0, distance=5) - peak_heights = peak_properties["peak_heights"] + def in_ignore(energy): + return len(ignore_range) == 2 and ignore_range[0] <= float(energy) <= ignore_range[1] + peak_indices, props = find_peaks(spec, height=0, distance=5) + peak_heights = props['peak_heights'] background_std = np.nanstd(spec[spec <= np.nanpercentile(spec, 50)]) if not np.isfinite(background_std) or background_std <= 0: background_std = np.nanstd(spec) if not np.isfinite(background_std) or background_std <= 0: background_std = 1.0 - initial_snrs = [] - for _, height in zip(peak_indices, peak_heights): - initial_snrs.append(height / background_std) - - if len(initial_snrs) > 0: - snr_values = np.asarray(initial_snrs, dtype=float) - snr_values = snr_values[np.isfinite(snr_values)] - else: - snr_values = np.asarray([], dtype=float) - - if snr_min is None: - if snr_values.size > 0: - sorted_snrs = np.sort(snr_values) - target_rank = int(np.clip(2 * int(peaks), 12, 64)) - target_rank = min(target_rank, int(sorted_snrs.size)) - rank_cutoff = float(sorted_snrs[-target_rank]) - q30 = float(np.percentile(sorted_snrs, 30)) - q40 = float(np.percentile(sorted_snrs, 40)) - q50 = float(np.percentile(sorted_snrs, 50)) - adaptive_cutoff = min(q50, max(q30, 0.35 * rank_cutoff, 0.9 * q40)) - # Keep the display threshold permissive so moderate-SNR peaks remain visible. - min_snr = float(np.clip(adaptive_cutoff, 7.0, 14.0)) - else: - min_snr = 8.0 - else: - min_snr = float(snr_min) - - if snr_threshold is None: - if snr_values.size > 0: - high_snr_pool = snr_values[snr_values >= min_snr] - if high_snr_pool.size == 0: - high_snr_pool = snr_values - sorted_high = np.sort(high_snr_pool)[::-1] - anchor_count = int(np.clip(int(peaks), 10, 40)) - anchor_count = min(anchor_count, int(sorted_high.size)) - anchor_pool = sorted_high[:anchor_count] - anchor_median = float(np.percentile(anchor_pool, 50)) - anchor_q75 = float(np.percentile(anchor_pool, 75)) - anchor_q90 = float(np.percentile(anchor_pool, 90)) - adaptive_threshold = max(anchor_median, 0.7 * anchor_q75, 2.5 * min_snr) - # Anchor to the strongest displayed-peak regime so defaults do not - # collapse toward the low-SNR bulk when peak counts are large. - snr_threshold_for_sample = float( - np.clip(adaptive_threshold, max(2.5 * min_snr, min_snr), anchor_q90) - ) - else: - snr_threshold_for_sample = max(4.0 * min_snr, 30.0) - else: - snr_threshold_for_sample = float(snr_threshold) - - all_candidate_peaks = [] - significant_peaks = [] - for peak_idx, height in zip(peak_indices, peak_heights): - peak_energy = E[peak_idx] - - if _is_in_ignored_range(peak_energy): - continue - - snr = height / background_std - all_candidate_peaks.append((peak_idx, height, peak_energy, snr)) - if snr >= min_snr: - significant_peaks.append((peak_idx, height, peak_energy, snr)) - - significant_peaks.sort(key=lambda item: item[3], reverse=True) - - display_peaks = significant_peaks[:peaks] + snr_values = np.asarray([height / background_std for height in peak_heights], dtype=float) + snr_min, snr_threshold = type(self)._estimate_snr_thresholds(snr_values, peaks, snr_min, snr_threshold) - all_info = type(self).element_info - peak_matches = [] - - def _line_shell(line_name): - line_upper = str(line_name).upper() - if line_upper.startswith("K"): - return "K" - if line_upper.startswith("L"): - return "L" - if line_upper.startswith("M"): - return "M" - return "?" - - def _shell_preference_factor(shell_name): - # Keep K and L comparable; only downweight M lines because they - # are more prone to overlap-driven false assignments. - if shell_name in {"K", "L"}: - return 1.0 - if shell_name == "M": - return 0.72 - return 1.0 - - def _peak_confidence(snr_value, line_weight, distance_value): - return type(self)._peak_confidence( - snr_value=snr_value, - line_weight=line_weight, - distance_value=distance_value, - tolerance=tolerance, - ) - - def _line_matches_selector(line_name: str, selector: str) -> bool: - line = str(line_name).strip().lower() - token = str(selector).strip().lower() - if token in {"k", "l", "m"}: - return line.startswith(token) - return token in line - - def _line_allowed_for_element(element_name, line_name, edge_filters=None): - if edge_filters is None: - return True - - selectors = edge_filters.get(str(element_name)) - if selectors is None: - return True - - return any(_line_matches_selector(line_name, token) for token in selectors) - - def _best_line_match(peak_energy, peak_snr, allowed_elements=None, edge_filters=None): - best_score = -float("inf") - best_element = None - best_line_name = None - best_line_weight = 0.0 - best_distance = float("inf") - - if not all_info: - return None + display_peaks = [ + (int(i), float(h), float(E[i]), float(h / background_std)) + for i, h in zip(peak_indices, peak_heights) + if not in_ignore(E[i]) and h / background_std >= snr_min + ] + display_peaks.sort(key=lambda item: item[3], reverse=True) + significant_peaks = list(display_peaks) + display_peaks = display_peaks[:peaks] + def candidate_matches(peak_energy, snr, allowed_elements=None): + matches = [] for element_name, lines in all_info.items(): if allowed_elements is not None and element_name not in allowed_elements: continue - for line_name, line_info in lines.items(): - if not _line_allowed_for_element(element_name, line_name, edge_filters): + if not type(self)._line_allowed_for_element(element_name, line_name, edge_filters): continue - line_energy = line_info["energy (keV)"] - line_weight = line_info.get("weight", 0.5) + line_weight = float(line_info.get('weight', 0.5)) + line_energy = float(line_info['energy (keV)']) + shell = type(self)._line_shell(line_name) + tol = tolerance * 0.5 if shell == 'M' and ('Ma' not in line_name and 'Mb' not in line_name) else tolerance distance = abs(peak_energy - line_energy) - shell = _line_shell(line_name) - - is_m_line = "M" in line_name and not ("Ma" in line_name or "Mb" in line_name) - effective_tolerance = tolerance * 0.5 if is_m_line else tolerance - - if line_weight >= min_line_weight and distance <= effective_tolerance: - # Use score-based ranking: confidence with weight and distance - # This matches rematcher logic and avoids distance-driven artifacts - score = _peak_confidence(peak_snr, line_weight, distance) - score *= _shell_preference_factor(shell) - if score > best_score: - best_score = score - best_element = element_name - best_line_name = line_name - best_line_weight = line_weight - best_distance = distance - - if best_element is None: - return None - - return best_element, best_line_name, best_line_weight, best_distance - - search_elements = match_elements + if line_weight < min_line_weight or distance > tol: + continue + score = type(self)._peak_confidence(snr, line_weight, distance, tolerance) * type(self)._shell_preference_factor(shell) + matches.append({ + 'element': str(element_name), + 'line': str(line_name), + 'weight': line_weight, + 'distance': distance, + 'score': float(score), + 'shell': shell, + }) + matches.sort(key=lambda m: m['score'], reverse=True) + return matches + peak_matches = [] for peak_idx, height, peak_energy, snr in display_peaks: - best_match_info = _best_line_match( - peak_energy, snr, search_elements, requested_edge_filters - ) - if best_match_info is not None: - best_element, best_line_name, best_line_weight, best_distance = best_match_info - best_element = str(best_element) - best_line_name = str(best_line_name) - best_match = f"{best_element} {best_line_name}" - match_confidence = _peak_confidence(snr, best_line_weight, best_distance) - peak_matches.append( - ( - peak_idx, - height, - peak_energy, - snr, - best_element, - best_match, - best_distance, - best_line_name, - best_line_weight, - match_confidence, - ) - ) - - detected_elements = set() - detected_sample_peaks = {} - element_confidence = {} - element_stats = {} - line_evidence = {} - for ( - peak_idx, - height, - peak_energy, - snr, - element_name, - match_str, - distance, - line_name, - line_weight, - match_confidence, - ) in peak_matches: - element_name = str(element_name) - line_name = str(line_name) - if search_elements is not None and element_name not in search_elements: + matches = candidate_matches(peak_energy, snr, search_elements) + if not matches: continue + best = matches[0] + peak_matches.append(( + peak_idx, + height, + peak_energy, + snr, + best['element'], + f"{best['element']} {best['line']}", + best['distance'], + best['line'], + best['weight'], + best['score'], + )) + + element_stats, line_evidence = {}, {} + for _, _, peak_energy, snr, element, _, distance, line_name, line_weight, conf in peak_matches: + if search_elements is not None and element not in search_elements: + continue + shell = type(self)._line_shell(line_name) + stats = element_stats.setdefault(element, { + 'raw_conf': 0.0, + 'shells': set(), + 'lines': set(), + 'strong_matches': 0, + 'match_count': 0, + 'best_match_conf': 0.0, + 'best_match_snr': 0.0, + 'best_match_energy': 0.0, + 'best_match_distance': float('inf'), + 'best_match_weight': 0.0, + 'best_match_shell': '?', + }) + label = f'{element} {line_name}' + evidence = line_evidence.setdefault(label, {'match_count': 0, 'strong_matches': 0, 'best_conf': 0.0, 'best_snr': 0.0, 'energies': []}) + + stats['raw_conf'] += float(conf) + stats['shells'].add(shell) + stats['lines'].add(line_name) + stats['match_count'] += 1 + stats['strong_matches'] += int(snr > snr_threshold and distance < distance_threshold_for_sample) + if conf > stats['best_match_conf']: + stats.update({ + 'best_match_conf': float(conf), + 'best_match_snr': float(snr), + 'best_match_energy': float(peak_energy), + 'best_match_distance': float(distance), + 'best_match_weight': float(line_weight), + 'best_match_shell': shell, + }) + + evidence['match_count'] += 1 + evidence['energies'].append(float(peak_energy)) + evidence['strong_matches'] += int(snr > snr_threshold and distance < distance_threshold_for_sample) + if conf > evidence['best_conf']: + evidence['best_conf'] = float(conf) + evidence['best_snr'] = float(snr) - shell = _line_shell(line_name) - line_label = f"{element_name} {line_name}" - if element_name not in element_stats: - element_stats[element_name] = { - "raw_conf": 0.0, - "shells": set(), - "lines": set(), - "strong_matches": 0, - "match_count": 0, - "best_match_conf": 0.0, - "best_match_snr": 0.0, - "best_match_energy": 0.0, - "best_match_distance": float("inf"), - "best_match_weight": 0.0, - "best_match_shell": "?", - } - - if line_label not in line_evidence: - line_evidence[line_label] = { - "match_count": 0, - "strong_matches": 0, - "best_conf": 0.0, - "best_snr": 0.0, - "energies": [], - } - - element_stats[element_name]["raw_conf"] += float(match_confidence) - element_stats[element_name]["shells"].add(shell) - element_stats[element_name]["lines"].add(str(line_name)) - element_stats[element_name]["match_count"] += 1 - if snr > snr_threshold_for_sample and distance < distance_threshold_for_sample: - element_stats[element_name]["strong_matches"] += 1 - - if float(match_confidence) > float(element_stats[element_name]["best_match_conf"]): - element_stats[element_name]["best_match_conf"] = float(match_confidence) - element_stats[element_name]["best_match_snr"] = float(snr) - element_stats[element_name]["best_match_energy"] = float(peak_energy) - element_stats[element_name]["best_match_distance"] = float(distance) - element_stats[element_name]["best_match_weight"] = float(line_weight) - element_stats[element_name]["best_match_shell"] = shell - - line_evidence[line_label]["match_count"] += 1 - line_evidence[line_label]["energies"].append(float(peak_energy)) - if snr > snr_threshold_for_sample and distance < distance_threshold_for_sample: - line_evidence[line_label]["strong_matches"] += 1 - if float(match_confidence) > float(line_evidence[line_label]["best_conf"]): - line_evidence[line_label]["best_conf"] = float(match_confidence) - line_evidence[line_label]["best_snr"] = float(snr) - - for element_name, stats in element_stats.items(): - valid_shells = {shell for shell in stats["shells"] if shell in {"K", "L", "M"}} - num_shells = len(valid_shells) - num_lines = len(stats["lines"]) - has_major_shell = len(valid_shells.intersection({"K", "L"})) > 0 - - # Shell bonus: each additional confirmed shell is independent evidence. - # Two independent shells give P(element|K∧L) ∝ P_K · P_L, so the - # log-likelihood adds linearly and the likelihood ratio scales as - # sqrt(n_shells) in the geometric-mean sense (Jaynes, Prob. Theory). - shell_bonus = float(np.sqrt(max(1, num_shells))) - - # Line bonus: each additional distinct matched line reduces the - # probability of a chance coincidence. Diminishing returns are - # captured by log1p (each doubling of evidence adds a fixed increment). - line_bonus = 1.0 + 0.30 * float(np.log1p(max(0, num_lines - 1))) - - # Strong-match bonus: the Poisson false-peak rate at SNR > T scales - # as erfc(T/√2); n independent strong peaks compound multiplicatively - # so log1p(n) correctly models the diminishing-returns likelihood ratio. - strong_bonus = 1.0 + 0.40 * float(np.log1p(stats["strong_matches"])) - - # K/L shell presence is strong physical prior: these are the primary - # transitions expected in any sample above Z≈10. - major_bonus = 1.20 if has_major_shell else 1.0 - - confidence = stats["raw_conf"] * shell_bonus * line_bonus * strong_bonus * major_bonus - element_confidence[element_name] = float(confidence) + element_confidence = {} + # --- Intensity ratio check and multi-peak pattern boost --- + for element, stats in element_stats.items(): + valid_shells = {shell for shell in stats['shells'] if shell in {'K', 'L', 'M'}} + shell_bonus = float(np.sqrt(max(1, len(valid_shells)))) + line_bonus = 1.0 + 0.30 * float(np.log1p(max(0, len(stats['lines']) - 1))) + strong_bonus = 1.0 + 0.40 * float(np.log1p(stats['strong_matches'])) + major_bonus = 1.20 if {'K', 'L'} & valid_shells else 1.0 + + # Intensity ratio logic + element_peak_intensities = {} + for _, height, peak_energy, snr, el, _, distance, line_name, line_weight, conf in peak_matches: + if el == element: + element_peak_intensities.setdefault(line_name, []).append(float(height)) + # Only consider if at least 2 lines detected + if len(element_peak_intensities) >= 2: + observed = [] + expected = [] + for line_name, intensities in element_peak_intensities.items(): + observed.append(max(intensities)) + weight = all_info.get(element, {}).get(line_name, {}).get('weight', None) + try: + expected.append(float(weight) if weight is not None else 0.0) + except Exception: + expected.append(0.0) + obs_sum = sum(observed) + exp_sum = sum(expected) + if obs_sum > 0 and exp_sum > 0: + observed_norm = [x / obs_sum for x in observed] + expected_norm = [x / exp_sum for x in expected] + ratio_score = 1.0 - (sum(abs(o - e) for o, e in zip(observed_norm, expected_norm)) / 2.0) + ratio_factor = 1.0 + if ratio_score > 0.7: + ratio_factor = 1.15 + 0.25 * (ratio_score - 0.7) + elif ratio_score < 0.4: + ratio_factor = 0.7 + 0.5 * ratio_score + else: + ratio_factor = 1.0 + else: + ratio_factor = 1.0 + + # --- Strong pattern boost: if both main lines for K, L, or M are matched, multiply confidence by 3 (dominates score) --- + matched_lines = set(element_peak_intensities.keys()) + k_lines = {'Ka1', 'Kb1'} + l_lines = {'La1', 'Lb1'} + m_lines = {'Ma1', 'Mb1'} + pattern_factor = 1.0 + if k_lines.issubset(matched_lines): + pattern_factor = 3.0 + elif l_lines.issubset(matched_lines): + pattern_factor = 2.5 + elif m_lines.issubset(matched_lines): + pattern_factor = 2.0 + + element_confidence[element] = stats['raw_conf'] * shell_bonus * line_bonus * strong_bonus * major_bonus * ratio_factor * pattern_factor + detected_elements = set() if element_confidence: - conf_values = np.array(list(element_confidence.values()), dtype=float) - # Absolute Poisson MDL floor (Currie 1968, Anal. Chem. 40:586): - # a peak is physically detectable only when SNR >= 3σ of background. - # The minimum confidence a single real peak can produce is: - # log1p(3.0) * 0.5 * exp(-0.5*(1σ distance)²) ≈ 0.334 - # (half-weight line, 1-sigma spatial offset, SNR=3). - # No element below this floor can have a statistically detectable peak. + conf_values = np.asarray(list(element_confidence.values()), dtype=float) poisson_mdl_snr = 3.0 - _mdl_conf_floor = float(np.log1p(poisson_mdl_snr)) * 0.5 * float(np.exp(-0.5)) - confidence_cutoff = max( - float(np.percentile(conf_values, 45)), - 0.30 * float(conf_values.max()), - _mdl_conf_floor, - ) - - for element_name, confidence in element_confidence.items(): - stats = element_stats[element_name] - valid_shells = {shell for shell in stats["shells"] if shell in {"K", "L", "M"}} - has_major_shell = len(valid_shells.intersection({"K", "L"})) > 0 - # is_supported: best peak must clear the 3σ Poisson MDL - # (Currie 1968) — any peak below SNR=3 cannot be distinguished - # from background noise regardless of fit quality. - is_supported = ( - confidence >= confidence_cutoff - and stats["best_match_snr"] >= poisson_mdl_snr - and ( - stats["strong_matches"] >= 1 - or stats["best_match_snr"] >= max(min_snr, 0.6 * snr_threshold_for_sample) - ) - ) - # is_near_cutoff_but_consistent: two independent peaks above the - # 3σ MDL have a joint false-positive probability ≈ p₁·p₂ ≈ negligible - # (akin to requiring both the critical level Lc and detection limit Ld - # to be satisfied on independent lines — Currie 1968, §IV). - is_near_cutoff_but_consistent = ( - confidence >= 0.75 * confidence_cutoff - and stats["best_match_snr"] >= poisson_mdl_snr - and stats["match_count"] >= 2 - and has_major_shell - and stats["best_match_snr"] >= max(min_snr, 0.5 * snr_threshold_for_sample) - ) - is_high_energy_singleton_anchor = ( - stats["match_count"] == 1 - and stats["best_match_energy"] >= 6.0 - and stats["best_match_weight"] >= 0.8 - and stats["best_match_distance"] <= 0.35 * tolerance - and confidence >= 0.45 * confidence_cutoff - ) - is_high_quality_singleton = ( - stats["match_count"] == 1 - and has_major_shell - and stats["best_match_snr"] >= max(min_snr, 0.6 * snr_threshold_for_sample) - and stats["best_match_weight"] >= 0.5 - and stats["best_match_distance"] <= 0.30 * tolerance - and confidence >= 0.35 * confidence_cutoff - ) + cutoff = max(float(np.percentile(conf_values, 45)), 0.30 * float(conf_values.max())) + for element, confidence in element_confidence.items(): + stats = element_stats[element] + lines = set(stats['lines']) + # Criterion 1: Both main lines matched (pattern match) → always autodetect + strong_pattern = ( + {'Ka1', 'Kb1'}.issubset(lines) + or {'La1', 'Lb1'}.issubset(lines) + or {'Ma1', 'Mb1'}.issubset(lines) + ) and confidence > 0 + # Criterion 2: High confidence above cutoff and sufficient SNR + high_confidence = confidence >= cutoff and stats['best_match_snr'] >= poisson_mdl_snr + if strong_pattern or high_confidence: + detected_elements.add(element) - if ( - is_supported - or is_near_cutoff_but_consistent - or is_high_energy_singleton_anchor - or is_high_quality_singleton - ): - detected_elements.add(element_name) + dominant_elements = set() + if element_confidence: + conf_values = np.asarray(list(element_confidence.values()), dtype=float) + conf_floor = max(float(np.median(conf_values)) if conf_values.size else 0.0, 1e-9) + conf_p80 = float(np.percentile(conf_values, 80)) if conf_values.size > 1 else 0.0 + for element, confidence in element_confidence.items(): + stats = element_stats.get(element, {}) + repeat_support = int(stats.get('match_count', 0)) >= 2 or int(stats.get('strong_matches', 0)) >= 1 + if confidence >= conf_p80 and confidence >= 1.8 * conf_floor and repeat_support: + dominant_elements.add(element) - refined_peak_matches = [] - raw_match_by_idx = {int(match[0]): match for match in peak_matches} anchor_elements = { - element_name - for element_name in detected_elements - if element_name in element_stats - and element_stats[element_name].get("best_match_energy", 0.0) >= 6.0 - and element_stats[element_name].get("best_match_weight", 0.0) >= 0.8 + element for element in detected_elements + if element in element_stats and element_stats[element].get('best_match_energy', 0.0) >= 6.0 and element_stats[element].get('best_match_weight', 0.0) >= 0.8 } - max_detected_conf = ( - max([element_confidence.get(el, 0.0) for el in detected_elements]) - if len(detected_elements) > 0 - else 0.0 - ) - dominant_elements = set() - if element_confidence: - conf_values = np.array(list(element_confidence.values()), dtype=float) - conf_median = float(np.median(conf_values)) if len(conf_values) > 0 else 0.0 - conf_p80 = float(np.percentile(conf_values, 80)) if len(conf_values) > 1 else 0.0 - conf_floor = max(conf_median, 1e-9) - - for element_name, confidence in element_confidence.items(): - stats = element_stats.get(str(element_name), {}) - has_repeat_support = ( - int(stats.get("match_count", 0)) >= 2 - or int(stats.get("strong_matches", 0)) >= 1 - ) - if ( - float(confidence) >= conf_p80 - and float(confidence) >= 1.8 * conf_floor - and has_repeat_support - ): - dominant_elements.add(str(element_name)) - - def _element_prior_factor(element_name, denom): - prior = float(element_confidence.get(element_name, 0.0)) / denom - prior_factor = 1.0 + 0.5 * prior - - # When an element is already strongly supported by the spectrum, - # bias nearby ambiguous peaks toward that same element. + max_detected_conf = max([element_confidence.get(el, 0.0) for el in detected_elements], default=0.0) + + def prior_boost(element): + prior = float(element_confidence.get(element, 0.0)) / max(float(max_detected_conf), 1e-9) + factor = 1.0 + 0.5 * prior if prior >= 0.90: - prior_factor *= 1.9 + factor *= 1.9 elif prior >= 0.75: - prior_factor *= 1.5 + factor *= 1.5 elif prior >= 0.55: - prior_factor *= 1.2 - - return prior, prior_factor - - def _line_consistency_boost(element_name, line_name, peak_energy, denom): - # Use dominant_elements membership rather than a normalized-prior - # threshold. The normalized prior can be suppressed when one or - # two elements have very large confidence (e.g. Au driving the - # denominator high), causing legitimate dominant elements like Cu - # to silently fail the old `prior >= 0.75` gate even though they - # clearly belong in the sample. - if str(element_name) not in dominant_elements: - return 1.0 + factor *= 1.2 + return prior, factor - line_label = f"{element_name} {line_name}" - evidence = line_evidence.get(line_label) - if evidence is None: + def consistency_boost(element, line_name, peak_energy): + if element not in dominant_elements: return 1.0 - - # Require support from another peak for this exact line so we - # don't self-reinforce a single spurious match. - has_other_peak_support = any( - abs(float(peak_energy) - float(prev_energy)) >= 0.04 - for prev_energy in evidence.get("energies", []) - ) - if not has_other_peak_support: + evidence = line_evidence.get(f'{element} {line_name}') + if not evidence or not any(abs(float(peak_energy) - float(prev)) >= 0.04 for prev in evidence.get('energies', [])): return 1.0 - - best_line_conf = float(evidence.get("best_conf", 0.0)) - best_line_snr = float(evidence.get("best_snr", 0.0)) - strong_line_matches = int(evidence.get("strong_matches", 0)) - - # Scale by line weight: high-weight primary lines (K/L α, weight - # ≈ 0.7–1.0) get proportionally stronger boosts than low-weight - # secondary lines (weight < 0.4). This prevents a low-weight - # line from an equally-dominant element from outscoring a - # confirmed primary line purely via prior amplification. - line_info_entry = (all_info or {}).get(str(element_name), {}).get(str(line_name), {}) - line_weight = ( - float(line_info_entry.get("weight", 0.5)) - if isinstance(line_info_entry, dict) - else 0.5 - ) - weight_tier = 1.0 + 0.7 * max(0.0, line_weight - 0.35) - - if strong_line_matches >= 1 and best_line_conf >= 1.4: - return min(3.2, 2.4 * weight_tier) - if best_line_conf >= 1.1 and best_line_snr >= max( - min_snr, 0.75 * snr_threshold_for_sample - ): - return min(2.6, 1.9 * weight_tier) - if best_line_conf >= 0.8: - return min(2.0, 1.5 * weight_tier) - return min(1.5, 1.2 * weight_tier) - - def _dominant_element_boost(element_name, denom): - element_key = str(element_name) - if element_key not in dominant_elements: + best_conf = float(evidence.get('best_conf', 0.0)) + best_snr = float(evidence.get('best_snr', 0.0)) + strong = int(evidence.get('strong_matches', 0)) + line_weight = float((all_info.get(element, {}).get(line_name, {}) or {}).get('weight', 0.5)) + tier = 1.0 + 0.7 * max(0.0, line_weight - 0.35) + if strong >= 1 and best_conf >= 1.4: + return min(3.2, 2.4 * tier) + if best_conf >= 1.1 and best_snr >= max(snr_min, 0.75 * snr_threshold): + return min(2.6, 1.9 * tier) + if best_conf >= 0.8: + return min(2.0, 1.5 * tier) + return min(1.5, 1.2 * tier) + + def dominant_boost(element): + if element not in dominant_elements: return 1.0 - - prior, _ = _element_prior_factor(element_key, denom) - stats = element_stats.get(element_key, {}) - repeat_support = max( - int(stats.get("strong_matches", 0)), - max(0, int(stats.get("match_count", 0)) - 1), - ) - - if prior >= 0.90: - base_boost = 2.30 - elif prior >= 0.75: - base_boost = 1.85 - else: - base_boost = 1.45 - + prior, _ = prior_boost(element) + stats = element_stats.get(element, {}) + repeat_support = max(int(stats.get('strong_matches', 0)), max(0, int(stats.get('match_count', 0)) - 1)) + base = 2.30 if prior >= 0.90 else 1.85 if prior >= 0.75 else 1.45 if repeat_support >= 2: - base_boost *= 1.10 - - return min(base_boost, 2.60) - - def _best_supported_line_match_with_prior( - peak_energy, snr, allowed_elements, edge_filters=None - ): - if not all_info or not allowed_elements: - return None - - best_tuple = None - best_score = -float("inf") - best_preferred_tuple = None - best_preferred_score = -float("inf") - denom = max(float(max_detected_conf), 1e-9) - - for element_name, lines in all_info.items(): - if element_name not in allowed_elements: + base *= 1.10 + return min(base, 2.60) + + def reranked_matches(peak_energy, snr, allowed_elements=None, top_k=None): + # Compute which elements have both main lines matched (pattern boost) + element_to_lines = {} + for _, _, _, _, el, _, _, ln, _, _ in peak_matches: + element_to_lines.setdefault(el, set()).add(ln) + scored = [] + for match in candidate_matches(peak_energy, snr, allowed_elements): + element, line_name, shell = match['element'], match['line'], match['shell'] + prior, prior_factor = prior_boost(element) + pref = 1.35 if element in preferred_elements else 1.0 + anchor = 1.15 if element in anchor_elements and shell in {'K', 'L'} else 1.0 + consistency = consistency_boost(element, line_name, peak_energy) + dom = dominant_boost(element) + # Pattern boost: if both main lines for K, L, or M are matched by detected peaks, boost candidate score + lines_matched = element_to_lines.get(element, set()) + k_lines = {'Ka1', 'Kb1'} + l_lines = {'La1', 'Lb1'} + m_lines = {'Ma1', 'Mb1'} + pattern_factor = 1.0 + if k_lines.issubset(lines_matched): + pattern_factor = 3.0 + elif l_lines.issubset(lines_matched): + pattern_factor = 2.5 + elif m_lines.issubset(lines_matched): + pattern_factor = 2.0 + if shell == 'M': + prior_factor = 1.0 + 0.3 * prior + consistency = 1.0 + dom = min(dom, 1.30) + score = match['score'] * prior_factor * pref * anchor * consistency * dom * pattern_factor + scored.append({**match, 'score': float(score)}) + + scored.sort(key=lambda m: m['score'], reverse=True) + if mode == 'elements_preferred' and preferred_elements: + preferred = [m for m in scored if m['element'] in preferred_elements] + scored = preferred + [m for m in scored if m['element'] not in preferred_elements] if preferred else scored + + unique, seen = [], set() + for match in scored: + label = f"{match['element']} {match['line']}" + if label in seen: continue - - prior, prior_factor = _element_prior_factor(element_name, denom) - preferred_factor = 1.35 if str(element_name) in preferred_elements_set else 1.0 - - for line_name, line_info in lines.items(): - if not _line_allowed_for_element(element_name, line_name, edge_filters): - continue - line_energy = line_info["energy (keV)"] - line_weight = line_info.get("weight", 0.5) - distance = abs(peak_energy - line_energy) - shell = _line_shell(line_name) - - is_m_line = "M" in line_name and not ("Ma" in line_name or "Mb" in line_name) - effective_tolerance = tolerance * 0.5 if is_m_line else tolerance - - if line_weight < min_line_weight or distance > effective_tolerance: - continue - - local_conf = _peak_confidence(snr, line_weight, distance) - anchor_boost = 1.0 - if element_name in anchor_elements and shell in {"K", "L"}: - anchor_boost = 1.15 - - consistency_boost = _line_consistency_boost( - element_name, line_name, peak_energy, denom - ) - dominant_boost = _dominant_element_boost(element_name, denom) - - # M-lines are secondary transitions confirmed by K/L lines - # from the same element. The large cascade boosts designed - # for K/L cross-peak propagation must not override a - # better-fitting primary K/L line from another element - # (e.g. P Ka1 vs Pt Ma1 near 2.03 keV: P Ka1 has higher - # local_conf but Pt is dominant via La1 at 9.47 keV). - if shell == "M": - eff_prior_factor = 1.0 + 0.3 * prior - eff_consistency = 1.0 - eff_dominant = min(dominant_boost, 1.30) - else: - eff_prior_factor = prior_factor - eff_consistency = consistency_boost - eff_dominant = dominant_boost - - score = ( - local_conf - * eff_prior_factor - * anchor_boost - * preferred_factor - * eff_consistency - * eff_dominant - ) - score *= _shell_preference_factor(shell) - - if ( - str(element_name) in preferred_elements_set - and score > best_preferred_score - ): - best_preferred_score = score - best_preferred_tuple = (element_name, line_name, line_weight, distance) - - if score > best_score: - best_score = score - best_tuple = (element_name, line_name, line_weight, distance) - - if mode_normalized == "elements_preferred" and best_preferred_tuple is not None: - return best_preferred_tuple - - return best_tuple - - def _top_supported_line_matches_with_prior( - peak_energy, snr, allowed_elements, edge_filters=None, top_k=3 - ): - if not all_info or top_k <= 0: - return [] - - scored_matches = [] - denom = max(float(max_detected_conf), 1e-9) - - for element_name, lines in all_info.items(): - if allowed_elements is not None and element_name not in allowed_elements: - continue - - prior, prior_factor = _element_prior_factor(element_name, denom) - preferred_factor = 1.35 if str(element_name) in preferred_elements_set else 1.0 - - for line_name, line_info in lines.items(): - if not _line_allowed_for_element(element_name, line_name, edge_filters): - continue - line_energy = line_info["energy (keV)"] - line_weight = line_info.get("weight", 0.5) - distance = abs(peak_energy - line_energy) - shell = _line_shell(line_name) - - is_m_line = "M" in line_name and not ("Ma" in line_name or "Mb" in line_name) - effective_tolerance = tolerance * 0.5 if is_m_line else tolerance - - if line_weight < min_line_weight or distance > effective_tolerance: - continue - - local_conf = _peak_confidence(snr, line_weight, distance) - anchor_boost = 1.0 - if element_name in anchor_elements and shell in {"K", "L"}: - anchor_boost = 1.15 - - consistency_boost = _line_consistency_boost( - element_name, line_name, peak_energy, denom - ) - dominant_boost = _dominant_element_boost(element_name, denom) - - if shell == "M": - eff_prior_factor = 1.0 + 0.3 * prior - eff_consistency = 1.0 - eff_dominant = min(dominant_boost, 1.30) - else: - eff_prior_factor = prior_factor - eff_consistency = consistency_boost - eff_dominant = dominant_boost - - score = ( - local_conf - * eff_prior_factor - * anchor_boost - * preferred_factor - * eff_consistency - * eff_dominant - ) - score *= _shell_preference_factor(shell) - scored_matches.append( - ( - float(score), - str(element_name), - str(line_name), - float(line_weight), - float(distance), - ) - ) - - scored_matches.sort(key=lambda item: item[0], reverse=True) - - if mode_normalized == "elements_preferred" and preferred_elements_set: - preferred_scored_matches = [ - item for item in scored_matches if str(item[1]) in preferred_elements_set - ] - if len(preferred_scored_matches) > 0: - non_preferred_scored_matches = [ - item - for item in scored_matches - if str(item[1]) not in preferred_elements_set - ] - scored_matches = preferred_scored_matches + non_preferred_scored_matches - - unique = [] - seen_labels = set() - for score, element_name, line_name, line_weight, distance in scored_matches: - label = f"{element_name} {line_name}" - if label in seen_labels: - continue - seen_labels.add(label) - unique.append((element_name, line_name, line_weight, distance, score)) - - if len(unique) == 0: - return [] - - # Keep the top-scoring best match first, then prefer alternatives - # from different elements before falling back to same-element lines. - best_match = unique[0] - selected = [best_match] - selected_labels = {f"{best_match[0]} {best_match[1]}"} - used_elements = {str(best_match[0])} - - for candidate in unique[1:]: - element_name, line_name, _, _, _ = candidate - label = f"{element_name} {line_name}" - if label in selected_labels or str(element_name) in used_elements: + seen.add(label) + unique.append(match) + + if top_k is None or len(unique) <= 1: + return unique + selected = [unique[0]] + used_elements = {unique[0]['element']} + for match in unique[1:]: + if match['element'] in used_elements: continue - selected.append(candidate) - selected_labels.add(label) - used_elements.add(str(element_name)) + selected.append(match) + used_elements.add(match['element']) if len(selected) >= int(top_k): return selected - - for candidate in unique[1:]: - element_name, line_name, _, _, _ = candidate - label = f"{element_name} {line_name}" - if label in selected_labels: - continue - selected.append(candidate) - selected_labels.add(label) + for match in unique[1:]: + if match not in selected: + selected.append(match) if len(selected) >= int(top_k): break - return selected - first_pass_elements = { - str(match[4]) - for match in peak_matches - if len(match) > 4 and str(match[4]) not in ignored_elements - } - rematch_allowed_elements = set(str(element_name) for element_name in detected_elements) - rematch_allowed_elements.update(first_pass_elements) - if preferred_elements_set: - rematch_allowed_elements.update(preferred_elements_set) + rematch_allowed = {str(match[4]) for match in peak_matches if str(match[4]) not in ignored_elements} + rematch_allowed.update(map(str, detected_elements)) + rematch_allowed.update(preferred_elements) + refined_peak_matches = [] + raw_match_by_idx = {int(match[0]): match for match in peak_matches} for peak_idx, height, peak_energy, snr in display_peaks: - match = raw_match_by_idx.get(int(peak_idx)) - - if rematch_allowed_elements: - alt_match_info = _best_supported_line_match_with_prior( - peak_energy, snr, rematch_allowed_elements, requested_edge_filters - ) - if alt_match_info is not None: - alt_element, alt_line_name, alt_line_weight, alt_distance = alt_match_info - alt_element = str(alt_element) - alt_line_name = str(alt_line_name) - alt_match_str = f"{alt_element} {alt_line_name}" - alt_conf = _peak_confidence(snr, alt_line_weight, alt_distance) - match = ( - peak_idx, - height, - peak_energy, - snr, - alt_element, - alt_match_str, - alt_distance, - alt_line_name, - alt_line_weight, - alt_conf, - ) - - if match is not None: - refined_peak_matches.append(match) - + best = reranked_matches(peak_energy, snr, rematch_allowed or None, top_k=1) + best = best[0] if best else None + if best is None: + continue + refined_peak_matches.append(( + peak_idx, + height, + peak_energy, + snr, + best['element'], + f"{best['element']} {best['line']}", + best['distance'], + best['line'], + best['weight'], + best['score'], + )) peak_matches = refined_peak_matches - # Keep confidence-based detected elements, but ensure they still have - # at least one matched peak after rematching. matched_elements = {str(match[4]) for match in peak_matches} - detected_elements = { - str(element_name) - for element_name in detected_elements - if str(element_name) in matched_elements - } - if mode_normalized == "elements_preferred" and preferred_elements_set: - detected_elements.update( - str(element_name) - for element_name in preferred_elements_set - if str(element_name) in matched_elements - ) - if ignored_elements: - detected_elements = { - str(element_name) - for element_name in detected_elements - if str(element_name) not in ignored_elements - } - + detected_elements = {str(el) for el in detected_elements if str(el) in matched_elements and str(el) not in ignored_elements} + if mode == 'elements_preferred': + detected_elements.update(str(el) for el in preferred_elements if str(el) in matched_elements) refined_match_by_idx = {int(match[0]): match for match in peak_matches} final_matches_by_element: dict[str, set[str]] = {} - for ( - _peak_idx, - _height, - _peak_energy, - _snr, - element_name, - _match_str, - _distance, - line_name, - _line_weight, - _match_confidence, - ) in peak_matches: - element_key = str(element_name) - if element_key in ignored_elements: - continue - final_matches_by_element.setdefault(element_key, set()).add(str(line_name)) - - displayed_peak_count = len(display_peaks) - total_over_snr_peak_count = len(significant_peaks) - - candidate_elements = sorted( - str(element_name) - for element_name in final_matches_by_element - if str(element_name) not in detected_elements - ) - possible_elements_set = set(candidate_elements) - possible_line_labels = { - f"{str(element_name)} {str(line_name)}" - for ( - _, - _, - _, - _, - element_name, - _, - _, - line_name, - _, - _, - ) in peak_matches - if str(element_name) in possible_elements_set - } - - def _format_saved_model_elements(edge_filters): + for _, _, _, _, element, _, _, line_name, _, _ in peak_matches: + if element not in ignored_elements: + final_matches_by_element.setdefault(element, set()).add(str(line_name)) + + candidate_elements = sorted(str(el) for el in final_matches_by_element if str(el) not in detected_elements) + possible_elements = set(candidate_elements) + possible_line_labels = {f'{element} {line}' for _, _, _, _, element, _, _, line, _, _ in peak_matches if element in possible_elements} + + def format_elements_with_lines(names): + items = [] + for element in sorted(map(str, names)): + lines = sorted(map(str, final_matches_by_element.get(element, set()))) + line_strs = [str(line) for line in lines] + items.append(f"{element} [{', '.join(line_strs)}]" if lines else f'{element}') + return ', '.join(items) + + def format_saved(edge_filters): if edge_filters is None: - return "None" - - formatted = [] - for element_name in sorted(str(name) for name in edge_filters): - selectors = edge_filters.get(element_name) - if selectors is None or len(selectors) == 0: - formatted.append(f"{element_name} [all]") - continue - - selector_names = sorted(str(token) for token in selectors) - formatted.append(f"{element_name} [{', '.join(selector_names)}]") - - return "\n".join(formatted) if len(formatted) > 0 else "None" - - def _format_elements_with_lines(element_names): - formatted = [] - for element_name in sorted(str(name) for name in element_names): - line_names = sorted( - str(line_name) - for line_name in final_matches_by_element.get(str(element_name), set()) - ) - confidence_value = float(element_confidence.get(str(element_name), 0.0)) - confidence_suffix = f" conf={confidence_value:.2f}" - if len(line_names) > 0: - formatted.append( - f"{element_name} [{', '.join(line_names)}]{confidence_suffix}" - ) - else: - formatted.append(f"{str(element_name)}{confidence_suffix}") - return ", ".join(formatted) - - model_elements_header = "Saved Model Elements (Plotted):\n" - print( - f"\n{model_elements_header} {_format_saved_model_elements(saved_model_edge_filters)}" - ) + return 'None' + out = [] + for element in sorted(map(str, edge_filters)): + selectors = edge_filters.get(element) + out.append(f'{element} [all]' if not selectors else f"{element} [{', '.join(sorted(map(str, selectors)))}]") + return '\n'.join(out) if out else 'None' - if detected_elements: - print(f"\nAutodetected: {_format_elements_with_lines(detected_elements)}") - else: - print("\nAutodetected: None") + print(f"\nAutodetected: {format_elements_with_lines(detected_elements) if detected_elements else 'None'}") if dominant_elements: - dominant_sorted = sorted( - dominant_elements, - key=lambda el: element_confidence.get(str(el), 0.0), - reverse=True, - ) - dominant_str = ", ".join( - f"{el} (conf={element_confidence.get(str(el), 0.0):.2f})" for el in dominant_sorted - ) - print(f"Dominant (strong prior): {dominant_str}") - if candidate_elements: - print(f"Possible: {_format_elements_with_lines(candidate_elements)}") - else: - print("Possible: None") + dominant_str = ', '.join(f"{el} (conf={element_confidence.get(str(el), 0.0):.2f})" for el in sorted(dominant_elements, key=lambda el: element_confidence.get(str(el), 0.0), reverse=True)) + print(f'Dominant (strong prior): {dominant_str}') + print(f"Possible: {format_elements_with_lines(candidate_elements) if candidate_elements else 'None'}") - # Stable per-element colors (same element color across K/L/M lines) - elements_for_color = set(detected_elements) + elements_for_color = set(detected_elements) | {str(match[4]) for match in peak_matches} if search_elements is not None: - elements_for_color.update(str(el) for el in search_elements) - elements_for_color.update(str(match[4]) for match in peak_matches) - - sorted_elements_for_colors = sorted(elements_for_color) - high_contrast_palette = [ - "#1f77b4", # blue - "#d62728", # red - "#2ca02c", # green - "#9467bd", # purple - "#ff7f0e", # orange - "#8c564b", # brown - "#e377c2", # pink - "#17becf", # cyan - "#bcbd22", # olive - "#7f7f7f", # gray - "#003f5c", # dark blue - "#7a5195", # deep violet - "#ef5675", # strong rose - "#ffa600", # amber - "#2f4b7c", # slate blue - ] - color_palette = [ - high_contrast_palette[i % len(high_contrast_palette)] - for i in range(max(1, len(sorted_elements_for_colors))) - ] - element_color_map = { - element: color_palette[i] for i, element in enumerate(sorted_elements_for_colors) + elements_for_color.update(map(str, search_elements)) + palette = ['#1f77b4', '#d62728', '#2ca02c', '#9467bd', '#ff7f0e', '#8c564b', '#e377c2', '#17becf', '#bcbd22', '#7f7f7f', '#003f5c', '#7a5195', '#ef5675', '#ffa600', '#2f4b7c'] + element_color_map = {el: palette[i % len(palette)] for i, el in enumerate(sorted(elements_for_color))} + + detected_sample_peaks = { + float(peak_energy) + for _, _, peak_energy, _, element, _, _, _, _, _ in peak_matches + if element in detected_elements } - - table_rows = [] - matched_row_count = 0 - - def _mark_autodetected_label(label_text): - if not label_text or label_text == "-": - return label_text - element_token = str(label_text).split()[0] - if element_token in detected_elements: - return f"{label_text}*" - return label_text - - def _format_label_with_score(label_text, score_value): - if score_value is None: - return str(label_text) - return f"{label_text} ({float(score_value):.3f})" - - for peak_idx, height, peak_energy, snr in display_peaks: - match = refined_match_by_idx.get(int(peak_idx)) - if match is not None: - if search_elements is not None: - allowed_for_table = set(str(element_name) for element_name in search_elements) - else: - allowed_for_table = { - str(element_name) - for element_name in (all_info or {}).keys() - if str(element_name) not in ignored_elements - } - if len(allowed_for_table) == 0: - allowed_for_table = None - - top_matches = _top_supported_line_matches_with_prior( - peak_energy, - snr, - allowed_for_table, - requested_edge_filters, - top_k=3, - ) - best_label = f"{str(match[4])} {str(match[7])}" - score_by_label = { - f"{element_name} {line_name}".lower(): float(score) - for element_name, line_name, _, _, score in top_matches - } - best_score = score_by_label.get(str(best_label).lower()) - best_match = _mark_autodetected_label( - _format_label_with_score(best_label, best_score) - ) - alt_2 = "-" - alt_3 = "-" - - if len(top_matches) > 0: - ranked_entries = [ - (f"{element_name} {line_name}", float(score)) - for element_name, line_name, _, _, score in top_matches - ] - ranked_entries = [ - (label, score) - for label, score in ranked_entries - if str(label).lower() != str(best_label).lower() - ] - if len(ranked_entries) > 0: - alt_2 = _mark_autodetected_label( - _format_label_with_score(ranked_entries[0][0], ranked_entries[0][1]) - ) - if len(ranked_entries) > 1: - alt_3 = _mark_autodetected_label( - _format_label_with_score(ranked_entries[1][0], ranked_entries[1][1]) - ) - - table_rows.append((peak_energy, height, snr, best_match, alt_2, alt_3)) - matched_row_count += 1 - else: - row_label = "Unmatched" if search_elements is not None else "Unknown" - table_rows.append((peak_energy, height, snr, row_label, "-", "-")) - - sorted_table_rows = sorted(table_rows, key=lambda item: item[0]) - - for ( - peak_idx, - height, - peak_energy, - snr, - element_name, - match_str, - distance, - line_name, - line_weight, - match_confidence, - ) in peak_matches: - if element_name in detected_elements: - detected_sample_peaks[peak_energy] = True - - filtered_sample_peaks = {} - for peak_energy in detected_sample_peaks: - for ( - peak_idx, - height, - matched_energy, - snr, - element_name, - match_str, - distance, - line_name, - line_weight, - match_confidence, - ) in peak_matches: - if abs(matched_energy - peak_energy) < 0.001 and element_name in detected_elements: - filtered_sample_peaks[peak_energy] = True - break - detected_sample_peaks = filtered_sample_peaks - - y_min = float(np.nanmin(spec)) if len(spec) > 0 else 0.0 - y_max = float(np.nanmax(spec)) if len(spec) > 0 else 1.0 - y_span = max(1e-9, y_max - y_min) - y_scale = max(y_span, abs(y_max), 1.0) + y_min = float(np.nanmin(spec)) if len(spec) else 0.0 + y_max = float(np.nanmax(spec)) if len(spec) else 1.0 + y_scale = max(max(1e-9, y_max - y_min), abs(y_max), 1.0) y_dot = -0.04 * y_scale - def _infer_requested_element_for_color(peak_energy): - if reference_elements is None or not all_info: + def infer_requested_color(peak_energy): + if reference_elements is None: return None - - best_element = None - best_distance = float("inf") - for element_name in reference_elements: - element_key = str(element_name) - lines_info = all_info.get(element_key, {}) - if not isinstance(lines_info, dict): - continue - for line_name, line_info in lines_info.items(): - if not _line_allowed_for_element( - element_key, line_name, requested_edge_filters - ): - continue - if not isinstance(line_info, dict): - continue - line_energy = line_info.get("energy (keV)") - if line_energy is None: + best_element, best_distance = None, float('inf') + for element in reference_elements: + for line_name, line_info in (all_info.get(str(element), {}) or {}).items(): + if not type(self)._line_allowed_for_element(str(element), line_name, edge_filters): continue try: - distance = abs(float(peak_energy) - float(line_energy)) + distance = abs(float(peak_energy) - float(line_info.get('energy (keV)'))) except (TypeError, ValueError): continue if distance < best_distance: - best_distance = distance - best_element = element_key - + best_distance, best_element = distance, str(element) return best_element + table_rows = [] for peak_idx, height, peak_energy, snr in display_peaks: - if _is_in_ignored_range(peak_energy): - continue - is_sample = detected_sample_peaks.get(peak_energy, False) match = refined_match_by_idx.get(int(peak_idx)) - is_possible = match is not None and str(match[4]) in possible_elements_set - if match is not None: - peak_element = match[4] - line_color = element_color_map.get(peak_element, "red") - else: - inferred_element = _infer_requested_element_for_color(peak_energy) - if inferred_element is not None: - line_color = element_color_map.get(str(inferred_element), "red") + is_sample = float(peak_energy) in detected_sample_peaks + is_possible = match is not None and str(match[4]) in possible_elements + color = element_color_map.get(match[4], 'red') if match is not None else element_color_map.get(str(infer_requested_color(peak_energy)), 'red') + + if not in_ignore(peak_energy): + # Only plot solid lines for matched peaks (autodetected or requested elements) + if match is not None: + ax_spec.axvline(peak_energy, color=color, linestyle='-', alpha=0.7, linewidth=1.5) else: - line_color = "red" - - if is_sample: - ax_spec.axvline( - peak_energy, - color=line_color, - linestyle="-", - alpha=0.5, - linewidth=1.5, - ) - elif is_possible: - ax_spec.axvline( - peak_energy, - color=line_color, - linestyle="--", - alpha=0.45, - linewidth=1.25, - ) - else: - ax_spec.plot( - [peak_energy], - [y_dot], - marker="|", - markersize=4, - color="gray", - alpha=0.8, - linestyle="None", - ) + ax_spec.plot([peak_energy], [y_dot], marker='|', markersize=4, color='gray', alpha=0.8, linestyle='None') + + if show_text and match is not None: + for grid_element, grid_energy in grid_peaks.items(): + if abs(peak_energy - grid_energy) < 0.1: + ax_spec.text(peak_energy, height * 0.7, f'{grid_element}\n(grid)', ha='center', va='bottom', fontsize=8, color='gray', style='italic') + print(f'Peak at {peak_energy} keV may come from the grid.') + break + + def label_with_energy_and_ratio(label, detected_peak_intensity=None): + # label is like 'Fe Ka', want to append (energy, ratio) from all_info and observed/expected + if not label or label == '-' or label == 'Unmatched' or label == 'Unknown': + return label + parts = label.split() + if len(parts) < 2: + return label + element, line = parts[0], parts[1].replace('*','') + line_info = all_info.get(element, {}).get(line, {}) + ref_energy = None + if isinstance(line_info, dict): + ref_energy = line_info.get('energy (keV)', line_info.get('energy')) + try: + ref_energy = float(ref_energy) + except (TypeError, ValueError): + ref_energy = None + # Compute observed/expected ratio: use detected peak intensity / expected weight + ratio_str = '' + weight = line_info.get('weight', None) + try: + weight = float(weight) if weight is not None else 0.0 + except Exception: + weight = 0.0 + if detected_peak_intensity is not None and weight: + try: + ratio = float(detected_peak_intensity) / float(weight) + ratio_str = f", {ratio:.2f}" + except Exception: + ratio_str = '' + label_core = label.rstrip('*') + star = '*' if label.endswith('*') else '' + if ref_energy is not None: + return f"{label_core} ({ref_energy:.3f}{ratio_str}){star}" + else: + return label - if show_text and is_sample: - y_pos = height * 0.7 - is_grid_peak = False - for grid_element, grid_energy in grid_peaks.items(): - if abs(peak_energy - grid_energy) < 0.1: - ax_spec.text( - peak_energy, - y_pos, - f"{grid_element}\n(grid)", - ha="center", - va="bottom", - fontsize=8, - color="gray", - style="italic", - ) - is_grid_peak = True - break - if is_grid_peak: - print(f"Peak at {peak_energy} keV may come from the grid.") + if match is None: + table_rows.append((peak_energy, height, snr, 'Unmatched' if search_elements is not None else 'Unknown', '-', '-')) + continue - current_bottom, current_top = ax_spec.get_ylim() - dot_padding = 0.02 * y_scale - target_bottom = min(current_bottom, y_dot - dot_padding, -dot_padding) - ax_spec.set_ylim(bottom=target_bottom, top=current_top) - - # If elements were explicitly requested, overlay reference X-ray lines from the - # database even when they are not peak-matched by auto-id. - all_label_candidates = [] - unified_axes_top_label_y = 0.92 - if reference_elements is not None: - energy_min = float(np.min(E)) - energy_max = float(np.max(E)) - displayed_peak_energies = [ - float(peak_energy) for _, _, peak_energy, _ in display_peaks - ] - display_peak_tolerance = max(0.05, 0.5 * tolerance) - existing_matches_by_element = {} - for ( - peak_idx, - height, + allowed_for_table = set(map(str, search_elements)) if search_elements is not None else ({str(el) for el in all_info if str(el) not in ignored_elements} or None) + ranked = reranked_matches(peak_energy, snr, allowed_for_table, top_k=3) + labels = [(f"{m['element']} {m['line']}", float(m['score']), m['element'], m['line']) for m in ranked] + best_label = f'{match[4]} {match[7]}' + + def fmt(label, score=None): + label = f'{label}*' if str(label).split()[0] in detected_elements else label + return label + + # Gather all intensities for this element for ratio calculation + all_element_intensities = {} + for l in all_info.get(match[4], {}): + # Find the highest observed intensity for each line + obs = 0.0 + for _, h, _, _, el, _, _, ln, _, _ in peak_matches: + if el == match[4] and ln == l: + obs = max(obs, float(h)) + weight = all_info.get(match[4], {}).get(l, {}).get('weight', None) + try: + weight = float(weight) if weight is not None else 0.0 + except Exception: + weight = 0.0 + all_element_intensities[l] = (obs, weight) + + remaining = [(label, score, elem, line) for label, score, elem, line in labels if label.lower() != best_label.lower()] + # For each label, show ratio for that line + def get_peak_intensity(elem, line): + obs = 0.0 + for _, h, _, _, el, _, _, ln, _, _ in peak_matches: + if el == elem and ln == line: + obs = max(obs, float(h)) + return obs + + table_rows.append(( peak_energy, + height, snr, - element_name, - match_str, - distance, - line_name, - line_weight, - match_confidence, - ) in peak_matches: - element_key = str(element_name) - if element_key not in existing_matches_by_element: - existing_matches_by_element[element_key] = [] - existing_matches_by_element[element_key].append(float(peak_energy)) - - for element_name in sorted(reference_elements): - element_key = str(element_name) - lines_info = all_info.get(element_key, {}) if all_info is not None else {} - if not isinstance(lines_info, dict) or len(lines_info) == 0: - continue + label_with_energy_and_ratio(fmt(best_label), detected_peak_intensity=height), + label_with_energy_and_ratio(fmt(remaining[0][0]), detected_peak_intensity=height) if len(remaining) > 0 else '-', + label_with_energy_and_ratio(fmt(remaining[1][0]), detected_peak_intensity=height) if len(remaining) > 1 else '-', + )) - candidate_lines = [] - for line_name, line_info in lines_info.items(): - if not _line_allowed_for_element( - element_key, line_name, requested_edge_filters - ): - continue - if not isinstance(line_info, dict): - continue - energy_val = line_info.get("energy (keV)") - if energy_val is None: + current_bottom, current_top = ax_spec.get_ylim() + ax_spec.set_ylim(bottom=min(current_bottom, y_dot - 0.02 * y_scale, -0.02 * y_scale), top=current_top) + + label_candidates = [] + top_label_y = 0.92 + # Plot reference lines (dotted) ONLY for explicitly requested elements, not for autodetected/possible + if requested_elements: + energy_min, energy_max = float(np.min(E)), float(np.max(E)) + matched_by_element = {} + for _, _, peak_energy, _, element, _, _, _, _, _ in peak_matches: + matched_by_element.setdefault(str(element), []).append(float(peak_energy)) + + for element in sorted(requested_elements): + candidates = [] + for line_name, line_info in (all_info.get(str(element), {}) or {}).items(): + if not type(self)._line_allowed_for_element(str(element), line_name, edge_filters): continue try: - line_energy = float(energy_val) + line_energy = float(line_info.get('energy (keV)')) + line_weight = float(line_info.get('weight', 0.0)) except (TypeError, ValueError): continue - if not (energy_min <= line_energy <= energy_max): + if energy_min <= line_energy <= energy_max: + candidates.append((str(line_name), line_energy, line_weight)) + candidates = sorted([c for c in candidates if c[2] >= 0.05] or candidates, key=lambda item: item[2], reverse=True)[:6] + for line_name, line_energy, _ in candidates: + if in_ignore(line_energy): continue - - line_weight = float(line_info.get("weight", 0.0)) - candidate_lines.append((line_name, line_energy, line_weight)) - - if len(candidate_lines) == 0: - continue - - # Keep meaningful requested-element lines while avoiding excessive clutter. - filtered_lines = [line for line in candidate_lines if line[2] >= 0.05] - if len(filtered_lines) == 0: - filtered_lines = sorted( - candidate_lines, key=lambda item: item[2], reverse=True - )[:1] - else: - filtered_lines = sorted( - filtered_lines, key=lambda item: item[2], reverse=True - )[:6] - - for line_name, line_energy, line_weight in filtered_lines: - if _is_in_ignored_range(line_energy): + # Skip if already matched by a detected peak + if any(abs(line_energy - matched_energy) <= max(0.05, 0.5 * tolerance) for matched_energy in matched_by_element.get(str(element), [])): continue - - line_label = f"{element_key} {line_name}" - is_possible_line = line_label in possible_line_labels - - # For possible elements, draw guides only near peaks that already - # passed snr_min. For reference-only requested elements, keep - # explicit refline guides even without nearby peaks. - if is_possible_line: - if not any( - abs(line_energy - peak_energy) <= display_peak_tolerance - for peak_energy in displayed_peak_energies - ): - continue - - matched_energies = existing_matches_by_element.get(element_key, []) - if any( - abs(line_energy - matched_energy) <= max(0.05, 0.5 * tolerance) - for matched_energy in matched_energies - ): - continue - - line_color = element_color_map.get(element_key, "black") - line_style = "--" if is_possible_line else ":" - line_alpha = 0.3 if is_possible_line else 0.35 - ax_spec.axvline( - line_energy, - color=line_color, - linestyle=line_style, - alpha=line_alpha, - linewidth=1.2, - ) - all_label_candidates.append( - ( - float(line_energy), - f"{element_key} {line_name}", - line_color, - line_style, - float(unified_axes_top_label_y), - "axes_top", - 8, - "normal", - 0.8, - ) - ) - - legend_handles = [] - legend_labels = set() + color = element_color_map.get(str(element), 'black') + style = '--' + alpha = 0.45 + ax_spec.axvline(line_energy, color=color, linestyle=style, alpha=alpha, linewidth=1.2) + label_candidates.append((float(line_energy), f'{element} {line_name}', color, style, float(top_label_y), 'axes_top', 8, 'normal', 0.8)) if show_text and peak_matches: - labels_to_plot = [] - for ( - peak_idx, - height, - peak_energy, - snr, - element_name, - match_str, - distance, - line_name, - line_weight, - match_confidence, - ) in peak_matches: - is_detected_peak = element_name in detected_elements and detected_sample_peaks.get( - peak_energy, False - ) - is_possible_peak = str(element_name) in possible_elements_set - if not (is_detected_peak or is_possible_peak): - continue - - line_name = match_str.split()[-1] if match_str else "" - label_text = f"{element_name} {line_name}" if line_name else element_name - if is_detected_peak: - label_text = f"{label_text}*" - color = element_color_map.get(element_name, "black") - linestyle = "-" if is_detected_peak else "--" - labels_to_plot.append((peak_energy, label_text, color, height, linestyle)) - - label_vertical_offset = max(0.03 * y_scale, 0.08) - for peak_energy, label_text, color, height, linestyle in labels_to_plot: - if _is_in_ignored_range(peak_energy): + label_offset = max(0.03 * y_scale, 0.08) + # Only label autodetected elements and explicitly requested elements + label_allowed = set(detected_elements) + if requested_elements: + label_allowed.update(str(el) for el in requested_elements) + for _, height, peak_energy, _, element, match_str, _, _, _, _ in peak_matches: + is_detected = element in detected_elements and float(peak_energy) in detected_sample_peaks + if element not in label_allowed or in_ignore(peak_energy): continue - if linestyle == "--": - all_label_candidates.append( - ( - float(peak_energy), - label_text, - color, - linestyle, - float(unified_axes_top_label_y), - "axes_top", - 9, - "normal", - 0.9, - ) - ) - else: - all_label_candidates.append( - ( - float(peak_energy), - label_text, - color, - linestyle, - float(height + label_vertical_offset), - "data", - 10, - "bold", - 1.0, - ) - ) - - if show_text and all_label_candidates: - all_label_candidates.sort(key=lambda item: item[0]) - - overlap_threshold = max(0.16, 1.10 * float(tolerance)) - grouped_labels = [] - current_group = [] - - for label in all_label_candidates: - if not current_group: - current_group.append(label) + if not is_detected and element not in (requested_elements or set()): continue - - prev_energy = current_group[-1][0] - energy_delta = abs(label[0] - prev_energy) - should_group = energy_delta <= overlap_threshold - - if should_group: - current_group.append(label) + label = f"{element} {match_str.split()[-1]}" + ('*' if is_detected else '') + style = '-' if is_detected else '--' + y_value = float(height + label_offset) if is_detected else float(top_label_y) + y_mode = 'data' if is_detected else 'axes_top' + label_candidates.append((float(peak_energy), label, element_color_map.get(element, 'black'), style, y_value, y_mode, 10 if is_detected else 9, 'bold' if is_detected else 'normal', 1.0 if is_detected else 0.9)) + + legend_handles, legend_labels = [], set() + if show_text and label_candidates: + label_candidates.sort(key=lambda item: item[0]) + groups, current = [], [] + overlap_threshold = max(0.16, 1.1 * float(tolerance)) + for label in label_candidates: + if not current or abs(label[0] - current[-1][0]) <= overlap_threshold: + current.append(label) else: - grouped_labels.append(current_group) - current_group = [label] + groups.append(current) + current = [label] + if current: + groups.append(current) - if current_group: - grouped_labels.append(current_group) - - for group in grouped_labels: + for group in groups: if len(group) == 1: - ( - peak_energy, - label_text, - color, - linestyle, - y_value, - y_mode, - font_size, - font_weight, - alpha_value, - ) = group[0] - if y_mode == "axes_top": - ax_spec.text( - peak_energy, - y_value, - label_text, - ha="center", - va="top", - fontsize=font_size, - color=color, - weight=font_weight, - rotation=90, - alpha=alpha_value, - transform=ax_spec.get_xaxis_transform(), - clip_on=True, - ) + peak_energy, label_text, color, _, y_value, y_mode, font_size, font_weight, alpha_value = group[0] + common = dict(ha='center', fontsize=font_size, color=color, weight=font_weight, rotation=90, alpha=alpha_value) + if y_mode == 'axes_top': + ax_spec.text(peak_energy, y_value, label_text, va='top', transform=ax_spec.get_xaxis_transform(), clip_on=True, **common) else: - ax_spec.text( - peak_energy, - y_value, - label_text, - ha="center", - va="bottom", - fontsize=font_size, - color=color, - weight=font_weight, - rotation=90, - alpha=alpha_value, - ) + ax_spec.text(peak_energy, y_value, label_text, va='bottom', **common) else: - for _, label_text, color, linestyle, _, _, _, _, _ in group: - legend_key = (label_text, str(color), linestyle) - if legend_key in legend_labels: + for _, label_text, color, linestyle, *_ in group: + key = (label_text, str(color), linestyle) + if key in legend_labels: continue - legend_labels.add(legend_key) - legend_handles.append( - Line2D( - [0], - [0], - color=color, - linestyle=linestyle, - linewidth=1.5, - label=label_text, - ) - ) - - overlap_legend = None + legend_labels.add(key) + legend_handles.append(Line2D([0], [0], color=color, linestyle=linestyle, linewidth=1.5, label=label_text)) + if legend_handles: - overlap_legend = ax_spec.legend( - handles=legend_handles, - loc="upper right", - fontsize=8, - title="Overlapping Labels", - ) - if overlap_legend is not None: + overlap_legend = ax_spec.legend(handles=legend_handles, loc='upper right', fontsize=8, title='Overlapping Labels') ax_spec.add_artist(overlap_legend) fig.tight_layout() plt.show() - print( - f"{'Energy (keV)':<12} {'Intensity':<12} {'SNR':<8} " - f"{'Best Match':<22} {'Alt 2':<22} {'Alt 3':<22}" - ) - print("-" * 105) + sorted_table_rows = sorted(table_rows, key=lambda item: item[0]) + print(f"{'Energy (keV)':<12} {'Intensity':<12} {'SNR':<8} {'Best Match':<22} {'Alt 2':<22} {'Alt 3':<22}") + print('-' * 105) for peak_energy, height, snr, best_match, alt_2, alt_3 in sorted_table_rows: - print( - f"{peak_energy:<12.3f} {height:<12.1f} {snr:<8.1f} " - f"{best_match:<22} {alt_2:<22} {alt_3:<22}" - ) - print("-" * 105) - print( - f"{displayed_peak_count} of {total_over_snr_peak_count} peaks above " - f"snr_min={min_snr:.1f}, snr_threshold={snr_threshold_for_sample:.1f} displayed.\n" - ) + print(f'{peak_energy:<12.3f} {height:<12.2f} {snr:<8.1f} {best_match:<22} {alt_2:<22} {alt_3:<22}') + print('-' * 105) + print(f'{len(display_peaks)} of {len(significant_peaks)} peaks above snr_min={snr_min:.1f}, snr_threshold={snr_threshold:.1f} displayed.\n') if return_details: return { - "figure": fig, - "axes": (ax_img, ax_spec), - "detected_elements": sorted(detected_elements), - "element_confidence": element_confidence, - "display_peaks": display_peaks, - "peak_matches": peak_matches, - "snr_min": min_snr, - "snr_threshold": snr_threshold_for_sample, + 'figure': fig, + 'axes': (ax_img, ax_spec), + 'detected_elements': sorted(detected_elements), + 'element_confidence': element_confidence, + 'display_peaks': display_peaks, + 'peak_matches': peak_matches, + 'snr_min': snr_min, + 'snr_threshold': snr_threshold, } - return fig, (ax_img, ax_spec) def _fit_mean_model_pytorch( diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 6c2852e3..5d9b2eb3 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -614,22 +614,22 @@ def _validate_roi_bounds(self, y, x, dy, dx): if errs: raise ValueError("Invalid ROI:\n - " + "\n - ".join(errs)) - def _resolve_roi(self, roi=None, roi_units=None): - selector_count = int(roi is not None) + int(roi_units is not None) + def _resolve_roi(self, roi=None, roi_cal=None): + selector_count = int(roi is not None) + int(roi_cal is not None) if selector_count > 1: - raise ValueError("Use only one ROI selector: roi or roi_units") + raise ValueError("Use only one ROI selector: roi or roi_cal") if roi is not None: roi_spec = roi - elif roi_units is not None: - if len(roi_units) == 2: - y_cal, x_cal = roi_units + elif roi_cal is not None: + if len(roi_cal) == 2: + y_cal, x_cal = roi_cal roi_spec = [ self._calibrated_position_to_pixel(y_cal, axis=1), self._calibrated_position_to_pixel(x_cal, axis=2), ] - elif len(roi_units) == 4: - y_cal, x_cal, dy_cal, dx_cal = roi_units + elif len(roi_cal) == 4: + y_cal, x_cal, dy_cal, dx_cal = roi_cal roi_spec = [ self._calibrated_position_to_pixel(y_cal, axis=1), self._calibrated_position_to_pixel(x_cal, axis=2), @@ -637,7 +637,7 @@ def _resolve_roi(self, roi=None, roi_units=None): self._calibrated_span_to_pixels(dx_cal, axis=2), ] else: - raise ValueError("roi_units must be [y, x] or [y, x, dy, dx]") + raise ValueError("roi_cal must be [y, x] or [y, x, dy, dx]") else: roi_spec = None @@ -654,7 +654,7 @@ def _resolve_roi(self, roi=None, roi_units=None): dx = int(self.shape[2]) - x if dx_val is None else int(dx_val) else: raise ValueError( - "ROI must be None, [y, x], or [y, x, dy, dx]. Use one selector: roi or roi_units" + "ROI must be None, [y, x], or [y, x, dy, dx]. Use one selector: roi or roi_cal" ) self._validate_roi_bounds(y, x, dy, dx) @@ -667,9 +667,9 @@ def calculate_mean_spectrum( ignore_range=None, mask=None, attach_mean_spectrum=True, - roi_units=None, + roi_cal=None, ): - y, x, dy, dx = self._resolve_roi(roi=roi, roi_units=roi_units) + y, x, dy, dx = self._resolve_roi(roi=roi, roi_cal=roi_cal) # SPECTRUM CALCULATION -------------------------------------------------------------- @@ -745,7 +745,7 @@ def calculate_mean_spectrum( def show_mean_spectrum( self, roi=None, - roi_units=None, + roi_cal=None, energy_range=None, mask=None, intensity_range=None, @@ -801,11 +801,11 @@ def show_mean_spectrum( # CALCULATE MEAN SPECTRUM FOR GIVEN ROI AND ENERGY RANGE -------------------------- - y, x, dy, dx = self._resolve_roi(roi=roi, roi_units=roi_units) + y, x, dy, dx = self._resolve_roi(roi=roi, roi_cal=roi_cal) spec = self.calculate_mean_spectrum( roi=roi, - roi_units=roi_units, + roi_cal=roi_cal, energy_range=energy_range, mask=mask, ) From 795c87b9c9d16b38bf522193226b1b28a3f01819 Mon Sep 17 00:00:00 2001 From: Sangoda <87961379+Sangoda@users.noreply.github.com> Date: Mon, 20 Apr 2026 08:23:34 -0700 Subject: [PATCH 103/136] added docstrings to functions in dataset3deds.py --- src/quantem/spectroscopy/dataset3deds.py | 278 +++++++++++++++++++++-- 1 file changed, 261 insertions(+), 17 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index c4fb6100..0511212a 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -46,6 +46,7 @@ def __init__( signal_units: str = 'arb. units', _token: object | None = None, ): + """Initialize a 3D EDS dataset.""" super().__init__( array=array, name=name, @@ -59,6 +60,7 @@ def __init__( @staticmethod def _normalize_specs(specs, param_name='spec', allow_none=False): + """Parse specs into a flat list of stripped strings.""" if specs is None: if allow_none: return None @@ -71,14 +73,17 @@ def _normalize_specs(specs, param_name='spec', allow_none=False): @staticmethod def _normalize_token(text): + """Return a lowercase alphanumeric-only token for fuzzy matching.""" return re.sub(r'[^a-z0-9]', '', str(text).lower()) @staticmethod def _ordered_element_keys(all_info): + """Return element keys sorted longest-first for greedy prefix matching.""" return sorted(map(str, all_info), key=lambda k: (-len(k), k)) @classmethod def _resolve_element_from_label(cls, label, ordered_elements): + """Extract the element name from a line label like 'FeKa1'.""" label = str(label) for element in ordered_elements: if label.startswith(element): @@ -88,12 +93,14 @@ def _resolve_element_from_label(cls, label, ordered_elements): @classmethod def _ensure_element_info(cls): + """Load element X-ray line data if not already cached.""" if cls.element_info is None: cls.load_element_info() return cls.element_info or {} @classmethod def _parse_element_selectors(cls, specs, *, allow_none=False, param_name='spec'): + """Parse element/line specifiers into a dict of {element: set_of_suffixes | None}.""" tokens = cls._normalize_specs(specs, param_name=param_name, allow_none=allow_none) if tokens is None: return None @@ -115,10 +122,12 @@ def _parse_element_selectors(cls, specs, *, allow_none=False, param_name='spec') @staticmethod def _canonical_line_name(line_name: str) -> str: + """Strip any suffix after '__' from a line name.""" return str(line_name).split('__', 1)[0] @classmethod def _iter_selected_lines(cls, element: str, suffix: str, *, raw_spec: str): + """Yield (line_name, line_info) pairs matching an element and optional suffix.""" lines = cls._ensure_element_info().get(element) or {} if not lines: raise ValueError(f"No X-ray lines found for element '{element}'") @@ -141,6 +150,7 @@ def _iter_selected_lines(cls, element: str, suffix: str, *, raw_spec: str): @classmethod def _group_labels_by_element(cls, labels: list[str]): + """Group line labels by their parent element.""" ordered = cls._ordered_element_keys(cls._ensure_element_info()) grouped: dict[str, list[str]] = {} for lbl in sorted(map(str, labels)): @@ -151,6 +161,7 @@ def _group_labels_by_element(cls, labels: list[str]): @classmethod def _select_labels(cls, selector: str, *, labels: list[str], labels_by_element: dict[str, list[str]]): + """Return labels matching a selector string (exact, element, or prefix).""" selector = str(selector).strip() if not selector: return [] @@ -168,11 +179,13 @@ def _select_labels(cls, selector: str, *, labels: list[str], labels_by_element: @staticmethod def _line_shell(line_name: str) -> str: + """Return the shell letter ('K', 'L', 'M', or '?') for a line name.""" line_name = str(line_name).upper() return 'K' if line_name.startswith('K') else 'L' if line_name.startswith('L') else 'M' if line_name.startswith('M') else '?' @staticmethod def _peak_confidence(snr_value: float, line_weight: float, distance_value: float, tolerance: float) -> float: + """Compute a confidence score for a peak-to-line match.""" sigma = max(float(tolerance) / 3.0, 1e-9) return np.log1p(max(float(snr_value), 0.0)) * max(float(line_weight), 0.0) * np.exp( -0.5 * (float(distance_value) / sigma) ** 2 @@ -180,16 +193,19 @@ def _peak_confidence(snr_value: float, line_weight: float, distance_value: float @staticmethod def _line_matches_selector(line_name: str, selector: str) -> bool: + """Check whether a line name matches a shell or substring selector.""" line = str(line_name).strip().lower() selector = str(selector).strip().lower() return line.startswith(selector) if selector in {'k', 'l', 'm'} else selector in line @classmethod def _line_allowed_for_element(cls, element_name: str, line_name: str, edge_filters=None) -> bool: + """Return True if the line passes the edge filter for its element.""" selectors = None if edge_filters is None else edge_filters.get(str(element_name)) return selectors is None or any(cls._line_matches_selector(line_name, token) for token in selectors) def _get_spectrum_images(self, method='integration'): + """Retrieve cached spectrum images for the given method.""" return { 'integration': getattr(self, '_spectrum_images', None), 'fit': getattr(self, '_spectrum_images_pytorch', None), @@ -197,10 +213,12 @@ def _get_spectrum_images(self, method='integration'): @staticmethod def _shell_preference_factor(shell_name: str) -> float: + """Return a down-weighting factor for M-shell lines.""" return 0.72 if shell_name == 'M' else 1.0 @staticmethod def _merge_edge_filters(requested, saved): + """Merge requested and saved edge filters, unioning selectors per element.""" if requested and saved: merged = dict(saved) for element, selectors in requested.items(): @@ -211,6 +229,7 @@ def _merge_edge_filters(requested, saved): @staticmethod def _estimate_snr_thresholds(snr_values, peaks, snr_min=None, snr_threshold=None): + """Auto-estimate snr_min and snr_threshold from peak SNR distribution.""" snr_values = np.asarray(snr_values, dtype=float) snr_values = snr_values[np.isfinite(snr_values)] @@ -242,6 +261,30 @@ def _estimate_snr_thresholds(snr_values, peaks, snr_min=None, snr_threshold=None return snr_min, snr_threshold def x_ray_lookup(self, spec: str | list[str] | tuple[str, ...] | set[str]) -> tuple[np.ndarray, np.ndarray, list[str]]: + """Look up X-ray line energies, weights, and labels. + + Parameters + ---------- + spec : str | sequence[str] + One or more element/line specifiers. Accepted formats include + element names (``'Fe'``), element + shell (``'Fe K'``), and + element + line (``'Fe Ka1'``). Comma-separated strings are + split automatically. + + Returns + ------- + energies : ndarray + 1-D array of line energies in keV, sorted by energy. + weights : ndarray + Corresponding tabulated line weights (0--1). + labels : list[str] + Human-readable labels such as ``'FeKa1'``. + + Raises + ------ + ValueError + If no lines match the specifier(s). + """ info = type(self)._ensure_element_info() ordered = type(self)._ordered_element_keys(info) specs = type(self)._normalize_specs(spec, param_name='spec') @@ -282,6 +325,30 @@ def x_ray_lookup(self, spec: str | list[str] | tuple[str, ...] | set[str]) -> tu ) def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False, show=True): + """Generate spectrum images by integrating around X-ray line energies. + + For each matched X-ray line, sums the spectral intensity within an + energy window of ``line_energy +/- width`` at every spatial pixel. + Results are cached in ``self._spectrum_images`` for later use by + :meth:`show_spectrum_images` and :meth:`quantify_composition_cliff_lorimer`. + + Parameters + ---------- + elements : str | sequence[str] | None, optional + Element/line specifiers (see :meth:`x_ray_lookup`). If ``None``, + uses ``self.model_elements``. + width : float, optional + Half-width of the integration window in keV. + return_maps : bool, optional + If ``True``, return ``(maps, labels)``. + show : bool, optional + If ``True``, display the generated maps. + + Returns + ------- + tuple[ndarray, list[str]] | None + Only returned when *return_maps* is ``True``. + """ if elements is None: if self.model_elements is None: raise ValueError('elements must be specified') @@ -303,6 +370,31 @@ def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False, return maps, labels def Integrate(self, spec, width=0.15, return_maps=False, show=True, **kwargs): + """Integrate the spectrum around specified X-ray lines. + + Sums spectral intensity within ``line_energy +/- width`` for each + selector. By default, displays the resulting map(s). + + Parameters + ---------- + spec : str | sequence[str] + Element/line specifiers (see :meth:`x_ray_lookup`), e.g. + ``'Fe Ka'`` or ``['Cu', 'Zn']``. + width : float, optional + Half-width of the integration window in keV. + return_maps : bool, optional + If ``True``, return the integrated maps. + show : bool, optional + If ``True``, display the maps. + **kwargs + Forwarded to the plotting function (e.g. ``cmap``, ``roi``). + + Returns + ------- + ndarray | dict[str, ndarray] + Single map when one selector is given, otherwise a dict keyed by + selector string. + """ width = float(width) specs = type(self)._normalize_specs(spec, param_name='spec') arr = np.asarray(self.array, dtype=float) @@ -349,9 +441,35 @@ def Integrate(self, spec, width=0.15, return_maps=False, show=True, **kwargs): return integrated_maps if return_maps or len(integrated_maps) != 1 else next(iter(integrated_maps.values())) def integrate(self, spec, width=0.15, return_maps=False, show=True, **kwargs): + """Convenience wrapper for Integrate.""" return self.Integrate(spec=spec, width=width, return_maps=return_maps, show=show, **kwargs) def show_spectrum_images(self, x_ray_lines=None, return_fig=False, method='integration', **kwargs): + """Display cached spectrum images. + + Parameters + ---------- + x_ray_lines : str | sequence[str] | None, optional + Selectors to filter which images are shown. If ``None``, one + panel per element is displayed. + return_fig : bool, optional + If ``True``, return ``(fig, ax)``. + method : {"integration", "fit"}, optional + Which cache to read from: integration-based maps or PyTorch + fit-based maps. + **kwargs + Forwarded to :func:`show_2d` (e.g. ``cmap``). + + Returns + ------- + tuple[Figure, Axes] | None + Only returned when *return_fig* is ``True``. + + Raises + ------ + ValueError + If no cached spectrum images exist for the chosen *method*. + """ spectrum_images = self._get_spectrum_images(method) if not spectrum_images: raise ValueError('No spectrum images found. Run generage_spectrum_images(...) first.') @@ -387,6 +505,7 @@ def sum_maps(lbls): return fig, ax def _build_pytorch_spectrum_images(self, abundance_maps: np.ndarray, element_names: list[str] | tuple[str, ...]) -> dict[str, np.ndarray]: + """Convert per-element abundance maps into per-line spectrum images using weights.""" maps = np.asarray(abundance_maps) if maps.ndim != 3: return {} @@ -405,6 +524,36 @@ def _build_pytorch_spectrum_images(self, abundance_maps: np.ndarray, element_nam return line_maps def quantify_composition_cliff_lorimer(self, k_factors, method='integration', return_maps=False, verbose=True): + """Quantify elemental composition using the Cliff-Lorimer thin-film method. + + Parameters + ---------- + k_factors : dict[str, float] + Mapping of element/line selectors to their k-factors, e.g. + ``{'Fe K': 1.0, 'Cu K': 1.45}``. At least two elements are + required. + method : {"integration", "fit"}, optional + Which cached spectrum images to use for intensity extraction. + return_maps : bool, optional + If ``True``, include per-pixel atomic-percent and weight-percent + maps in the returned dict. + verbose : bool, optional + If ``True``, print the quantification summary table. + + Returns + ------- + dict + Keys include ``atomic_percent``, ``weight_percent``, + ``intensities``, ``weighted_intensities``, and + ``summary_table``. When *return_maps* is ``True``, also + includes ``atomic_percent_maps`` and ``weight_percent_maps``. + + Raises + ------ + ValueError + If *k_factors* is empty, fewer than two elements are matched, or + spectrum images are missing. + """ if not k_factors: raise ValueError('k_factors must be a non-empty dict') spectrum_images = self._get_spectrum_images(method) @@ -508,9 +657,11 @@ def match(selector: str) -> list[str]: return result def clear_spectrum_images(self): + """Clear cached integration-based spectrum images.""" self._spectrum_images = {} def clear_spectrum_images_pytorch(self): + """Clear cached PyTorch fit-based spectrum images.""" self._spectrum_images_pytorch = {} def peak_autoid( @@ -535,6 +686,80 @@ def peak_autoid( mode=None, return_details=False, ): + """Automatically identify elements from EDS peaks in the mean spectrum. + + Finds peaks in the spatially-averaged spectrum, matches them against a + database of known X-ray line energies, and classifies elements as + *detected* (high confidence) or *possible* (lower confidence). Results + are printed and overlaid on an interactive spectrum plot. + + Parameters + ---------- + roi : sequence[int] | None, optional + Pixel-coordinate ROI ``[y0, y1, x0, x1]`` used when computing the + mean spectrum. If ``None``, the full spatial extent is used. + roi_cal : sequence[float] | None, optional + Calibrated-coordinate ROI (same layout as *roi* but in physical + units). + energy_range : sequence[float] | None, optional + Two-element energy window ``[emin, emax]`` in keV. Peaks outside + this range are ignored. + elements : str | sequence[str] | None, optional + Element or element-line specifiers to search for, e.g. + ``'Fe'``, ``'Fe Ka'``, or ``['Cu', 'Zn K']``. When provided, + behaviour depends on *mode*. + refline : str | None, optional + Reserved for future use. + ignore_elements : str | sequence[str] | None, optional + Elements to exclude from autodetection. + ignore_range : sequence[float] | None, optional + Energy range ``[emin, emax]`` whose peaks are ignored. Defaults to + ``[0, 0.25]`` keV to skip the noise floor. + threshold : float, optional + Legacy parameter (currently unused). SNR filtering is controlled + by *snr_min* and *snr_threshold*. + tolerance : float, optional + Maximum energy difference in keV between a detected peak and a + tabulated X-ray line for them to be considered a match. + M-shell minor lines use ``tolerance * 0.5``. + min_line_weight : float, optional + Minimum tabulated line weight (0--1) for a line to be considered. + mask : ndarray | None, optional + Boolean spatial mask; only pixels where ``mask`` is ``True`` + contribute to the mean spectrum. + show_text : bool, optional + If ``True``, annotate matched peaks on the plot. + snr_min : float | None, optional + Minimum signal-to-noise ratio for a peak to be displayed. If + ``None``, estimated automatically from the SNR distribution. + snr_threshold : float | None, optional + SNR above which a peak match counts as "strong" evidence for an + element. If ``None``, estimated automatically. + distance_threshold_for_sample : float, optional + Maximum energy distance (keV) for a match to qualify as a strong + match (used together with *snr_threshold*). + grid_peaks : dict | None, optional + Mapping of ``{label: energy}`` for known grid/artifact peaks that + should be flagged in the output. + peaks : int, optional + Maximum number of peaks to display. + mode : {"elements_only", "elements_preferred", "autofill"} | None, optional + Search strategy. ``"elements_only"`` restricts matching to + *elements*; ``"elements_preferred"`` boosts them but allows others; + ``"autofill"`` (default when *elements* is ``None``) searches all + elements. + return_details : bool, optional + If ``True``, return a dict with detection details instead of the + figure. + + Returns + ------- + tuple[Figure, tuple[Axes, Axes]] | dict + By default returns ``(fig, (ax_img, ax_spec))``. When + *return_details* is ``True``, returns a dict containing + ``detected_elements``, ``element_confidence``, ``display_peaks``, + ``peak_matches``, ``snr_min``, ``snr_threshold``, and the figure. + """ type(self)._ensure_element_info() all_info = type(self).element_info or {} grid_peaks = grid_peaks or {} @@ -1211,6 +1436,7 @@ def _fit_mean_model_pytorch( default_lr_lbfgs, verbose=False, ): + """Fit a single mean spectrum using the PyTorch EDS model.""" target = spectrum_raw spectrum_offset = torch.tensor(0.0, dtype=spectrum_raw.dtype, device=spectrum_raw.device) spectrum_scale = torch.tensor(1.0, dtype=spectrum_raw.dtype, device=spectrum_raw.device) @@ -1301,6 +1527,40 @@ def fit_spectrum_mean_pytorch( optimizer="lbfgs", device=None, ): + """Fit the spatially-summed mean EDS spectrum and display results. + + A convenience wrapper around :meth:`_fit_mean_model_pytorch` that + handles device selection, energy windowing, and result visualization. + + Parameters + ---------- + energy_range : sequence[float] | None, optional + Two-element energy interval ``[emin, emax]`` in keV. If ``None``, + the full energy axis is used. + elements_to_fit : sequence[str] | None, optional + Element symbols to include in the fit. If ``None``, uses keys + from ``self.model_elements``. + peak_width : float, optional + Initial FWHM-like peak width in keV. + num_iters : int, optional + Number of optimization iterations. + lr : float | None, optional + Learning rate. If ``None``, an optimizer-specific default is used. + polynomial_background_degree : int, optional + Degree of the polynomial background basis. + optimizer : {"adam", "lbfgs"}, optional + Optimizer to use. + device : str | torch.device | None, optional + Torch device. If ``None``, uses CUDA when available. + + Returns + ------- + dict + Keys include ``loss_history``, ``fitted_spectrum``, + ``input_spectrum``, ``background_spectrum``, ``concentrations``, + ``element_names``, ``peak_widths``, ``energy_axis``, and + ``fit_range``. + """ optimizer_name = str(optimizer).lower() if optimizer_name not in {"adam", "lbfgs"}: raise ValueError("optimizer must be 'lbfgs' or 'adam'") @@ -2021,24 +2281,8 @@ def _local_closure(): } def calculate_background_powerlaw(self, spectrum): + """Estimate a power-law Bremsstrahlung background from the spectrum.""" import numpy as np - - """ - From input spectrum, calculate power-law background typical for EDS Bremsstrahlung. - Uses a conservative approach with heavy smoothing to avoid creating artifacts. - - Parameters - ---------- - spectrum : ndarray - 1D spectrum - energy_axis : ndarray - Energy axis corresponding to spectrum - - Returns - ------- - ndarray - 1D array representing the calculated background - """ from scipy.ndimage import gaussian_filter # Use a larger window for more conservative background estimation From f7bf217103eb7574355ba7a95487cd1bb07e107f Mon Sep 17 00:00:00 2001 From: Sangoda <87961379+Sangoda@users.noreply.github.com> Date: Mon, 20 Apr 2026 09:00:28 -0700 Subject: [PATCH 104/136] auto_id labeling changes --- src/quantem/spectroscopy/dataset3deds.py | 42 ++++++------------- .../spectroscopy/dataset3dspectroscopy.py | 1 + 2 files changed, 13 insertions(+), 30 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 0511212a..1fda8fcc 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -1166,11 +1166,12 @@ def format_saved(edge_filters): out.append(f'{element} [all]' if not selectors else f"{element} [{', '.join(sorted(map(str, selectors)))}]") return '\n'.join(out) if out else 'None' - print(f"\nAutodetected: {format_elements_with_lines(detected_elements) if detected_elements else 'None'}") - if dominant_elements: - dominant_str = ', '.join(f"{el} (conf={element_confidence.get(str(el), 0.0):.2f})" for el in sorted(dominant_elements, key=lambda el: element_confidence.get(str(el), 0.0), reverse=True)) - print(f'Dominant (strong prior): {dominant_str}') - print(f"Possible: {format_elements_with_lines(candidate_elements) if candidate_elements else 'None'}") + all_identified = set(detected_elements) | set(candidate_elements) + print(f"\nDetected: {format_elements_with_lines(all_identified) if all_identified else 'None'}") + visible_dominant = dominant_elements & all_identified + if visible_dominant: + dominant_str = ', '.join(f"{el} (conf={element_confidence.get(str(el), 0.0):.2f})" for el in sorted(visible_dominant, key=lambda el: element_confidence.get(str(el), 0.0), reverse=True)) + print(f'High confidence: {dominant_str}') elements_for_color = set(detected_elements) | {str(match[4]) for match in peak_matches} if search_elements is not None: @@ -1241,23 +1242,10 @@ def label_with_energy_and_ratio(label, detected_peak_intensity=None): ref_energy = float(ref_energy) except (TypeError, ValueError): ref_energy = None - # Compute observed/expected ratio: use detected peak intensity / expected weight - ratio_str = '' - weight = line_info.get('weight', None) - try: - weight = float(weight) if weight is not None else 0.0 - except Exception: - weight = 0.0 - if detected_peak_intensity is not None and weight: - try: - ratio = float(detected_peak_intensity) / float(weight) - ratio_str = f", {ratio:.2f}" - except Exception: - ratio_str = '' label_core = label.rstrip('*') star = '*' if label.endswith('*') else '' if ref_energy is not None: - return f"{label_core} ({ref_energy:.3f}{ratio_str}){star}" + return f"{label_core} ({ref_energy:.3f}){star}" else: return label @@ -1271,7 +1259,7 @@ def label_with_energy_and_ratio(label, detected_peak_intensity=None): best_label = f'{match[4]} {match[7]}' def fmt(label, score=None): - label = f'{label}*' if str(label).split()[0] in detected_elements else label + label = f'{label}*' if requested_elements and str(label).split()[0] in requested_elements else label return label # Gather all intensities for this element for ratio calculation @@ -1346,21 +1334,15 @@ def get_peak_intensity(elem, line): if show_text and peak_matches: label_offset = max(0.03 * y_scale, 0.08) - # Only label autodetected elements and explicitly requested elements - label_allowed = set(detected_elements) + label_allowed = set(detected_elements) | possible_elements if requested_elements: label_allowed.update(str(el) for el in requested_elements) for _, height, peak_energy, _, element, match_str, _, _, _, _ in peak_matches: - is_detected = element in detected_elements and float(peak_energy) in detected_sample_peaks + is_requested = requested_elements is not None and element in requested_elements if element not in label_allowed or in_ignore(peak_energy): continue - if not is_detected and element not in (requested_elements or set()): - continue - label = f"{element} {match_str.split()[-1]}" + ('*' if is_detected else '') - style = '-' if is_detected else '--' - y_value = float(height + label_offset) if is_detected else float(top_label_y) - y_mode = 'data' if is_detected else 'axes_top' - label_candidates.append((float(peak_energy), label, element_color_map.get(element, 'black'), style, y_value, y_mode, 10 if is_detected else 9, 'bold' if is_detected else 'normal', 1.0 if is_detected else 0.9)) + label = f"{element} {match_str.split()[-1]}" + ('*' if is_requested else '') + label_candidates.append((float(peak_energy), label, element_color_map.get(element, 'black'), '-', float(height + label_offset), 'data', 10, 'bold', 1.0)) legend_handles, legend_labels = [], set() if show_text and label_candidates: diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 5d9b2eb3..e7e891e1 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -959,6 +959,7 @@ def refline( linestyle=linestyle, linewidth=linewidth, alpha=alpha, + label=label, ) artists.append(line_artist) labels.append(label) From 9b9f01997e05361c8546a58523d69272395f755c Mon Sep 17 00:00:00 2001 From: smribet Date: Mon, 20 Apr 2026 12:06:11 -0700 Subject: [PATCH 105/136] bug fix --- src/quantem/spectroscopy/dataset3dspectroscopy.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index e7e891e1..c53506cf 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -984,7 +984,7 @@ def show_energy_window_map( self, energy_window=None, roi=None, - roi_units=None, + roi_cal=None, mask=None, data_type="eds", cmap="viridis", @@ -1019,8 +1019,8 @@ def show_energy_window_map( tuple ``(fig, (ax_map, ax_spec), energy_map)`` where ``energy_map`` is the integrated 2D array. """ - y, x, dy, dx = self._resolve_roi(roi=roi, roi_units=roi_units) - has_roi_overlay = any(val is not None for val in (roi, roi_units)) + y, x, dy, dx = self._resolve_roi(roi=roi, roi_cal=roi_cal) + has_roi_overlay = any(val is not None for val in (roi, roi_cal)) dE = float(self.sampling[0]) E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 @@ -1057,7 +1057,7 @@ def show_energy_window_map( spec = self.calculate_mean_spectrum( roi=roi, - roi_units=roi_units, + roi_cal=roi_cal, mask=mask, attach_mean_spectrum=False, ) From 572044c00eb8f74b2c3b170d1bbbab3095973b04 Mon Sep 17 00:00:00 2001 From: smribet Date: Mon, 20 Apr 2026 12:20:21 -0700 Subject: [PATCH 106/136] update return map logic --- src/quantem/spectroscopy/dataset3deds.py | 1008 +++++++++++++++------- 1 file changed, 691 insertions(+), 317 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 1fda8fcc..c7560eab 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -43,7 +43,7 @@ def __init__( origin: NDArray | tuple | list | float | int, sampling: NDArray | tuple | list | float | int, units: list[str] | tuple | list, - signal_units: str = 'arb. units', + signal_units: str = "arb. units", _token: object | None = None, ): """Initialize a 3D EDS dataset.""" @@ -56,25 +56,25 @@ def __init__( signal_units=signal_units, _token=_token, ) - self.dataset_type = 'eds' + self.dataset_type = "eds" @staticmethod - def _normalize_specs(specs, param_name='spec', allow_none=False): + def _normalize_specs(specs, param_name="spec", allow_none=False): """Parse specs into a flat list of stripped strings.""" if specs is None: if allow_none: return None - raise TypeError(f'{param_name} must be a string or sequence of strings') + raise TypeError(f"{param_name} must be a string or sequence of strings") if isinstance(specs, str): - return [s.strip() for s in specs.split(',') if s.strip()] + return [s.strip() for s in specs.split(",") if s.strip()] if isinstance(specs, (list, tuple, set)): - return [s.strip() for item in specs for s in str(item).split(',') if s.strip()] - raise TypeError(f'{param_name} must be a string or sequence of strings') + return [s.strip() for item in specs for s in str(item).split(",") if s.strip()] + raise TypeError(f"{param_name} must be a string or sequence of strings") @staticmethod def _normalize_token(text): """Return a lowercase alphanumeric-only token for fuzzy matching.""" - return re.sub(r'[^a-z0-9]', '', str(text).lower()) + return re.sub(r"[^a-z0-9]", "", str(text).lower()) @staticmethod def _ordered_element_keys(all_info): @@ -88,7 +88,7 @@ def _resolve_element_from_label(cls, label, ordered_elements): for element in ordered_elements: if label.startswith(element): return element - m = re.match(r'^[A-Z][a-z]?', label) + m = re.match(r"^[A-Z][a-z]?", label) return m.group(0) if m else None @classmethod @@ -99,7 +99,7 @@ def _ensure_element_info(cls): return cls.element_info or {} @classmethod - def _parse_element_selectors(cls, specs, *, allow_none=False, param_name='spec'): + def _parse_element_selectors(cls, specs, *, allow_none=False, param_name="spec"): """Parse element/line specifiers into a dict of {element: set_of_suffixes | None}.""" tokens = cls._normalize_specs(specs, param_name=param_name, allow_none=allow_none) if tokens is None: @@ -108,13 +108,13 @@ def _parse_element_selectors(cls, specs, *, allow_none=False, param_name='spec') ordered = cls._ordered_element_keys(cls._ensure_element_info()) out: dict[str, set[str] | None] = {} for raw in tokens: - compact = re.sub(r'[\s_-]+', '', str(raw).strip()) + compact = re.sub(r"[\s_-]+", "", str(raw).strip()) if not compact: continue element = next((k for k in ordered if compact.lower().startswith(k.lower())), None) if element is None: raise ValueError(f"Could not resolve element from specifier '{raw}'") - suffix = compact[len(element):] + suffix = compact[len(element) :] out.setdefault(element, None if not suffix else set()) if suffix and out[element] is not None: out[element].add(suffix) @@ -123,7 +123,7 @@ def _parse_element_selectors(cls, specs, *, allow_none=False, param_name='spec') @staticmethod def _canonical_line_name(line_name: str) -> str: """Strip any suffix after '__' from a line name.""" - return str(line_name).split('__', 1)[0] + return str(line_name).split("__", 1)[0] @classmethod def _iter_selected_lines(cls, element: str, suffix: str, *, raw_spec: str): @@ -145,7 +145,9 @@ def _iter_selected_lines(cls, element: str, suffix: str, *, raw_spec: str): prefix.append((line_name, line_info)) matches = exact or prefix if not matches: - raise ValueError(f"No X-ray lines matched specifier '{raw_spec}' for element '{element}'") + raise ValueError( + f"No X-ray lines matched specifier '{raw_spec}' for element '{element}'" + ) yield from matches @classmethod @@ -160,7 +162,9 @@ def _group_labels_by_element(cls, labels: list[str]): return grouped @classmethod - def _select_labels(cls, selector: str, *, labels: list[str], labels_by_element: dict[str, list[str]]): + def _select_labels( + cls, selector: str, *, labels: list[str], labels_by_element: dict[str, list[str]] + ): """Return labels matching a selector string (exact, element, or prefix).""" selector = str(selector).strip() if not selector: @@ -181,14 +185,26 @@ def _select_labels(cls, selector: str, *, labels: list[str], labels_by_element: def _line_shell(line_name: str) -> str: """Return the shell letter ('K', 'L', 'M', or '?') for a line name.""" line_name = str(line_name).upper() - return 'K' if line_name.startswith('K') else 'L' if line_name.startswith('L') else 'M' if line_name.startswith('M') else '?' + return ( + "K" + if line_name.startswith("K") + else "L" + if line_name.startswith("L") + else "M" + if line_name.startswith("M") + else "?" + ) @staticmethod - def _peak_confidence(snr_value: float, line_weight: float, distance_value: float, tolerance: float) -> float: + def _peak_confidence( + snr_value: float, line_weight: float, distance_value: float, tolerance: float + ) -> float: """Compute a confidence score for a peak-to-line match.""" sigma = max(float(tolerance) / 3.0, 1e-9) - return np.log1p(max(float(snr_value), 0.0)) * max(float(line_weight), 0.0) * np.exp( - -0.5 * (float(distance_value) / sigma) ** 2 + return ( + np.log1p(max(float(snr_value), 0.0)) + * max(float(line_weight), 0.0) + * np.exp(-0.5 * (float(distance_value) / sigma) ** 2) ) @staticmethod @@ -196,25 +212,29 @@ def _line_matches_selector(line_name: str, selector: str) -> bool: """Check whether a line name matches a shell or substring selector.""" line = str(line_name).strip().lower() selector = str(selector).strip().lower() - return line.startswith(selector) if selector in {'k', 'l', 'm'} else selector in line + return line.startswith(selector) if selector in {"k", "l", "m"} else selector in line @classmethod - def _line_allowed_for_element(cls, element_name: str, line_name: str, edge_filters=None) -> bool: + def _line_allowed_for_element( + cls, element_name: str, line_name: str, edge_filters=None + ) -> bool: """Return True if the line passes the edge filter for its element.""" selectors = None if edge_filters is None else edge_filters.get(str(element_name)) - return selectors is None or any(cls._line_matches_selector(line_name, token) for token in selectors) + return selectors is None or any( + cls._line_matches_selector(line_name, token) for token in selectors + ) - def _get_spectrum_images(self, method='integration'): + def _get_spectrum_images(self, method="integration"): """Retrieve cached spectrum images for the given method.""" return { - 'integration': getattr(self, '_spectrum_images', None), - 'fit': getattr(self, '_spectrum_images_pytorch', None), + "integration": getattr(self, "_spectrum_images", None), + "fit": getattr(self, "_spectrum_images_pytorch", None), }.get(method) @staticmethod def _shell_preference_factor(shell_name: str) -> float: """Return a down-weighting factor for M-shell lines.""" - return 0.72 if shell_name == 'M' else 1.0 + return 0.72 if shell_name == "M" else 1.0 @staticmethod def _merge_edge_filters(requested, saved): @@ -223,7 +243,9 @@ def _merge_edge_filters(requested, saved): merged = dict(saved) for element, selectors in requested.items(): current = merged.get(element) - merged[element] = None if current is None or selectors is None else set(current).union(selectors) + merged[element] = ( + None if current is None or selectors is None else set(current).union(selectors) + ) return merged return requested or saved @@ -239,7 +261,9 @@ def _estimate_snr_thresholds(snr_values, peaks, snr_min=None, snr_threshold=None target_rank = min(sorted_snrs.size, int(np.clip(2 * int(peaks), 12, 64))) rank_cutoff = float(sorted_snrs[-target_rank]) q30, q40, q50 = np.percentile(sorted_snrs, [30, 40, 50]) - snr_min = float(np.clip(min(q50, max(q30, 0.35 * rank_cutoff, 0.9 * q40)), 7.0, 14.0)) + snr_min = float( + np.clip(min(q50, max(q30, 0.35 * rank_cutoff, 0.9 * q40)), 7.0, 14.0) + ) else: snr_min = 8.0 else: @@ -252,7 +276,9 @@ def _estimate_snr_thresholds(snr_values, peaks, snr_min=None, snr_threshold=None high = np.sort(high)[::-1] anchor = high[: min(high.size, int(np.clip(int(peaks), 10, 40)))] med, q75, q90 = np.percentile(anchor, [50, 75, 90]) - snr_threshold = float(np.clip(max(med, 0.7 * q75, 2.5 * snr_min), max(2.5 * snr_min, snr_min), q90)) + snr_threshold = float( + np.clip(max(med, 0.7 * q75, 2.5 * snr_min), max(2.5 * snr_min, snr_min), q90) + ) else: snr_threshold = max(4.0 * snr_min, 30.0) else: @@ -260,7 +286,9 @@ def _estimate_snr_thresholds(snr_values, peaks, snr_min=None, snr_threshold=None return snr_min, snr_threshold - def x_ray_lookup(self, spec: str | list[str] | tuple[str, ...] | set[str]) -> tuple[np.ndarray, np.ndarray, list[str]]: + def x_ray_lookup( + self, spec: str | list[str] | tuple[str, ...] | set[str] + ) -> tuple[np.ndarray, np.ndarray, list[str]]: """Look up X-ray line energies, weights, and labels. Parameters @@ -287,32 +315,36 @@ def x_ray_lookup(self, spec: str | list[str] | tuple[str, ...] | set[str]) -> tu """ info = type(self)._ensure_element_info() ordered = type(self)._ordered_element_keys(info) - specs = type(self)._normalize_specs(spec, param_name='spec') + specs = type(self)._normalize_specs(spec, param_name="spec") rows: list[tuple[str, float, float]] = [] for raw in specs: - compact = re.sub(r'[\s_-]+', '', str(raw).strip()) + compact = re.sub(r"[\s_-]+", "", str(raw).strip()) if not compact: continue element = next((k for k in ordered if compact.lower().startswith(k.lower())), None) if element is None: raise ValueError(f"Could not resolve element from specifier '{raw}'") - suffix = compact[len(element):] - for line_name, line_info in type(self)._iter_selected_lines(element, suffix, raw_spec=str(raw)): + suffix = compact[len(element) :] + for line_name, line_info in type(self)._iter_selected_lines( + element, suffix, raw_spec=str(raw) + ): if not isinstance(line_info, dict): continue try: - energy = float(line_info.get('energy (keV)', line_info.get('energy'))) + energy = float(line_info.get("energy (keV)", line_info.get("energy"))) except (TypeError, ValueError): continue try: - weight = float(line_info.get('weight', 0.0)) + weight = float(line_info.get("weight", 0.0)) except (TypeError, ValueError): weight = 0.0 - rows.append((f'{element}{type(self)._canonical_line_name(line_name)}', energy, weight)) + rows.append( + (f"{element}{type(self)._canonical_line_name(line_name)}", energy, weight) + ) if not rows: - raise ValueError(f'No X-ray lines matched specifier(s): {specs}') + raise ValueError(f"No X-ray lines matched specifier(s): {specs}") unique = sorted( {(lbl, round(float(e), 12), round(float(w), 12)) for lbl, e, w in rows}, @@ -324,7 +356,7 @@ def x_ray_lookup(self, spec: str | list[str] | tuple[str, ...] | set[str]) -> tu [lbl for lbl, _, _ in unique], ) - def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False, show=True): + def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False): """Generate spectrum images by integrating around X-ray line energies. For each matched X-ray line, sums the spectral intensity within an @@ -351,7 +383,7 @@ def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False, """ if elements is None: if self.model_elements is None: - raise ValueError('elements must be specified') + raise ValueError("elements must be specified") elements = list(self.model_elements) energies, _, labels = self.x_ray_lookup(elements) @@ -359,15 +391,23 @@ def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False, energies = energies[keep] labels = [label for label, ok in zip(labels, keep) if ok] - mask = (self.energy_axis[:, None] > energies[None, :] - width) & (self.energy_axis[:, None] < energies[None, :] + width) + mask = (self.energy_axis[:, None] > energies[None, :] - width) & ( + self.energy_axis[:, None] < energies[None, :] + width + ) n, h, w = self.array.shape - maps = (mask.astype(self.array.dtype).T @ self.array.reshape(n, -1)).reshape(mask.shape[1], h, w) + maps = (mask.astype(self.array.dtype).T @ self.array.reshape(n, -1)).reshape( + mask.shape[1], h, w + ) + + self._spectrum_images = { + **getattr(self, "_spectrum_images", {}), + **dict(zip(labels, maps)), + } + + images, titles = self.show_spectrum_images(x_ray_lines=elements, return_maps=True) - self._spectrum_images = {**getattr(self, '_spectrum_images', {}), **dict(zip(labels, maps))} - if show: - self.show_spectrum_images(x_ray_lines=elements) if return_maps: - return maps, labels + return images, titles def Integrate(self, spec, width=0.15, return_maps=False, show=True, **kwargs): """Integrate the spectrum around specified X-ray lines. @@ -396,7 +436,7 @@ def Integrate(self, spec, width=0.15, return_maps=False, show=True, **kwargs): selector string. """ width = float(width) - specs = type(self)._normalize_specs(spec, param_name='spec') + specs = type(self)._normalize_specs(spec, param_name="spec") arr = np.asarray(self.array, dtype=float) energy_axis = np.asarray(self.energy_axis, dtype=float) energy_min, energy_max = float(energy_axis.min()), float(energy_axis.max()) @@ -404,9 +444,13 @@ def Integrate(self, spec, width=0.15, return_maps=False, show=True, **kwargs): selector_masks, integrated_maps = {}, {} for selector in map(str, specs): line_energies, _, _ = self.x_ray_lookup(selector.strip()) - line_energies = line_energies[(line_energies >= energy_min) & (line_energies <= energy_max)] + line_energies = line_energies[ + (line_energies >= energy_min) & (line_energies <= energy_max) + ] if not len(line_energies): - raise ValueError(f"No X-ray lines for selector '{selector}' are within the dataset energy range") + raise ValueError( + f"No X-ray lines for selector '{selector}' are within the dataset energy range" + ) mask = np.any( (energy_axis[:, None] >= line_energies[None, :] - width) @@ -417,15 +461,15 @@ def Integrate(self, spec, width=0.15, return_maps=False, show=True, **kwargs): integrated_maps[selector] = arr[mask].sum(axis=0) if show: - cmap = kwargs.pop('cmap', 'magma') + cmap = kwargs.pop("cmap", "magma") if len(integrated_maps) == 1: selector = next(iter(integrated_maps)) self.show_energy_window_map( energy_window=[energy_min, energy_max], - roi=kwargs.pop('roi', None), - roi_cal=kwargs.pop('roi_cal', None), + roi=kwargs.pop("roi", None), + roi_cal=kwargs.pop("roi_cal", None), mask=selector_masks[selector], - data_type=kwargs.pop('data_type', 'eds'), + data_type=kwargs.pop("data_type", "eds"), cmap=cmap, show=True, ) @@ -434,17 +478,23 @@ def Integrate(self, spec, width=0.15, return_maps=False, show=True, **kwargs): list(integrated_maps.values()), title=list(integrated_maps), cmap=cmap, - scalebar={'sampling': self.sampling[1], 'units': self.units[1]}, + scalebar={"sampling": self.sampling[1], "units": self.units[1]}, **kwargs, ) - return integrated_maps if return_maps or len(integrated_maps) != 1 else next(iter(integrated_maps.values())) + return ( + integrated_maps + if return_maps or len(integrated_maps) != 1 + else next(iter(integrated_maps.values())) + ) def integrate(self, spec, width=0.15, return_maps=False, show=True, **kwargs): """Convenience wrapper for Integrate.""" return self.Integrate(spec=spec, width=width, return_maps=return_maps, show=show, **kwargs) - def show_spectrum_images(self, x_ray_lines=None, return_fig=False, method='integration', **kwargs): + def show_spectrum_images( + self, x_ray_lines=None, method="integration", return_fig=False, return_maps=False, **kwargs + ): """Display cached spectrum images. Parameters @@ -452,11 +502,13 @@ def show_spectrum_images(self, x_ray_lines=None, return_fig=False, method='integ x_ray_lines : str | sequence[str] | None, optional Selectors to filter which images are shown. If ``None``, one panel per element is displayed. - return_fig : bool, optional - If ``True``, return ``(fig, ax)``. method : {"integration", "fit"}, optional Which cache to read from: integration-based maps or PyTorch fit-based maps. + return_fig : bool, optional + If ``True``, return ``(fig, ax)``. + return_maps: bool, optional + If ``True``, return plotted images **kwargs Forwarded to :func:`show_2d` (e.g. ``cmap``). @@ -472,7 +524,7 @@ def show_spectrum_images(self, x_ray_lines=None, return_fig=False, method='integ """ spectrum_images = self._get_spectrum_images(method) if not spectrum_images: - raise ValueError('No spectrum images found. Run generage_spectrum_images(...) first.') + raise ValueError("No spectrum images found. Run generage_spectrum_images(...) first.") line_map = {str(k): np.asarray(v) for k, v in spectrum_images.items()} labels = list(line_map) @@ -481,12 +533,17 @@ def show_spectrum_images(self, x_ray_lines=None, return_fig=False, method='integ def sum_maps(lbls): return np.sum([line_map[lbl] for lbl in lbls], axis=0) - specs = type(self)._normalize_specs(x_ray_lines, param_name='x_ray_lines', allow_none=True) + specs = type(self)._normalize_specs(x_ray_lines, param_name="x_ray_lines", allow_none=True) if not specs: titles = sorted(labels_by_element) images = [sum_maps(labels_by_element[t]) for t in titles] else: - selected = [type(self)._select_labels(str(raw), labels=labels, labels_by_element=labels_by_element) for raw in specs] + selected = [ + type(self)._select_labels( + str(raw), labels=labels, labels_by_element=labels_by_element + ) + for raw in specs + ] if any(not s for s in selected): bad = next(raw for raw, s in zip(specs, selected) if not s) raise ValueError(f"No spectrum images matched selector '{bad}'") @@ -496,15 +553,22 @@ def sum_maps(lbls): fig, ax = show_2d( images, title=titles, - cmap=kwargs.pop('cmap', 'magma'), - scalebar={'sampling': self.sampling[1], 'units': self.units[1]}, + cmap=kwargs.pop("cmap", "magma"), + scalebar={"sampling": self.sampling[1], "units": self.units[1]}, returnfig=True, **kwargs, ) - if return_fig: + + if return_fig and return_maps: + return (fig, ax), (images, titles) + elif return_fig: return fig, ax + elif return_maps: + return images, titles - def _build_pytorch_spectrum_images(self, abundance_maps: np.ndarray, element_names: list[str] | tuple[str, ...]) -> dict[str, np.ndarray]: + def _build_pytorch_spectrum_images( + self, abundance_maps: np.ndarray, element_names: list[str] | tuple[str, ...] + ) -> dict[str, np.ndarray]: """Convert per-element abundance maps into per-line spectrum images using weights.""" maps = np.asarray(abundance_maps) if maps.ndim != 3: @@ -523,7 +587,9 @@ def _build_pytorch_spectrum_images(self, abundance_maps: np.ndarray, element_nam line_maps[str(label)] = element_map * float(weight) return line_maps - def quantify_composition_cliff_lorimer(self, k_factors, method='integration', return_maps=False, verbose=True): + def quantify_composition_cliff_lorimer( + self, k_factors, method="integration", return_maps=False, verbose=True + ): """Quantify elemental composition using the Cliff-Lorimer thin-film method. Parameters @@ -555,10 +621,10 @@ def quantify_composition_cliff_lorimer(self, k_factors, method='integration', re spectrum images are missing. """ if not k_factors: - raise ValueError('k_factors must be a non-empty dict') + raise ValueError("k_factors must be a non-empty dict") spectrum_images = self._get_spectrum_images(method) if not spectrum_images: - raise ValueError('No spectrum images available for quantification') + raise ValueError("No spectrum images available for quantification") ordered_elements = type(self)._ordered_element_keys(type(self)._ensure_element_info()) line_map = {str(k): np.asarray(v, dtype=float) for k, v in spectrum_images.items()} @@ -566,7 +632,9 @@ def quantify_composition_cliff_lorimer(self, k_factors, method='integration', re labels_by_element = type(self)._group_labels_by_element(labels) def match(selector: str) -> list[str]: - return type(self)._select_labels(selector, labels=labels, labels_by_element=labels_by_element) + return type(self)._select_labels( + selector, labels=labels, labels_by_element=labels_by_element + ) intensities, weighted_intensities = {}, {} selector_maps = {} if return_maps else None @@ -577,11 +645,15 @@ def match(selector: str) -> list[str]: k_val = float(k_raw) sel_labels = match(str(selector).strip()) if not sel_labels: - raise ValueError(f'No spectrum images matched selector {selector!r}') + raise ValueError(f"No spectrum images matched selector {selector!r}") - matched_elements = {type(self)._resolve_element_from_label(lbl, ordered_elements) for lbl in sel_labels} - {None} + matched_elements = { + type(self)._resolve_element_from_label(lbl, ordered_elements) for lbl in sel_labels + } - {None} if len(matched_elements) != 1: - raise ValueError(f'Selector {selector!r} matched multiple elements: {sorted(matched_elements)}') + raise ValueError( + f"Selector {selector!r} matched multiple elements: {sorted(matched_elements)}" + ) element = next(iter(matched_elements)) grouped_map = np.sum([line_map[lbl] for lbl in sel_labels], axis=0) @@ -594,42 +666,53 @@ def match(selector: str) -> list[str]: weighted_map = grouped_map * k_val selector_maps[str(selector)] = grouped_map intensity_maps[element] = intensity_maps.get(element, 0) + grouped_map - weighted_intensity_maps[element] = weighted_intensity_maps.get(element, 0) + weighted_map + weighted_intensity_maps[element] = ( + weighted_intensity_maps.get(element, 0) + weighted_map + ) if len(weighted_intensities) < 2: - raise ValueError('At least two elements are required for Cliff-Lorimer quantification') + raise ValueError("At least two elements are required for Cliff-Lorimer quantification") weighted_sum = sum(weighted_intensities.values()) - atomic_percent = {el: 100.0 * val / weighted_sum if weighted_sum > 0 else 0.0 for el, val in weighted_intensities.items()} + atomic_percent = { + el: 100.0 * val / weighted_sum if weighted_sum > 0 else 0.0 + for el, val in weighted_intensities.items() + } if type(self).atomic_weights is None: type(self).load_atomic_weights() atomic_weights = type(self).atomic_weights or {} missing = [el for el in atomic_percent if el not in atomic_weights] if missing: - raise ValueError(f'Atomic weights not found for elements: {missing}') + raise ValueError(f"Atomic weights not found for elements: {missing}") - weight_sum = sum((atomic_percent[el] / 100.0) * float(atomic_weights[el]) for el in atomic_percent) + weight_sum = sum( + (atomic_percent[el] / 100.0) * float(atomic_weights[el]) for el in atomic_percent + ) weight_percent = { - el: (atomic_percent[el] / 100.0) * float(atomic_weights[el]) / weight_sum * 100.0 if weight_sum > 0 else 0.0 + el: (atomic_percent[el] / 100.0) * float(atomic_weights[el]) / weight_sum * 100.0 + if weight_sum > 0 + else 0.0 for el in atomic_percent } ordered = sorted(weighted_intensities, key=weighted_intensities.get, reverse=True) - table_text = '\n'.join([ - 'Element Intensity Weighted Intensity Atomic % Weight %', - '------- ------------- -------------------- ---------- ----------', - *[ - f'{el:<7} {intensities[el]:>13.3f} {weighted_intensities[el]:>20.3f} {atomic_percent[el]:>10.3f} {weight_percent[el]:>10.3f}' - for el in ordered - ], - ]) + table_text = "\n".join( + [ + "Element Intensity Weighted Intensity Atomic % Weight %", + "------- ------------- -------------------- ---------- ----------", + *[ + f"{el:<7} {intensities[el]:>13.3f} {weighted_intensities[el]:>20.3f} {atomic_percent[el]:>10.3f} {weight_percent[el]:>10.3f}" + for el in ordered + ], + ] + ) result = { - 'intensities': intensities, - 'weighted_intensities': weighted_intensities, - 'atomic_percent': atomic_percent, - 'weight_percent': weight_percent, - 'summary_table': table_text, + "intensities": intensities, + "weighted_intensities": weighted_intensities, + "atomic_percent": atomic_percent, + "weight_percent": weight_percent, + "summary_table": table_text, } if verbose: print(table_text) @@ -638,22 +721,37 @@ def match(selector: str) -> list[str]: weighted_stack = np.stack(list(weighted_intensity_maps.values()), axis=0) weighted_sum_map = weighted_stack.sum(axis=0) atomic_percent_maps = { - el: np.divide(wmap * 100.0, weighted_sum_map, out=np.zeros_like(weighted_sum_map, dtype=float), where=weighted_sum_map > 0) + el: np.divide( + wmap * 100.0, + weighted_sum_map, + out=np.zeros_like(weighted_sum_map, dtype=float), + where=weighted_sum_map > 0, + ) for el, wmap in weighted_intensity_maps.items() } - mass_maps = {el: atomic_percent_maps[el] / 100.0 * float(atomic_weights[el]) for el in atomic_percent_maps} + mass_maps = { + el: atomic_percent_maps[el] / 100.0 * float(atomic_weights[el]) + for el in atomic_percent_maps + } mass_sum_map = np.sum(np.stack(list(mass_maps.values()), axis=0), axis=0) weight_percent_maps = { - el: np.divide(mmap * 100.0, mass_sum_map, out=np.zeros_like(mass_sum_map, dtype=float), where=mass_sum_map > 0) + el: np.divide( + mmap * 100.0, + mass_sum_map, + out=np.zeros_like(mass_sum_map, dtype=float), + where=mass_sum_map > 0, + ) for el, mmap in mass_maps.items() } - result.update({ - 'selector_maps': selector_maps, - 'intensity_maps': intensity_maps, - 'weighted_intensity_maps': weighted_intensity_maps, - 'atomic_percent_maps': atomic_percent_maps, - 'weight_percent_maps': weight_percent_maps, - }) + result.update( + { + "selector_maps": selector_maps, + "intensity_maps": intensity_maps, + "weighted_intensity_maps": weighted_intensity_maps, + "atomic_percent_maps": atomic_percent_maps, + "weight_percent_maps": weight_percent_maps, + } + ) return result def clear_spectrum_images(self): @@ -764,20 +862,28 @@ def peak_autoid( all_info = type(self).element_info or {} grid_peaks = grid_peaks or {} ignore_range = [0, 0.25] if ignore_range is None else ignore_range - ignored_elements = set(map(str, type(self)._normalize_specs(ignore_elements, allow_none=True) or [])) + ignored_elements = set( + map(str, type(self)._normalize_specs(ignore_elements, allow_none=True) or []) + ) min_line_weight = max(float(min_line_weight), 0.0) - requested = type(self)._parse_element_selectors(elements, allow_none=True, param_name='elements') + requested = type(self)._parse_element_selectors( + elements, allow_none=True, param_name="elements" + ) saved = { str(k): (set(map(str, v.keys())) if isinstance(v, dict) and v else None) - for k, v in (getattr(self, 'model_elements', {}) or {}).items() + for k, v in (getattr(self, "model_elements", {}) or {}).items() } or None edge_filters = type(self)._merge_edge_filters(requested, saved) requested_elements = set(edge_filters) if edge_filters else None - mode = (str(mode).strip().lower() if mode is not None else None) or ('elements_only' if requested_elements else 'autofill') - search_elements = requested_elements if mode == 'elements_only' else None - preferred_elements = set(map(str, requested_elements or [])) if mode == 'elements_preferred' else set() + mode = (str(mode).strip().lower() if mode is not None else None) or ( + "elements_only" if requested_elements else "autofill" + ) + search_elements = requested_elements if mode == "elements_only" else None + preferred_elements = ( + set(map(str, requested_elements or [])) if mode == "elements_preferred" else set() + ) reference_elements = requested_elements fig, (ax_img, ax_spec) = self.show_mean_spectrum( @@ -785,7 +891,7 @@ def peak_autoid( roi_cal=roi_cal, energy_range=energy_range, mask=mask, - data_type='eds', + data_type="eds", show=False, ) spec = self.calculate_mean_spectrum( @@ -805,7 +911,7 @@ def in_ignore(energy): return len(ignore_range) == 2 and ignore_range[0] <= float(energy) <= ignore_range[1] peak_indices, props = find_peaks(spec, height=0, distance=5) - peak_heights = props['peak_heights'] + peak_heights = props["peak_heights"] background_std = np.nanstd(spec[spec <= np.nanpercentile(spec, 50)]) if not np.isfinite(background_std) or background_std <= 0: background_std = np.nanstd(spec) @@ -813,7 +919,9 @@ def in_ignore(energy): background_std = 1.0 snr_values = np.asarray([height / background_std for height in peak_heights], dtype=float) - snr_min, snr_threshold = type(self)._estimate_snr_thresholds(snr_values, peaks, snr_min, snr_threshold) + snr_min, snr_threshold = type(self)._estimate_snr_thresholds( + snr_values, peaks, snr_min, snr_threshold + ) display_peaks = [ (int(i), float(h), float(E[i]), float(h / background_std)) @@ -830,25 +938,35 @@ def candidate_matches(peak_energy, snr, allowed_elements=None): if allowed_elements is not None and element_name not in allowed_elements: continue for line_name, line_info in lines.items(): - if not type(self)._line_allowed_for_element(element_name, line_name, edge_filters): + if not type(self)._line_allowed_for_element( + element_name, line_name, edge_filters + ): continue - line_weight = float(line_info.get('weight', 0.5)) - line_energy = float(line_info['energy (keV)']) + line_weight = float(line_info.get("weight", 0.5)) + line_energy = float(line_info["energy (keV)"]) shell = type(self)._line_shell(line_name) - tol = tolerance * 0.5 if shell == 'M' and ('Ma' not in line_name and 'Mb' not in line_name) else tolerance + tol = ( + tolerance * 0.5 + if shell == "M" and ("Ma" not in line_name and "Mb" not in line_name) + else tolerance + ) distance = abs(peak_energy - line_energy) if line_weight < min_line_weight or distance > tol: continue - score = type(self)._peak_confidence(snr, line_weight, distance, tolerance) * type(self)._shell_preference_factor(shell) - matches.append({ - 'element': str(element_name), - 'line': str(line_name), - 'weight': line_weight, - 'distance': distance, - 'score': float(score), - 'shell': shell, - }) - matches.sort(key=lambda m: m['score'], reverse=True) + score = type(self)._peak_confidence( + snr, line_weight, distance, tolerance + ) * type(self)._shell_preference_factor(shell) + matches.append( + { + "element": str(element_name), + "line": str(line_name), + "weight": line_weight, + "distance": distance, + "score": float(score), + "shell": shell, + } + ) + matches.sort(key=lambda m: m["score"], reverse=True) return matches peak_matches = [] @@ -857,74 +975,116 @@ def candidate_matches(peak_energy, snr, allowed_elements=None): if not matches: continue best = matches[0] - peak_matches.append(( - peak_idx, - height, - peak_energy, - snr, - best['element'], - f"{best['element']} {best['line']}", - best['distance'], - best['line'], - best['weight'], - best['score'], - )) + peak_matches.append( + ( + peak_idx, + height, + peak_energy, + snr, + best["element"], + f"{best['element']} {best['line']}", + best["distance"], + best["line"], + best["weight"], + best["score"], + ) + ) element_stats, line_evidence = {}, {} - for _, _, peak_energy, snr, element, _, distance, line_name, line_weight, conf in peak_matches: + for ( + _, + _, + peak_energy, + snr, + element, + _, + distance, + line_name, + line_weight, + conf, + ) in peak_matches: if search_elements is not None and element not in search_elements: continue shell = type(self)._line_shell(line_name) - stats = element_stats.setdefault(element, { - 'raw_conf': 0.0, - 'shells': set(), - 'lines': set(), - 'strong_matches': 0, - 'match_count': 0, - 'best_match_conf': 0.0, - 'best_match_snr': 0.0, - 'best_match_energy': 0.0, - 'best_match_distance': float('inf'), - 'best_match_weight': 0.0, - 'best_match_shell': '?', - }) - label = f'{element} {line_name}' - evidence = line_evidence.setdefault(label, {'match_count': 0, 'strong_matches': 0, 'best_conf': 0.0, 'best_snr': 0.0, 'energies': []}) - - stats['raw_conf'] += float(conf) - stats['shells'].add(shell) - stats['lines'].add(line_name) - stats['match_count'] += 1 - stats['strong_matches'] += int(snr > snr_threshold and distance < distance_threshold_for_sample) - if conf > stats['best_match_conf']: - stats.update({ - 'best_match_conf': float(conf), - 'best_match_snr': float(snr), - 'best_match_energy': float(peak_energy), - 'best_match_distance': float(distance), - 'best_match_weight': float(line_weight), - 'best_match_shell': shell, - }) - - evidence['match_count'] += 1 - evidence['energies'].append(float(peak_energy)) - evidence['strong_matches'] += int(snr > snr_threshold and distance < distance_threshold_for_sample) - if conf > evidence['best_conf']: - evidence['best_conf'] = float(conf) - evidence['best_snr'] = float(snr) + stats = element_stats.setdefault( + element, + { + "raw_conf": 0.0, + "shells": set(), + "lines": set(), + "strong_matches": 0, + "match_count": 0, + "best_match_conf": 0.0, + "best_match_snr": 0.0, + "best_match_energy": 0.0, + "best_match_distance": float("inf"), + "best_match_weight": 0.0, + "best_match_shell": "?", + }, + ) + label = f"{element} {line_name}" + evidence = line_evidence.setdefault( + label, + { + "match_count": 0, + "strong_matches": 0, + "best_conf": 0.0, + "best_snr": 0.0, + "energies": [], + }, + ) + + stats["raw_conf"] += float(conf) + stats["shells"].add(shell) + stats["lines"].add(line_name) + stats["match_count"] += 1 + stats["strong_matches"] += int( + snr > snr_threshold and distance < distance_threshold_for_sample + ) + if conf > stats["best_match_conf"]: + stats.update( + { + "best_match_conf": float(conf), + "best_match_snr": float(snr), + "best_match_energy": float(peak_energy), + "best_match_distance": float(distance), + "best_match_weight": float(line_weight), + "best_match_shell": shell, + } + ) + + evidence["match_count"] += 1 + evidence["energies"].append(float(peak_energy)) + evidence["strong_matches"] += int( + snr > snr_threshold and distance < distance_threshold_for_sample + ) + if conf > evidence["best_conf"]: + evidence["best_conf"] = float(conf) + evidence["best_snr"] = float(snr) element_confidence = {} # --- Intensity ratio check and multi-peak pattern boost --- for element, stats in element_stats.items(): - valid_shells = {shell for shell in stats['shells'] if shell in {'K', 'L', 'M'}} + valid_shells = {shell for shell in stats["shells"] if shell in {"K", "L", "M"}} shell_bonus = float(np.sqrt(max(1, len(valid_shells)))) - line_bonus = 1.0 + 0.30 * float(np.log1p(max(0, len(stats['lines']) - 1))) - strong_bonus = 1.0 + 0.40 * float(np.log1p(stats['strong_matches'])) - major_bonus = 1.20 if {'K', 'L'} & valid_shells else 1.0 + line_bonus = 1.0 + 0.30 * float(np.log1p(max(0, len(stats["lines"]) - 1))) + strong_bonus = 1.0 + 0.40 * float(np.log1p(stats["strong_matches"])) + major_bonus = 1.20 if {"K", "L"} & valid_shells else 1.0 # Intensity ratio logic element_peak_intensities = {} - for _, height, peak_energy, snr, el, _, distance, line_name, line_weight, conf in peak_matches: + for ( + _, + height, + peak_energy, + snr, + el, + _, + distance, + line_name, + line_weight, + conf, + ) in peak_matches: if el == element: element_peak_intensities.setdefault(line_name, []).append(float(height)) # Only consider if at least 2 lines detected @@ -933,7 +1093,7 @@ def candidate_matches(peak_energy, snr, allowed_elements=None): expected = [] for line_name, intensities in element_peak_intensities.items(): observed.append(max(intensities)) - weight = all_info.get(element, {}).get(line_name, {}).get('weight', None) + weight = all_info.get(element, {}).get(line_name, {}).get("weight", None) try: expected.append(float(weight) if weight is not None else 0.0) except Exception: @@ -943,7 +1103,9 @@ def candidate_matches(peak_energy, snr, allowed_elements=None): if obs_sum > 0 and exp_sum > 0: observed_norm = [x / obs_sum for x in observed] expected_norm = [x / exp_sum for x in expected] - ratio_score = 1.0 - (sum(abs(o - e) for o, e in zip(observed_norm, expected_norm)) / 2.0) + ratio_score = 1.0 - ( + sum(abs(o - e) for o, e in zip(observed_norm, expected_norm)) / 2.0 + ) ratio_factor = 1.0 if ratio_score > 0.7: ratio_factor = 1.15 + 0.25 * (ratio_score - 0.7) @@ -956,9 +1118,9 @@ def candidate_matches(peak_energy, snr, allowed_elements=None): # --- Strong pattern boost: if both main lines for K, L, or M are matched, multiply confidence by 3 (dominates score) --- matched_lines = set(element_peak_intensities.keys()) - k_lines = {'Ka1', 'Kb1'} - l_lines = {'La1', 'Lb1'} - m_lines = {'Ma1', 'Mb1'} + k_lines = {"Ka1", "Kb1"} + l_lines = {"La1", "Lb1"} + m_lines = {"Ma1", "Mb1"} pattern_factor = 1.0 if k_lines.issubset(matched_lines): pattern_factor = 3.0 @@ -967,7 +1129,15 @@ def candidate_matches(peak_energy, snr, allowed_elements=None): elif m_lines.issubset(matched_lines): pattern_factor = 2.0 - element_confidence[element] = stats['raw_conf'] * shell_bonus * line_bonus * strong_bonus * major_bonus * ratio_factor * pattern_factor + element_confidence[element] = ( + stats["raw_conf"] + * shell_bonus + * line_bonus + * strong_bonus + * major_bonus + * ratio_factor + * pattern_factor + ) detected_elements = set() if element_confidence: @@ -976,15 +1146,17 @@ def candidate_matches(peak_energy, snr, allowed_elements=None): cutoff = max(float(np.percentile(conf_values, 45)), 0.30 * float(conf_values.max())) for element, confidence in element_confidence.items(): stats = element_stats[element] - lines = set(stats['lines']) + lines = set(stats["lines"]) # Criterion 1: Both main lines matched (pattern match) → always autodetect strong_pattern = ( - {'Ka1', 'Kb1'}.issubset(lines) - or {'La1', 'Lb1'}.issubset(lines) - or {'Ma1', 'Mb1'}.issubset(lines) + {"Ka1", "Kb1"}.issubset(lines) + or {"La1", "Lb1"}.issubset(lines) + or {"Ma1", "Mb1"}.issubset(lines) ) and confidence > 0 # Criterion 2: High confidence above cutoff and sufficient SNR - high_confidence = confidence >= cutoff and stats['best_match_snr'] >= poisson_mdl_snr + high_confidence = ( + confidence >= cutoff and stats["best_match_snr"] >= poisson_mdl_snr + ) if strong_pattern or high_confidence: detected_elements.add(element) @@ -995,18 +1167,28 @@ def candidate_matches(peak_energy, snr, allowed_elements=None): conf_p80 = float(np.percentile(conf_values, 80)) if conf_values.size > 1 else 0.0 for element, confidence in element_confidence.items(): stats = element_stats.get(element, {}) - repeat_support = int(stats.get('match_count', 0)) >= 2 or int(stats.get('strong_matches', 0)) >= 1 + repeat_support = ( + int(stats.get("match_count", 0)) >= 2 + or int(stats.get("strong_matches", 0)) >= 1 + ) if confidence >= conf_p80 and confidence >= 1.8 * conf_floor and repeat_support: dominant_elements.add(element) anchor_elements = { - element for element in detected_elements - if element in element_stats and element_stats[element].get('best_match_energy', 0.0) >= 6.0 and element_stats[element].get('best_match_weight', 0.0) >= 0.8 + element + for element in detected_elements + if element in element_stats + and element_stats[element].get("best_match_energy", 0.0) >= 6.0 + and element_stats[element].get("best_match_weight", 0.0) >= 0.8 } - max_detected_conf = max([element_confidence.get(el, 0.0) for el in detected_elements], default=0.0) + max_detected_conf = max( + [element_confidence.get(el, 0.0) for el in detected_elements], default=0.0 + ) def prior_boost(element): - prior = float(element_confidence.get(element, 0.0)) / max(float(max_detected_conf), 1e-9) + prior = float(element_confidence.get(element, 0.0)) / max( + float(max_detected_conf), 1e-9 + ) factor = 1.0 + 0.5 * prior if prior >= 0.90: factor *= 1.9 @@ -1019,13 +1201,18 @@ def prior_boost(element): def consistency_boost(element, line_name, peak_energy): if element not in dominant_elements: return 1.0 - evidence = line_evidence.get(f'{element} {line_name}') - if not evidence or not any(abs(float(peak_energy) - float(prev)) >= 0.04 for prev in evidence.get('energies', [])): + evidence = line_evidence.get(f"{element} {line_name}") + if not evidence or not any( + abs(float(peak_energy) - float(prev)) >= 0.04 + for prev in evidence.get("energies", []) + ): return 1.0 - best_conf = float(evidence.get('best_conf', 0.0)) - best_snr = float(evidence.get('best_snr', 0.0)) - strong = int(evidence.get('strong_matches', 0)) - line_weight = float((all_info.get(element, {}).get(line_name, {}) or {}).get('weight', 0.5)) + best_conf = float(evidence.get("best_conf", 0.0)) + best_snr = float(evidence.get("best_snr", 0.0)) + strong = int(evidence.get("strong_matches", 0)) + line_weight = float( + (all_info.get(element, {}).get(line_name, {}) or {}).get("weight", 0.5) + ) tier = 1.0 + 0.7 * max(0.0, line_weight - 0.35) if strong >= 1 and best_conf >= 1.4: return min(3.2, 2.4 * tier) @@ -1040,7 +1227,9 @@ def dominant_boost(element): return 1.0 prior, _ = prior_boost(element) stats = element_stats.get(element, {}) - repeat_support = max(int(stats.get('strong_matches', 0)), max(0, int(stats.get('match_count', 0)) - 1)) + repeat_support = max( + int(stats.get("strong_matches", 0)), max(0, int(stats.get("match_count", 0)) - 1) + ) base = 2.30 if prior >= 0.90 else 1.85 if prior >= 0.75 else 1.45 if repeat_support >= 2: base *= 1.10 @@ -1053,17 +1242,17 @@ def reranked_matches(peak_energy, snr, allowed_elements=None, top_k=None): element_to_lines.setdefault(el, set()).add(ln) scored = [] for match in candidate_matches(peak_energy, snr, allowed_elements): - element, line_name, shell = match['element'], match['line'], match['shell'] + element, line_name, shell = match["element"], match["line"], match["shell"] prior, prior_factor = prior_boost(element) pref = 1.35 if element in preferred_elements else 1.0 - anchor = 1.15 if element in anchor_elements and shell in {'K', 'L'} else 1.0 + anchor = 1.15 if element in anchor_elements and shell in {"K", "L"} else 1.0 consistency = consistency_boost(element, line_name, peak_energy) dom = dominant_boost(element) # Pattern boost: if both main lines for K, L, or M are matched by detected peaks, boost candidate score lines_matched = element_to_lines.get(element, set()) - k_lines = {'Ka1', 'Kb1'} - l_lines = {'La1', 'Lb1'} - m_lines = {'Ma1', 'Mb1'} + k_lines = {"Ka1", "Kb1"} + l_lines = {"La1", "Lb1"} + m_lines = {"Ma1", "Mb1"} pattern_factor = 1.0 if k_lines.issubset(lines_matched): pattern_factor = 3.0 @@ -1071,17 +1260,29 @@ def reranked_matches(peak_energy, snr, allowed_elements=None, top_k=None): pattern_factor = 2.5 elif m_lines.issubset(lines_matched): pattern_factor = 2.0 - if shell == 'M': + if shell == "M": prior_factor = 1.0 + 0.3 * prior consistency = 1.0 dom = min(dom, 1.30) - score = match['score'] * prior_factor * pref * anchor * consistency * dom * pattern_factor - scored.append({**match, 'score': float(score)}) - - scored.sort(key=lambda m: m['score'], reverse=True) - if mode == 'elements_preferred' and preferred_elements: - preferred = [m for m in scored if m['element'] in preferred_elements] - scored = preferred + [m for m in scored if m['element'] not in preferred_elements] if preferred else scored + score = ( + match["score"] + * prior_factor + * pref + * anchor + * consistency + * dom + * pattern_factor + ) + scored.append({**match, "score": float(score)}) + + scored.sort(key=lambda m: m["score"], reverse=True) + if mode == "elements_preferred" and preferred_elements: + preferred = [m for m in scored if m["element"] in preferred_elements] + scored = ( + preferred + [m for m in scored if m["element"] not in preferred_elements] + if preferred + else scored + ) unique, seen = [], set() for match in scored: @@ -1094,12 +1295,12 @@ def reranked_matches(peak_energy, snr, allowed_elements=None, top_k=None): if top_k is None or len(unique) <= 1: return unique selected = [unique[0]] - used_elements = {unique[0]['element']} + used_elements = {unique[0]["element"]} for match in unique[1:]: - if match['element'] in used_elements: + if match["element"] in used_elements: continue selected.append(match) - used_elements.add(match['element']) + used_elements.add(match["element"]) if len(selected) >= int(top_k): return selected for match in unique[1:]: @@ -1109,35 +1310,44 @@ def reranked_matches(peak_energy, snr, allowed_elements=None, top_k=None): break return selected - rematch_allowed = {str(match[4]) for match in peak_matches if str(match[4]) not in ignored_elements} + rematch_allowed = { + str(match[4]) for match in peak_matches if str(match[4]) not in ignored_elements + } rematch_allowed.update(map(str, detected_elements)) rematch_allowed.update(preferred_elements) refined_peak_matches = [] - raw_match_by_idx = {int(match[0]): match for match in peak_matches} for peak_idx, height, peak_energy, snr in display_peaks: best = reranked_matches(peak_energy, snr, rematch_allowed or None, top_k=1) best = best[0] if best else None if best is None: continue - refined_peak_matches.append(( - peak_idx, - height, - peak_energy, - snr, - best['element'], - f"{best['element']} {best['line']}", - best['distance'], - best['line'], - best['weight'], - best['score'], - )) + refined_peak_matches.append( + ( + peak_idx, + height, + peak_energy, + snr, + best["element"], + f"{best['element']} {best['line']}", + best["distance"], + best["line"], + best["weight"], + best["score"], + ) + ) peak_matches = refined_peak_matches matched_elements = {str(match[4]) for match in peak_matches} - detected_elements = {str(el) for el in detected_elements if str(el) in matched_elements and str(el) not in ignored_elements} - if mode == 'elements_preferred': - detected_elements.update(str(el) for el in preferred_elements if str(el) in matched_elements) + detected_elements = { + str(el) + for el in detected_elements + if str(el) in matched_elements and str(el) not in ignored_elements + } + if mode == "elements_preferred": + detected_elements.update( + str(el) for el in preferred_elements if str(el) in matched_elements + ) refined_match_by_idx = {int(match[0]): match for match in peak_matches} final_matches_by_element: dict[str, set[str]] = {} @@ -1145,45 +1355,72 @@ def reranked_matches(peak_energy, snr, allowed_elements=None, top_k=None): if element not in ignored_elements: final_matches_by_element.setdefault(element, set()).add(str(line_name)) - candidate_elements = sorted(str(el) for el in final_matches_by_element if str(el) not in detected_elements) + candidate_elements = sorted( + str(el) for el in final_matches_by_element if str(el) not in detected_elements + ) possible_elements = set(candidate_elements) - possible_line_labels = {f'{element} {line}' for _, _, _, _, element, _, _, line, _, _ in peak_matches if element in possible_elements} def format_elements_with_lines(names): items = [] for element in sorted(map(str, names)): lines = sorted(map(str, final_matches_by_element.get(element, set()))) line_strs = [str(line) for line in lines] - items.append(f"{element} [{', '.join(line_strs)}]" if lines else f'{element}') - return ', '.join(items) + items.append(f"{element} [{', '.join(line_strs)}]" if lines else f"{element}") + return ", ".join(items) def format_saved(edge_filters): if edge_filters is None: - return 'None' + return "None" out = [] for element in sorted(map(str, edge_filters)): selectors = edge_filters.get(element) - out.append(f'{element} [all]' if not selectors else f"{element} [{', '.join(sorted(map(str, selectors)))}]") - return '\n'.join(out) if out else 'None' + out.append( + f"{element} [all]" + if not selectors + else f"{element} [{', '.join(sorted(map(str, selectors)))}]" + ) + return "\n".join(out) if out else "None" all_identified = set(detected_elements) | set(candidate_elements) - print(f"\nDetected: {format_elements_with_lines(all_identified) if all_identified else 'None'}") + print( + f"\nDetected: {format_elements_with_lines(all_identified) if all_identified else 'None'}" + ) visible_dominant = dominant_elements & all_identified if visible_dominant: - dominant_str = ', '.join(f"{el} (conf={element_confidence.get(str(el), 0.0):.2f})" for el in sorted(visible_dominant, key=lambda el: element_confidence.get(str(el), 0.0), reverse=True)) - print(f'High confidence: {dominant_str}') + dominant_str = ", ".join( + f"{el} (conf={element_confidence.get(str(el), 0.0):.2f})" + for el in sorted( + visible_dominant, + key=lambda el: element_confidence.get(str(el), 0.0), + reverse=True, + ) + ) + print(f"High confidence: {dominant_str}") elements_for_color = set(detected_elements) | {str(match[4]) for match in peak_matches} if search_elements is not None: elements_for_color.update(map(str, search_elements)) - palette = ['#1f77b4', '#d62728', '#2ca02c', '#9467bd', '#ff7f0e', '#8c564b', '#e377c2', '#17becf', '#bcbd22', '#7f7f7f', '#003f5c', '#7a5195', '#ef5675', '#ffa600', '#2f4b7c'] - element_color_map = {el: palette[i % len(palette)] for i, el in enumerate(sorted(elements_for_color))} - - detected_sample_peaks = { - float(peak_energy) - for _, _, peak_energy, _, element, _, _, _, _, _ in peak_matches - if element in detected_elements + palette = [ + "#1f77b4", + "#d62728", + "#2ca02c", + "#9467bd", + "#ff7f0e", + "#8c564b", + "#e377c2", + "#17becf", + "#bcbd22", + "#7f7f7f", + "#003f5c", + "#7a5195", + "#ef5675", + "#ffa600", + "#2f4b7c", + ] + element_color_map = { + el: palette[i % len(palette)] for i, el in enumerate(sorted(elements_for_color)) } + y_min = float(np.nanmin(spec)) if len(spec) else 0.0 y_max = float(np.nanmax(spec)) if len(spec) else 1.0 y_scale = max(max(1e-9, y_max - y_min), abs(y_max), 1.0) @@ -1192,13 +1429,15 @@ def format_saved(edge_filters): def infer_requested_color(peak_energy): if reference_elements is None: return None - best_element, best_distance = None, float('inf') + best_element, best_distance = None, float("inf") for element in reference_elements: for line_name, line_info in (all_info.get(str(element), {}) or {}).items(): - if not type(self)._line_allowed_for_element(str(element), line_name, edge_filters): + if not type(self)._line_allowed_for_element( + str(element), line_name, edge_filters + ): continue try: - distance = abs(float(peak_energy) - float(line_info.get('energy (keV)'))) + distance = abs(float(peak_energy) - float(line_info.get("energy (keV)"))) except (TypeError, ValueError): continue if distance < best_distance: @@ -1208,76 +1447,122 @@ def infer_requested_color(peak_energy): table_rows = [] for peak_idx, height, peak_energy, snr in display_peaks: match = refined_match_by_idx.get(int(peak_idx)) - is_sample = float(peak_energy) in detected_sample_peaks - is_possible = match is not None and str(match[4]) in possible_elements - color = element_color_map.get(match[4], 'red') if match is not None else element_color_map.get(str(infer_requested_color(peak_energy)), 'red') + color = ( + element_color_map.get(match[4], "red") + if match is not None + else element_color_map.get(str(infer_requested_color(peak_energy)), "red") + ) if not in_ignore(peak_energy): # Only plot solid lines for matched peaks (autodetected or requested elements) if match is not None: - ax_spec.axvline(peak_energy, color=color, linestyle='-', alpha=0.7, linewidth=1.5) + ax_spec.axvline( + peak_energy, color=color, linestyle="-", alpha=0.7, linewidth=1.5 + ) else: - ax_spec.plot([peak_energy], [y_dot], marker='|', markersize=4, color='gray', alpha=0.8, linestyle='None') + ax_spec.plot( + [peak_energy], + [y_dot], + marker="|", + markersize=4, + color="gray", + alpha=0.8, + linestyle="None", + ) if show_text and match is not None: for grid_element, grid_energy in grid_peaks.items(): if abs(peak_energy - grid_energy) < 0.1: - ax_spec.text(peak_energy, height * 0.7, f'{grid_element}\n(grid)', ha='center', va='bottom', fontsize=8, color='gray', style='italic') - print(f'Peak at {peak_energy} keV may come from the grid.') + ax_spec.text( + peak_energy, + height * 0.7, + f"{grid_element}\n(grid)", + ha="center", + va="bottom", + fontsize=8, + color="gray", + style="italic", + ) + print(f"Peak at {peak_energy} keV may come from the grid.") break def label_with_energy_and_ratio(label, detected_peak_intensity=None): # label is like 'Fe Ka', want to append (energy, ratio) from all_info and observed/expected - if not label or label == '-' or label == 'Unmatched' or label == 'Unknown': + if not label or label == "-" or label == "Unmatched" or label == "Unknown": return label parts = label.split() if len(parts) < 2: return label - element, line = parts[0], parts[1].replace('*','') + element, line = parts[0], parts[1].replace("*", "") line_info = all_info.get(element, {}).get(line, {}) ref_energy = None if isinstance(line_info, dict): - ref_energy = line_info.get('energy (keV)', line_info.get('energy')) + ref_energy = line_info.get("energy (keV)", line_info.get("energy")) try: ref_energy = float(ref_energy) except (TypeError, ValueError): ref_energy = None - label_core = label.rstrip('*') - star = '*' if label.endswith('*') else '' + label_core = label.rstrip("*") + star = "*" if label.endswith("*") else "" if ref_energy is not None: return f"{label_core} ({ref_energy:.3f}){star}" else: return label if match is None: - table_rows.append((peak_energy, height, snr, 'Unmatched' if search_elements is not None else 'Unknown', '-', '-')) + table_rows.append( + ( + peak_energy, + height, + snr, + "Unmatched" if search_elements is not None else "Unknown", + "-", + "-", + ) + ) continue - allowed_for_table = set(map(str, search_elements)) if search_elements is not None else ({str(el) for el in all_info if str(el) not in ignored_elements} or None) + allowed_for_table = ( + set(map(str, search_elements)) + if search_elements is not None + else ({str(el) for el in all_info if str(el) not in ignored_elements} or None) + ) ranked = reranked_matches(peak_energy, snr, allowed_for_table, top_k=3) - labels = [(f"{m['element']} {m['line']}", float(m['score']), m['element'], m['line']) for m in ranked] - best_label = f'{match[4]} {match[7]}' + labels = [ + (f"{m['element']} {m['line']}", float(m["score"]), m["element"], m["line"]) + for m in ranked + ] + best_label = f"{match[4]} {match[7]}" def fmt(label, score=None): - label = f'{label}*' if requested_elements and str(label).split()[0] in requested_elements else label + label = ( + f"{label}*" + if requested_elements and str(label).split()[0] in requested_elements + else label + ) return label # Gather all intensities for this element for ratio calculation all_element_intensities = {} - for l in all_info.get(match[4], {}): + for intensity in all_info.get(match[4], {}): # Find the highest observed intensity for each line obs = 0.0 for _, h, _, _, el, _, _, ln, _, _ in peak_matches: - if el == match[4] and ln == l: + if el == match[4] and ln == intensity: obs = max(obs, float(h)) - weight = all_info.get(match[4], {}).get(l, {}).get('weight', None) + weight = all_info.get(match[4], {}).get(intensity, {}).get("weight", None) try: weight = float(weight) if weight is not None else 0.0 except Exception: weight = 0.0 - all_element_intensities[l] = (obs, weight) + all_element_intensities[intensity] = (obs, weight) + + remaining = [ + (label, score, elem, line) + for label, score, elem, line in labels + if label.lower() != best_label.lower() + ] - remaining = [(label, score, elem, line) for label, score, elem, line in labels if label.lower() != best_label.lower()] # For each label, show ratio for that line def get_peak_intensity(elem, line): obs = 0.0 @@ -1286,17 +1571,29 @@ def get_peak_intensity(elem, line): obs = max(obs, float(h)) return obs - table_rows.append(( - peak_energy, - height, - snr, - label_with_energy_and_ratio(fmt(best_label), detected_peak_intensity=height), - label_with_energy_and_ratio(fmt(remaining[0][0]), detected_peak_intensity=height) if len(remaining) > 0 else '-', - label_with_energy_and_ratio(fmt(remaining[1][0]), detected_peak_intensity=height) if len(remaining) > 1 else '-', - )) + table_rows.append( + ( + peak_energy, + height, + snr, + label_with_energy_and_ratio(fmt(best_label), detected_peak_intensity=height), + label_with_energy_and_ratio( + fmt(remaining[0][0]), detected_peak_intensity=height + ) + if len(remaining) > 0 + else "-", + label_with_energy_and_ratio( + fmt(remaining[1][0]), detected_peak_intensity=height + ) + if len(remaining) > 1 + else "-", + ) + ) current_bottom, current_top = ax_spec.get_ylim() - ax_spec.set_ylim(bottom=min(current_bottom, y_dot - 0.02 * y_scale, -0.02 * y_scale), top=current_top) + ax_spec.set_ylim( + bottom=min(current_bottom, y_dot - 0.02 * y_scale, -0.02 * y_scale), top=current_top + ) label_candidates = [] top_label_y = 0.92 @@ -1310,27 +1607,50 @@ def get_peak_intensity(elem, line): for element in sorted(requested_elements): candidates = [] for line_name, line_info in (all_info.get(str(element), {}) or {}).items(): - if not type(self)._line_allowed_for_element(str(element), line_name, edge_filters): + if not type(self)._line_allowed_for_element( + str(element), line_name, edge_filters + ): continue try: - line_energy = float(line_info.get('energy (keV)')) - line_weight = float(line_info.get('weight', 0.0)) + line_energy = float(line_info.get("energy (keV)")) + line_weight = float(line_info.get("weight", 0.0)) except (TypeError, ValueError): continue if energy_min <= line_energy <= energy_max: candidates.append((str(line_name), line_energy, line_weight)) - candidates = sorted([c for c in candidates if c[2] >= 0.05] or candidates, key=lambda item: item[2], reverse=True)[:6] + candidates = sorted( + [c for c in candidates if c[2] >= 0.05] or candidates, + key=lambda item: item[2], + reverse=True, + )[:6] for line_name, line_energy, _ in candidates: if in_ignore(line_energy): continue # Skip if already matched by a detected peak - if any(abs(line_energy - matched_energy) <= max(0.05, 0.5 * tolerance) for matched_energy in matched_by_element.get(str(element), [])): + if any( + abs(line_energy - matched_energy) <= max(0.05, 0.5 * tolerance) + for matched_energy in matched_by_element.get(str(element), []) + ): continue - color = element_color_map.get(str(element), 'black') - style = '--' + color = element_color_map.get(str(element), "black") + style = "--" alpha = 0.45 - ax_spec.axvline(line_energy, color=color, linestyle=style, alpha=alpha, linewidth=1.2) - label_candidates.append((float(line_energy), f'{element} {line_name}', color, style, float(top_label_y), 'axes_top', 8, 'normal', 0.8)) + ax_spec.axvline( + line_energy, color=color, linestyle=style, alpha=alpha, linewidth=1.2 + ) + label_candidates.append( + ( + float(line_energy), + f"{element} {line_name}", + color, + style, + float(top_label_y), + "axes_top", + 8, + "normal", + 0.8, + ) + ) if show_text and peak_matches: label_offset = max(0.03 * y_scale, 0.08) @@ -1341,8 +1661,20 @@ def get_peak_intensity(elem, line): is_requested = requested_elements is not None and element in requested_elements if element not in label_allowed or in_ignore(peak_energy): continue - label = f"{element} {match_str.split()[-1]}" + ('*' if is_requested else '') - label_candidates.append((float(peak_energy), label, element_color_map.get(element, 'black'), '-', float(height + label_offset), 'data', 10, 'bold', 1.0)) + label = f"{element} {match_str.split()[-1]}" + ("*" if is_requested else "") + label_candidates.append( + ( + float(peak_energy), + label, + element_color_map.get(element, "black"), + "-", + float(height + label_offset), + "data", + 10, + "bold", + 1.0, + ) + ) legend_handles, legend_labels = [], set() if show_text and label_candidates: @@ -1360,45 +1692,87 @@ def get_peak_intensity(elem, line): for group in groups: if len(group) == 1: - peak_energy, label_text, color, _, y_value, y_mode, font_size, font_weight, alpha_value = group[0] - common = dict(ha='center', fontsize=font_size, color=color, weight=font_weight, rotation=90, alpha=alpha_value) - if y_mode == 'axes_top': - ax_spec.text(peak_energy, y_value, label_text, va='top', transform=ax_spec.get_xaxis_transform(), clip_on=True, **common) + ( + peak_energy, + label_text, + color, + _, + y_value, + y_mode, + font_size, + font_weight, + alpha_value, + ) = group[0] + common = dict( + ha="center", + fontsize=font_size, + color=color, + weight=font_weight, + rotation=90, + alpha=alpha_value, + ) + if y_mode == "axes_top": + ax_spec.text( + peak_energy, + y_value, + label_text, + va="top", + transform=ax_spec.get_xaxis_transform(), + clip_on=True, + **common, + ) else: - ax_spec.text(peak_energy, y_value, label_text, va='bottom', **common) + ax_spec.text(peak_energy, y_value, label_text, va="bottom", **common) else: for _, label_text, color, linestyle, *_ in group: key = (label_text, str(color), linestyle) if key in legend_labels: continue legend_labels.add(key) - legend_handles.append(Line2D([0], [0], color=color, linestyle=linestyle, linewidth=1.5, label=label_text)) + legend_handles.append( + Line2D( + [0], + [0], + color=color, + linestyle=linestyle, + linewidth=1.5, + label=label_text, + ) + ) if legend_handles: - overlap_legend = ax_spec.legend(handles=legend_handles, loc='upper right', fontsize=8, title='Overlapping Labels') + overlap_legend = ax_spec.legend( + handles=legend_handles, loc="upper right", fontsize=8, title="Overlapping Labels" + ) ax_spec.add_artist(overlap_legend) fig.tight_layout() plt.show() sorted_table_rows = sorted(table_rows, key=lambda item: item[0]) - print(f"{'Energy (keV)':<12} {'Intensity':<12} {'SNR':<8} {'Best Match':<22} {'Alt 2':<22} {'Alt 3':<22}") - print('-' * 105) + print( + f"{'Energy (keV)':<12} {'Intensity':<12} {'SNR':<8} {'Best Match':<22} {'Alt 2':<22} {'Alt 3':<22}" + ) + print("-" * 105) for peak_energy, height, snr, best_match, alt_2, alt_3 in sorted_table_rows: - print(f'{peak_energy:<12.3f} {height:<12.2f} {snr:<8.1f} {best_match:<22} {alt_2:<22} {alt_3:<22}') - print('-' * 105) - print(f'{len(display_peaks)} of {len(significant_peaks)} peaks above snr_min={snr_min:.1f}, snr_threshold={snr_threshold:.1f} displayed.\n') + print( + f"{peak_energy:<12.3f} {height:<12.2f} {snr:<8.1f} {best_match:<22} {alt_2:<22} {alt_3:<22}" + ) + print("-" * 105) + print( + f"{len(display_peaks)} of {len(significant_peaks)} peaks above snr_min={snr_min:.1f}, snr_threshold={snr_threshold:.1f} displayed.\n" + ) if return_details: return { - 'figure': fig, - 'axes': (ax_img, ax_spec), - 'detected_elements': sorted(detected_elements), - 'element_confidence': element_confidence, - 'display_peaks': display_peaks, - 'peak_matches': peak_matches, - 'snr_min': snr_min, - 'snr_threshold': snr_threshold, + "figure": fig, + "axes": (ax_img, ax_spec), + "detected_elements": sorted(detected_elements), + "element_confidence": element_confidence, + "display_peaks": display_peaks, + "peak_matches": peak_matches, + "snr_min": snr_min, + "snr_threshold": snr_threshold, } return fig, (ax_img, ax_spec) From 619071cd8aa27a776788cb7756c86ea0a34424ab Mon Sep 17 00:00:00 2001 From: Sangoda <87961379+Sangoda@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:04:41 -0700 Subject: [PATCH 107/136] convert eV to keV in read_3d_spectroscopy; refactor model_elements checks in Dataset3dspectroscopy --- src/quantem/core/io/file_readers.py | 6 +++ src/quantem/spectroscopy/dataset3deds.py | 4 +- .../spectroscopy/dataset3dspectroscopy.py | 38 +++++++++++++++---- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/quantem/core/io/file_readers.py b/src/quantem/core/io/file_readers.py index 64e15e82..bd9447cd 100644 --- a/src/quantem/core/io/file_readers.py +++ b/src/quantem/core/io/file_readers.py @@ -188,6 +188,12 @@ def read_3d_spectroscopy( for ax in ordered_axes ] + for i, unit in enumerate(units): + if unit == "eV": + sampling[i] = sampling[i] / 1000 + origin[i] = origin[i] / 1000 + units[i] = "keV" + if data_type == "EELS": dataset_cls = Dataset3deels elif data_type == "EDS": diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index c7560eab..3299392a 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -1929,7 +1929,7 @@ def fit_spectrum_mean_pytorch( raise ValueError("CUDA device requested but torch.cuda.is_available() is False.") if elements_to_fit is None: - if self.model_elements is None: + if not self.model_elements: raise ValueError("elements_to_fit must be specified") elements_to_fit = list(self.model_elements.keys()) print(f"using model_elements {elements_to_fit}") @@ -2205,7 +2205,7 @@ def _normalize_choice(name, param_name, allowed_values): raise ValueError("constrain_background must be >= 0") if elements_to_fit is None: - if self.model_elements is None: + if not self.model_elements: raise ValueError("elements_to_fit must be specified") elements_to_fit = list(self.model_elements.keys()) if verbose: diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index c53506cf..77f8b338 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -14,6 +14,31 @@ from quantem.spectroscopy.utils import load_xray_lines_database +class _ModelElementsDict(dict): + """dict subclass for model_elements with a readable repr.""" + + def __repr__(self): + if not self: + return "Model Elements:\n None" + lines = ["Model Elements:"] + for element, line_info in self.items(): + if isinstance(line_info, dict) and line_info: + line_names = ', '.join(sorted(line_info.keys())) + lines.append(f" {element}: {line_names}") + else: + lines.append(f" {element}") + return '\n'.join(lines) + + def _repr_html_(self): + if not self: + return "Model Elements:
  None" + rows = "".join( + f"{el}{', '.join(sorted(info.keys())) if isinstance(info, dict) and info else ''}" + for el, info in self.items() + ) + return f"Model Elements:{rows}
" + + class Dataset3dspectroscopy(Dataset3d): # stores the element line info so you don't need to reload each time element_info = None @@ -40,7 +65,7 @@ def __init__( _token=type(self)._token if _token is None else _token, ) - self.model_elements = None + self.model_elements = _ModelElementsDict() self.attached_spectra = None # loads elemental information @@ -159,9 +184,6 @@ def add_elements_to_model(self, elements): return specs = type(self)._normalize_element_specs(elements) - if self.model_elements is None: - self.model_elements = {} - added_this_call = {} for spec in specs: @@ -201,7 +223,7 @@ def add_elements_to_model(self, elements): added_this_call[element_key] = [] added_this_call[element_key].extend(added_keys) if not self.model_elements: - self.model_elements = None + self.model_elements = _ModelElementsDict() if added_this_call: print("Added to model:") @@ -225,7 +247,7 @@ def remove_elements_from_model(self, elements): - 'Te La' (remove only Te La line) - ['Au Ma', 'Te La'] """ - if self.model_elements is None: + if not self.model_elements: return specs = type(self)._normalize_element_specs(elements) @@ -257,11 +279,11 @@ def remove_elements_from_model(self, elements): self.model_elements.pop(element_key, None) if not self.model_elements: - self.model_elements = None + self.model_elements = _ModelElementsDict() def clear_model_elements(self): """Clear all elements from the model.""" - self.model_elements = None + self.model_elements = _ModelElementsDict() # Storage of spectra alongside dataset From cf19ca3112fd2639dfc7b05487efc0be8d07cecc Mon Sep 17 00:00:00 2001 From: smribet Date: Wed, 22 Apr 2026 08:43:33 -0700 Subject: [PATCH 108/136] quick fix --- src/quantem/core/io/file_readers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/quantem/core/io/file_readers.py b/src/quantem/core/io/file_readers.py index bd9447cd..0e358f29 100644 --- a/src/quantem/core/io/file_readers.py +++ b/src/quantem/core/io/file_readers.py @@ -189,7 +189,7 @@ def read_3d_spectroscopy( ] for i, unit in enumerate(units): - if unit == "eV": + if unit == "eV" and data_type == "EDS": sampling[i] = sampling[i] / 1000 origin[i] = origin[i] / 1000 units[i] = "keV" From 71edf75b59b1ddabba8ae851d8b4fce3ec9912f7 Mon Sep 17 00:00:00 2001 From: smribet Date: Thu, 23 Apr 2026 18:01:59 -0700 Subject: [PATCH 109/136] updating xray csv --- src/quantem/spectroscopy/x_ray_lines.csv | 1344 +++++++++++----------- 1 file changed, 672 insertions(+), 672 deletions(-) diff --git a/src/quantem/spectroscopy/x_ray_lines.csv b/src/quantem/spectroscopy/x_ray_lines.csv index 3e45b4ef..e0fd039a 100644 --- a/src/quantem/spectroscopy/x_ray_lines.csv +++ b/src/quantem/spectroscopy/x_ray_lines.csv @@ -1,702 +1,702 @@ -energy_eV,atomic_number,element,line,relative_intensity,col4_norm -22162.9,47,Ag,Ka1,100,0.662252 -21990.3,47,Ag,Ka2,53,0.350993 -24942.4,47,Ag,Kb1,16,0.10596 -25456.4,47,Ag,Kb2,4,0.02649 -24911.5,47,Ag,Kb3,9,0.059603 -2984.3,47,Ag,La1,100,0.900901 -2978.2,47,Ag,La2,11,0.099099 -3150.9,47,Ag,Lb1,56,0.504505 -3347.8,47,Ag,"Lb2,15",13,0.117117 -3519.6,47,Ag,Lg1,6,0.054054 -2633.7,47,Ag,Ll,4,0.036036 -1486.7,13,Al,Ka1,100,0.662252 +energy_eV,atomic_number,element,line,relative_intensity,weight +2633.7,47,Ag,Ll,4,0.021053 +2978.2,47,Ag,La2,11,0.057895 +2984.3,47,Ag,La1,100,0.526316 +3150.9,47,Ag,Lb1,56,0.294737 +3347.8,47,Ag,"Lb2,15",13,0.068421 +3519.6,47,Ag,Lg1,6,0.031579 +21990.3,47,Ag,Ka2,53,0.291209 +22162.9,47,Ag,Ka1,100,0.549451 +24911.5,47,Ag,Kb3,9,0.049451 +24942.4,47,Ag,Kb1,16,0.087912 +25456.4,47,Ag,Kb2,4,0.021978 1486.3,13,Al,Ka2,50,0.331126 +1486.7,13,Al,Ka1,100,0.662252 1557.4,13,Al,Kb1,1,0.006623 -2957.7,18,Ar,Ka1,100,0.662252 -2955.6,18,Ar,Ka2,50,0.331126 -3190.5,18,Ar,"Kb1,3",10,0.066225 -10543.7,33,As,Ka1,100,0.662252 -10508,33,As,Ka2,51,0.337748 -11726.2,33,As,Kb1,13,0.086093 -11864,33,As,Kb2,1,0.006623 -11720.3,33,As,Kb3,6,0.039735 -1282,33,As,"La1,2",111,1 -1317,33,As,Lb1,60,0.540541 -1120,33,As,Ll,6,0.054054 -68803.7,79,Au,Ka1,100,0.662252 -66989.5,79,Au,"Ka1,2",2,0.013245 -77984,79,Au,Kb1,23,0.152318 -80150,79,Au,Kb2,8,0.05298 -77580,79,Au,Kb3,12,0.07947 -9713.3,79,Au,La1,100,0.900901 -9628,79,Au,La2,11,0.099099 -11442.3,79,Au,Lb1,67,0.603604 -11584.7,79,Au,Lb2,23,0.207207 -13381.7,79,Au,Lg1,13,0.117117 -8493.9,79,Au,Ll,5,0.045045 +2955.6,18,Ar,Ka2,50,0.3125 +2957.7,18,Ar,Ka1,100,0.625 +3190.5,18,Ar,"Kb1,3",10,0.0625 +1120,33,As,Ll,6,0.033898 +1282,33,As,"La1,2",111,0.627119 +1317,33,As,Lb1,60,0.338983 +10508,33,As,Ka2,51,0.298246 +10543.7,33,As,Ka1,100,0.584795 +11720.3,33,As,Kb3,6,0.035088 +11726.2,33,As,Kb1,13,0.076023 +11864,33,As,Kb2,1,0.005848 2122.9,79,Au,Ma1,100,1 +8493.9,79,Au,Ll,5,0.022831 +9628,79,Au,La2,11,0.050228 +9713.3,79,Au,La1,100,0.456621 +11442.3,79,Au,Lb1,67,0.305936 +11584.7,79,Au,Lb2,23,0.105023 +13381.7,79,Au,Lg1,13,0.059361 +66989.5,79,Au,Ka2,59,0.292079 +68803.7,79,Au,Ka1,100,0.49505 +77580,79,Au,Kb3,12,0.059406 +77984,79,Au,Kb1,23,0.113861 +80150,79,Au,Kb2,8,0.039604 183.3,5,B,"Ka1,2",151,1 -32193.6,56,Ba,Ka1,100,0.662252 -31817.1,56,Ba,Ka2,54,0.357616 -36378.2,56,Ba,Kb1,18,0.119205 -37257,56,Ba,Kb2,6,0.039735 -36304,56,Ba,Kb3,10,0.066225 -4466.3,56,Ba,La1,100,0.900901 -4450.9,56,Ba,La2,11,0.099099 -4827.5,56,Ba,Lb1,60,0.540541 -5156.5,56,Ba,"Lb2,15",20,0.18018 -5531.1,56,Ba,Lg1,9,0.081081 -3954.1,56,Ba,Ll,4,0.036036 -108.5,4,Be,"Ka1,2",150,0.993377 -77107.9,83,Bi,Ka1,100,0.662252 -74814.8,83,Bi,Ka2,60,0.397351 -87343,83,Bi,Kb1,23,0.152318 -89830,83,Bi,Kb2,9,0.059603 -86834,83,Bi,Kb3,12,0.07947 -10838.8,83,Bi,La1,100,0.900901 -10730.9,83,Bi,La2,11,0.099099 -13023.5,83,Bi,Lb1,67,0.603604 -12979.9,83,Bi,Lb2,25,0.225225 -15247.7,83,Bi,Lg1,14,0.126126 -9420.4,83,Bi,Ll,6,0.054054 +3954.1,56,Ba,Ll,4,0.019608 +4450.9,56,Ba,La2,11,0.053922 +4466.3,56,Ba,La1,100,0.490196 +4827.5,56,Ba,Lb1,60,0.294118 +5156.5,56,Ba,"Lb2,15",20,0.098039 +5531.1,56,Ba,Lg1,9,0.044118 +31817.1,56,Ba,Ka2,54,0.287234 +32193.6,56,Ba,Ka1,100,0.531915 +36304,56,Ba,Kb3,10,0.053191 +36378.2,56,Ba,Kb1,18,0.095745 +37257,56,Ba,Kb2,6,0.031915 +108.5,4,Be,"Ka1,2",150,1 2422.6,83,Bi,Ma1,100,1 -11924.2,35,Br,Ka1,100,0.662252 -11877.6,35,Br,Ka2,52,0.344371 -13291.4,35,Br,Kb1,14,0.092715 -13469.5,35,Br,Kb2,1,0.006623 -13284.5,35,Br,Kb3,7,0.046358 -1480.4,35,Br,"La1,2",111,1 -1525.9,35,Br,Lb1,59,0.531532 -1293.5,35,Br,Ll,5,0.045045 -277,6,C,"Ka1,2",147,0.97351 -3691.7,20,Ca,Ka1,100,0.662252 -3688.1,20,Ca,Ka2,50,0.331126 -4012.7,20,Ca,"Kb1,3",13,0.086093 -23173.6,48,Cd,Ka1,100,0.662252 -22984.1,48,Cd,Ka2,53,0.350993 -26095.5,48,Cd,Kb1,17,0.112583 -26643.8,48,Cd,Kb2,4,0.02649 -26061.2,48,Cd,Kb3,9,0.059603 -3133.7,48,Cd,La1,100,0.900901 -3126.9,48,Cd,La2,11,0.099099 -3316.6,48,Cd,Lb1,58,0.522523 -3528.1,48,Cd,"Lb2,15",15,0.135135 -3716.9,48,Cd,Lg1,6,0.054054 -2767.4,48,Cd,Ll,4,0.036036 -34719.7,58,Ce,Ka1,100,0.662252 -34278.9,58,Ce,Ka2,55,0.364238 -39257.3,58,Ce,Kb1,19,0.125828 -40233,58,Ce,Kb2,6,0.039735 -39170.1,58,Ce,Kb3,10,0.066225 -4840.2,58,Ce,La1,100,0.900901 -4823,58,Ce,La2,11,0.099099 -5262.2,58,Ce,Lb1,61,0.54955 -5613.4,58,Ce,"Lb2,15",21,0.189189 -6052,58,Ce,Lg1,9,0.081081 -4287.5,58,Ce,Ll,4,0.036036 +9420.4,83,Bi,Ll,6,0.026906 +10730.9,83,Bi,La2,11,0.049327 +10838.8,83,Bi,La1,100,0.44843 +12979.9,83,Bi,Lb2,25,0.112108 +13023.5,83,Bi,Lb1,67,0.300448 +15247.7,83,Bi,Lg1,14,0.06278 +74814.8,83,Bi,Ka2,60,0.294118 +77107.9,83,Bi,Ka1,100,0.490196 +86834,83,Bi,Kb3,12,0.058824 +87343,83,Bi,Kb1,23,0.112745 +89830,83,Bi,Kb2,9,0.044118 +1293.5,35,Br,Ll,5,0.028571 +1480.4,35,Br,"La1,2",111,0.634286 +1525.9,35,Br,Lb1,59,0.337143 +11877.6,35,Br,Ka2,52,0.298851 +11924.2,35,Br,Ka1,100,0.574713 +13284.5,35,Br,Kb3,7,0.04023 +13291.4,35,Br,Kb1,14,0.08046 +13469.5,35,Br,Kb2,1,0.005747 +277,6,C,"Ka1,2",147,1 +3688.1,20,Ca,Ka2,50,0.306748 +3691.7,20,Ca,Ka1,100,0.613497 +4012.7,20,Ca,"Kb1,3",13,0.079755 +2767.4,48,Cd,Ll,4,0.020619 +3126.9,48,Cd,La2,11,0.056701 +3133.7,48,Cd,La1,100,0.515464 +3316.6,48,Cd,Lb1,58,0.298969 +3528.1,48,Cd,"Lb2,15",15,0.07732 +3716.9,48,Cd,Lg1,6,0.030928 +22984.1,48,Cd,Ka2,53,0.289617 +23173.6,48,Cd,Ka1,100,0.546448 +26061.2,48,Cd,Kb3,9,0.04918 +26095.5,48,Cd,Kb1,17,0.092896 +26643.8,48,Cd,Kb2,4,0.021858 883,58,Ce,Ma1,100,1 -2622.4,17,Cl,Ka1,100,0.662252 -2620.8,17,Cl,Ka2,50,0.331126 -2815.6,17,Cl,Kb1,6,0.039735 -6930.3,27,Co,Ka1,100,0.662252 -6915.3,27,Co,Ka2,51,0.337748 -7649.4,27,Co,"Kb1,3",17,0.112583 -776.2,27,Co,"La1,2",111,1 -791.4,27,Co,Lb1,76,0.684685 -677.8,27,Co,Ll,10,0.09009 -5414.7,24,Cr,Ka1,100,0.662252 -5405.5,24,Cr,Ka2,50,0.331126 -5946.7,24,Cr,"Kb1,3",15,0.099338 -572.8,24,Cr,"La1,2",111,1 -582.8,24,Cr,Lb1,79,0.711712 -500.3,24,Cr,Ll,17,0.153153 -30972.8,55,Cs,"Ka1,2",1,0.006623 -30625.1,55,Cs,Ka2,54,0.357616 -34986.9,55,Cs,Kb1,18,0.119205 -35822,55,Cs,Kb2,6,0.039735 -34919.4,55,Cs,Kb3,9,0.059603 -4286.5,55,Cs,La1,100,0.900901 -4272.2,55,Cs,La2,11,0.099099 -4619.8,55,Cs,Lb1,61,0.54955 -4935.9,55,Cs,"Lb2,15",20,0.18018 -5280.4,55,Cs,Lg1,8,0.072072 -3795,55,Cs,Ll,4,0.036036 -8047.8,29,Cu,Ka1,100,0.662252 -8027.8,29,Cu,Ka2,51,0.337748 -8905.3,29,Cu,"Kb1,3",17,0.112583 -929.7,29,Cu,"La1,2",111,1 -949.8,29,Cu,Lb1,65,0.585586 -811.1,29,Cu,Ll,8,0.072072 -45998.4,66,Dy,Ka1,100,0.662252 -45207.8,66,Dy,Ka2,56,0.370861 -52119,66,Dy,Kb1,20,0.13245 -53476,66,Dy,Kb2,7,0.046358 -51957,66,Dy,Kb3,10,0.066225 -6495.2,66,Dy,La1,100,0.900901 -6457.7,66,Dy,La2,11,0.099099 -7247.7,66,Dy,Lb1,62,0.558559 -7635.7,66,Dy,Lb2,20,0.18018 -8418.8,66,Dy,Lg1,11,0.099099 -5743.1,66,Dy,Ll,4,0.036036 +4287.5,58,Ce,Ll,4,0.019417 +4823,58,Ce,La2,11,0.053398 +4840.2,58,Ce,La1,100,0.485437 +5262.2,58,Ce,Lb1,61,0.296117 +5613.4,58,Ce,"Lb2,15",21,0.101942 +6052,58,Ce,Lg1,9,0.043689 +34278.9,58,Ce,Ka2,55,0.289474 +34719.7,58,Ce,Ka1,100,0.526316 +39170.1,58,Ce,Kb3,10,0.052632 +39257.3,58,Ce,Kb1,19,0.1 +40233,58,Ce,Kb2,6,0.031579 +2620.8,17,Cl,Ka2,50,0.320513 +2622.4,17,Cl,Ka1,100,0.641026 +2815.6,17,Cl,Kb1,6,0.038462 +677.8,27,Co,Ll,10,0.050761 +776.2,27,Co,"La1,2",111,0.563452 +791.4,27,Co,Lb1,76,0.385787 +6915.3,27,Co,Ka2,51,0.303571 +6930.3,27,Co,Ka1,100,0.595238 +7649.4,27,Co,"Kb1,3",17,0.10119 +500.3,24,Cr,Ll,17,0.082126 +572.8,24,Cr,"La1,2",111,0.536232 +582.8,24,Cr,Lb1,79,0.381643 +5405.5,24,Cr,Ka2,50,0.30303 +5414.7,24,Cr,Ka1,100,0.606061 +5946.7,24,Cr,"Kb1,3",15,0.090909 +3795,55,Cs,Ll,4,0.019608 +4272.2,55,Cs,La2,11,0.053922 +4286.5,55,Cs,La1,100,0.490196 +4619.8,55,Cs,Lb1,61,0.29902 +4935.9,55,Cs,"Lb2,15",20,0.098039 +5280.4,55,Cs,Lg1,8,0.039216 +30625.1,55,Cs,Ka2,54,0.28877 +30972.8,55,Cs,Ka1,100,0.534759 +34919.4,55,Cs,Kb3,9,0.048128 +34986.9,55,Cs,Kb1,18,0.096257 +35822,55,Cs,Kb2,6,0.032086 +811.1,29,Cu,Ll,8,0.043478 +929.7,29,Cu,"La1,2",111,0.603261 +949.8,29,Cu,Lb1,65,0.353261 +8027.8,29,Cu,Ka2,51,0.303571 +8047.8,29,Cu,Ka1,100,0.595238 +8905.3,29,Cu,"Kb1,3",17,0.10119 1293,66,Dy,Ma1,100,1 -49127.7,68,Er,Ka1,100,0.662252 -48221.1,68,Er,Ka2,56,0.370861 -55681,68,Er,Kb1,21,0.139073 -57210,68,Er,Kb2,7,0.046358 -55494,68,Er,Kb3,11,0.072848 -6948.7,68,Er,La1,100,0.900901 -6905,68,Er,La2,11,0.099099 -7810.9,68,Er,Lb1,64,0.576577 -8189,68,Er,"Lb2,15",20,0.18018 -9089,68,Er,Lg1,11,0.099099 -6152,68,Er,Ll,4,0.036036 +5743.1,66,Dy,Ll,4,0.019231 +6457.7,66,Dy,La2,11,0.052885 +6495.2,66,Dy,La1,100,0.480769 +7247.7,66,Dy,Lb1,62,0.298077 +7635.7,66,Dy,Lb2,20,0.096154 +8418.8,66,Dy,Lg1,11,0.052885 +45207.8,66,Dy,Ka2,56,0.290155 +45998.4,66,Dy,Ka1,100,0.518135 +51957,66,Dy,Kb3,10,0.051813 +52119,66,Dy,Kb1,20,0.103627 +53476,66,Dy,Kb2,7,0.036269 1406,68,Er,Ma1,100,1 -41542.2,63,Eu,Ka1,100,0.662252 -40901.9,63,Eu,Ka2,56,0.370861 -47037.9,63,Eu,Kb1,19,0.125828 -48256,63,Eu,Kb2,6,0.039735 -46903.6,63,Eu,Kb3,10,0.066225 -5845.7,63,Eu,La1,100,0.900901 -5816.6,63,Eu,La2,11,0.099099 -6456.4,63,Eu,Lb1,62,0.558559 -6843.2,63,Eu,"Lb2,15",21,0.189189 -7480.3,63,Eu,Lg1,10,0.09009 -5177.2,63,Eu,Ll,4,0.036036 +6152,68,Er,Ll,4,0.019048 +6905,68,Er,La2,11,0.052381 +6948.7,68,Er,La1,100,0.47619 +7810.9,68,Er,Lb1,64,0.304762 +8189,68,Er,"Lb2,15",20,0.095238 +9089,68,Er,Lg1,11,0.052381 +48221.1,68,Er,Ka2,56,0.287179 +49127.7,68,Er,Ka1,100,0.512821 +55494,68,Er,Kb3,11,0.05641 +55681,68,Er,Kb1,21,0.107692 +57210,68,Er,Kb2,7,0.035897 1131,63,Eu,Ma1,100,1 -676.8,9,F,"Ka1,2",148,0.980132 -6403.8,26,Fe,Ka1,100,0.662252 -6390.8,26,Fe,Ka2,50,0.331126 -7058,26,Fe,"Kb1,3",17,0.112583 -705,26,Fe,"La1,2",111,1 -718.5,26,Fe,Lb1,66,0.594595 -615.2,26,Fe,Ll,10,0.09009 -9251.7,31,Ga,Ka1,100,0.662252 -9224.8,31,Ga,Ka2,51,0.337748 -10264.2,31,Ga,Kb1,66,0.437086 -10260.3,31,Ga,Kb3,5,0.033113 -1097.9,31,Ga,"La1,2",111,1 -1124.8,31,Ga,Lb1,66,0.594595 -957.2,31,Ga,Ll,7,0.063063 -42996.2,64,Gd,Ka1,100,0.662252 -42308.9,64,Gd,Ka2,56,0.370861 -48697,64,Gd,Kb1,20,0.13245 -49959,64,Gd,Kb2,7,0.046358 -48555,64,Gd,Kb3,10,0.066225 -6057.2,64,Gd,La1,100,0.900901 -6025,64,Gd,La2,11,0.099099 -6713.2,64,Gd,Lb1,1,0.009009 -7102.8,64,Gd,"Lb2,15",21,0.189189 -7785.8,64,Gd,Lg1,11,0.099099 -5362.1,64,Gd,Ll,4,0.036036 +5177.2,63,Eu,Ll,4,0.019231 +5816.6,63,Eu,La2,11,0.052885 +5845.7,63,Eu,La1,100,0.480769 +6456.4,63,Eu,Lb1,62,0.298077 +6843.2,63,Eu,"Lb2,15",21,0.100962 +7480.3,63,Eu,Lg1,10,0.048077 +40901.9,63,Eu,Ka2,56,0.293194 +41542.2,63,Eu,Ka1,100,0.52356 +46903.6,63,Eu,Kb3,10,0.052356 +47037.9,63,Eu,Kb1,19,0.099476 +48256,63,Eu,Kb2,6,0.031414 +676.8,9,F,"Ka1,2",148,1 +615.2,26,Fe,Ll,10,0.053476 +705,26,Fe,"La1,2",111,0.593583 +718.5,26,Fe,Lb1,66,0.352941 +6390.8,26,Fe,Ka2,50,0.299401 +6403.8,26,Fe,Ka1,100,0.598802 +7058,26,Fe,"Kb1,3",17,0.101796 +957.2,31,Ga,Ll,7,0.038043 +1097.9,31,Ga,"La1,2",111,0.603261 +1124.8,31,Ga,Lb1,66,0.358696 +9224.8,31,Ga,Ka2,51,0.22973 +9251.7,31,Ga,Ka1,100,0.45045 +10260.3,31,Ga,Kb3,5,0.022523 +10264.2,31,Ga,Kb1,66,0.297297 1185,64,Gd,Ma1,100,1 -9886.4,32,Ge,Ka1,100,0.662252 -9855.3,32,Ge,Ka2,51,0.337748 -10982.1,32,Ge,Kb1,60,0.397351 -10978,32,Ge,Kb3,6,0.039735 -1188,32,Ge,"La1,2",111,1 -1218.5,32,Ge,Lb1,60,0.540541 -1036.2,32,Ge,Ll,6,0.054054 -55790.2,72,Hf,Ka1,100,0.662252 -54611.4,72,Hf,Ka2,57,0.377483 -63234,72,Hf,Kb1,22,0.145695 -64980,72,Hf,Kb2,7,0.046358 -62980,72,Hf,Kb3,11,0.072848 -7899,72,Hf,La1,100,0.900901 -7844.6,72,Hf,La2,11,0.099099 -9022.7,72,Hf,Lb1,67,0.603604 -9347.3,72,Hf,Lb2,20,0.18018 -10515.8,72,Hf,Lg1,12,0.108108 -6959.6,72,Hf,Ll,5,0.045045 +5362.1,64,Gd,Ll,4,0.019139 +6025,64,Gd,La2,11,0.052632 +6057.2,64,Gd,La1,100,0.478469 +6713.2,64,Gd,Lb1,62,0.296651 +7102.8,64,Gd,"Lb2,15",21,0.100478 +7785.8,64,Gd,Lg1,11,0.052632 +42308.9,64,Gd,Ka2,56,0.290155 +42996.2,64,Gd,Ka1,100,0.518135 +48555,64,Gd,Kb3,10,0.051813 +48697,64,Gd,Kb1,20,0.103627 +49959,64,Gd,Kb2,7,0.036269 +1036.2,32,Ge,Ll,6,0.033898 +1188,32,Ge,"La1,2",111,0.627119 +1218.5,32,Ge,Lb1,60,0.338983 +9855.3,32,Ge,Ka2,51,0.235023 +9886.4,32,Ge,Ka1,100,0.460829 +10978,32,Ge,Kb3,6,0.02765 +10982.1,32,Ge,Kb1,60,0.276498 1644.6,72,Hf,Ma1,100,1 -70819,80,Hg,Ka1,100,0.662252 -68895,80,Hg,Ka2,59,0.390728 -80253,80,Hg,Kb1,23,0.152318 -82515,80,Hg,Kb2,8,0.05298 -79822,80,Hg,Kb3,12,0.07947 -9988.8,80,Hg,La1,100,0.900901 -9897.6,80,Hg,La2,11,0.099099 -11822.6,80,Hg,Lb1,67,0.603604 -11924.1,80,Hg,Lb2,24,0.216216 -13830.1,80,Hg,Lg1,14,0.126126 -8721,80,Hg,Ll,5,0.045045 +6959.6,72,Hf,Ll,5,0.023256 +7844.6,72,Hf,La2,11,0.051163 +7899,72,Hf,La1,100,0.465116 +9022.7,72,Hf,Lb1,67,0.311628 +9347.3,72,Hf,Lb2,20,0.093023 +10515.8,72,Hf,Lg1,12,0.055814 +54611.4,72,Hf,Ka2,57,0.28934 +55790.2,72,Hf,Ka1,100,0.507614 +62980,72,Hf,Kb3,11,0.055838 +63234,72,Hf,Kb1,22,0.111675 +64980,72,Hf,Kb2,7,0.035533 2195.3,80,Hg,Ma1,100,1 -47546.7,67,Ho,Ka1,100,0.662252 -46699.7,67,Ho,Ka2,56,0.370861 -53877,67,Ho,Kb1,20,0.13245 -55293,67,Ho,Kb2,7,0.046358 -53711,67,Ho,Kb3,11,0.072848 -6719.8,67,Ho,La1,100,0.900901 -6679.5,67,Ho,La2,11,0.099099 -7525.3,67,Ho,Lb1,64,0.576577 -7911,67,Ho,"Lb2,15",20,0.18018 -8747,67,Ho,Lg1,11,0.099099 -5943.4,67,Ho,Ll,4,0.036036 +8721,80,Hg,Ll,5,0.022624 +9897.6,80,Hg,La2,11,0.049774 +9988.8,80,Hg,La1,100,0.452489 +11822.6,80,Hg,Lb1,67,0.303167 +11924.1,80,Hg,Lb2,24,0.108597 +13830.1,80,Hg,Lg1,14,0.063348 +68895,80,Hg,Ka2,59,0.292079 +70819,80,Hg,Ka1,100,0.49505 +79822,80,Hg,Kb3,12,0.059406 +80253,80,Hg,Kb1,23,0.113861 +82515,80,Hg,Kb2,8,0.039604 1348,67,Ho,Ma1,100,1 -28612,53,I,Ka1,100,0.662252 -28317.2,53,I,Ka2,54,0.357616 -32294.7,53,I,Kb1,18,0.119205 -33042,53,I,Kb2,5,0.033113 -32239.4,53,I,Kb3,9,0.059603 -3937.6,53,I,"La1,2",1,0.009009 -3926,53,I,La2,11,0.099099 -4220.7,53,I,Lb1,61,0.54955 -4507.5,53,I,"Lb2,15",19,0.171171 -4800.9,53,I,Lg1,8,0.072072 -3485,53,I,Ll,4,0.036036 -24209.7,49,In,Ka1,100,0.662252 -24002,49,In,Ka2,53,0.350993 -27275.9,49,In,Kb1,17,0.112583 -27860.8,49,In,Kb2,5,0.033113 -27237.7,49,In,Kb3,9,0.059603 -3286.9,49,In,La1,100,0.900901 -3279.3,49,In,La2,11,0.099099 -3487.2,49,In,Lb1,1,0.009009 -3713.8,49,In,"Lb2,15",15,0.135135 -3920.8,49,In,Lg1,6,0.054054 -2904.4,49,In,Ll,4,0.036036 -64895.6,77,Ir,Ka1,100,0.662252 -63286.7,77,Ir,Ka2,58,0.384106 -73560.8,77,Ir,Kb1,23,0.152318 -75575,77,Ir,Kb2,8,0.05298 -73202.7,77,Ir,Kb3,12,0.07947 -9175.1,77,Ir,La1,100,0.900901 -9099.5,77,Ir,La2,11,0.099099 -10708.3,77,Ir,Lb1,66,0.594595 -10920.3,77,Ir,Lb2,22,0.198198 -12512.6,77,Ir,Lg1,13,0.117117 -8045.8,77,Ir,Ll,5,0.045045 +5943.4,67,Ho,Ll,4,0.019048 +6679.5,67,Ho,La2,11,0.052381 +6719.8,67,Ho,La1,100,0.47619 +7525.3,67,Ho,Lb1,64,0.304762 +7911,67,Ho,"Lb2,15",20,0.095238 +8747,67,Ho,Lg1,11,0.052381 +46699.7,67,Ho,Ka2,56,0.28866 +47546.7,67,Ho,Ka1,100,0.515464 +53711,67,Ho,Kb3,11,0.056701 +53877,67,Ho,Kb1,20,0.103093 +55293,67,Ho,Kb2,7,0.036082 +3485,53,I,Ll,4,0.019704 +3926,53,I,La2,11,0.054187 +3937.6,53,I,La1,100,0.492611 +4220.7,53,I,Lb1,61,0.300493 +4507.5,53,I,"Lb2,15",19,0.093596 +4800.9,53,I,Lg1,8,0.039409 +28317.2,53,I,Ka2,54,0.290323 +28612,53,I,Ka1,100,0.537634 +32239.4,53,I,Kb3,9,0.048387 +32294.7,53,I,Kb1,18,0.096774 +33042,53,I,Kb2,5,0.026882 +2904.4,49,In,Ll,4,0.020619 +3279.3,49,In,La2,11,0.056701 +3286.9,49,In,La1,100,0.515464 +3487.2,49,In,Lb1,58,0.298969 +3713.8,49,In,"Lb2,15",15,0.07732 +3920.8,49,In,Lg1,6,0.030928 +24002,49,In,Ka2,53,0.288043 +24209.7,49,In,Ka1,100,0.543478 +27237.7,49,In,Kb3,9,0.048913 +27275.9,49,In,Kb1,17,0.092391 +27860.8,49,In,Kb2,5,0.027174 1979.9,77,Ir,Ma1,100,1 -3313.8,19,K,Ka1,100,0.662252 -3311.1,19,K,Ka2,50,0.331126 -3589.6,19,K,"Kb1,3",11,0.072848 -12649,36,Kr,Ka1,100,0.662252 -12598,36,Kr,"Ka1,2",2,0.013245 -14112,36,Kr,Kb1,14,0.092715 -14315,36,Kr,Kb2,2,0.013245 -14104,36,Kr,Kb3,7,0.046358 -1586,36,Kr,"La1,2",111,1 -1636.6,36,Kr,Lb1,57,0.513514 -1386,36,Kr,Ll,5,0.045045 -33441.8,57,La,Ka1,100,0.662252 -33034.1,57,La,Ka2,54,0.357616 -37801,57,La,Kb1,19,0.125828 -38729.9,57,La,Kb2,6,0.039735 -37720.2,57,La,Kb3,10,0.066225 -4651,57,La,La1,100,0.900901 -4634.2,57,La,La2,11,0.099099 -5042.1,57,La,Lb1,60,0.540541 -5383.5,57,La,"Lb2,15",21,0.189189 -5788.5,57,La,Lg1,9,0.081081 -4124,57,La,Ll,4,0.036036 +8045.8,77,Ir,Ll,5,0.023041 +9099.5,77,Ir,La2,11,0.050691 +9175.1,77,Ir,La1,100,0.460829 +10708.3,77,Ir,Lb1,66,0.304147 +10920.3,77,Ir,Lb2,22,0.101382 +12512.6,77,Ir,Lg1,13,0.059908 +63286.7,77,Ir,Ka2,58,0.288557 +64895.6,77,Ir,Ka1,100,0.497512 +73202.7,77,Ir,Kb3,12,0.059701 +73560.8,77,Ir,Kb1,23,0.114428 +75575,77,Ir,Kb2,8,0.039801 +3311.1,19,K,Ka2,50,0.310559 +3313.8,19,K,Ka1,100,0.621118 +3589.6,19,K,"Kb1,3",11,0.068323 +1386,36,Kr,Ll,5,0.028902 +1586,36,Kr,"La1,2",111,0.641618 +1636.6,36,Kr,Lb1,57,0.32948 +12598,36,Kr,Ka2,52,0.297143 +12649,36,Kr,Ka1,100,0.571429 +14104,36,Kr,Kb3,7,0.04 +14112,36,Kr,Kb1,14,0.08 +14315,36,Kr,Kb2,2,0.011429 833,57,La,Ma1,100,1 -54.3,3,Li,"Ka1,2",150,0.993377 -54069.8,71,Lu,Ka1,100,0.662252 -52965,71,Lu,Ka2,57,0.377483 -61283,71,Lu,Kb1,21,0.139073 -62970,71,Lu,Kb2,7,0.046358 -61050,71,Lu,Kb3,11,0.072848 -7655.5,71,Lu,La1,100,0.900901 -7604.9,71,Lu,La2,11,0.099099 -8709,71,Lu,Lb1,66,0.594595 -9048.9,71,Lu,Lb2,19,0.171171 -10143.4,71,Lu,Lg1,12,0.108108 -6752.8,71,Lu,Ll,4,0.036036 +4124,57,La,Ll,4,0.019512 +4634.2,57,La,La2,11,0.053659 +4651,57,La,La1,100,0.487805 +5042.1,57,La,Lb1,60,0.292683 +5383.5,57,La,"Lb2,15",21,0.102439 +5788.5,57,La,Lg1,9,0.043902 +33034.1,57,La,Ka2,54,0.285714 +33441.8,57,La,Ka1,100,0.529101 +37720.2,57,La,Kb3,10,0.05291 +37801,57,La,Kb1,19,0.100529 +38729.9,57,La,Kb2,6,0.031746 +54.3,3,Li,"Ka1,2",150,1 1581.3,71,Lu,Ma1,100,1 -1253.6,12,Mg,"Ka1,2",150,0.993377 -5898.8,25,Mn,Ka1,100,0.662252 -5887.6,25,Mn,Ka2,50,0.331126 -6490.4,25,Mn,"Kb1,3",17,0.112583 -637.4,25,Mn,"La1,2",111,1 -648.8,25,Mn,Lb1,77,0.693694 -556.3,25,Mn,Ll,15,0.135135 -17479.3,42,Mo,Ka1,100,0.662252 -17374.3,42,Mo,Ka2,52,0.344371 -19608.3,42,Mo,Kb1,15,0.099338 -19965.2,42,Mo,Kb2,3,0.019868 -19590.3,42,Mo,Kb3,8,0.05298 -2293.2,42,Mo,La1,100,0.900901 -2289.8,42,Mo,La2,11,0.099099 -2394.8,42,Mo,Lb1,53,0.477477 -2518.3,42,Mo,"Lb2,15",5,0.045045 -2623.5,42,Mo,Lg1,3,0.027027 -2015.7,42,Mo,Ll,5,0.045045 -392.4,7,N,"Ka1,2",150,0.993377 -1041,11,Na,"Ka1,2",150,0.993377 -16615.1,41,Nb,Ka1,100,0.662252 -16521,41,Nb,Ka2,52,0.344371 -18622.5,41,Nb,Kb1,15,0.099338 -18953,41,Nb,Kb2,3,0.019868 -18606.3,41,Nb,Kb3,8,0.05298 -2165.9,41,Nb,La1,100,0.900901 -2163,41,Nb,La2,11,0.099099 -2257.4,41,Nb,Lb1,52,0.468468 -2367,41,Nb,"Lb2,15",3,0.027027 -2461.8,41,Nb,Lg1,2,0.018018 -1902.2,41,Nb,Ll,5,0.045045 -37361,60,Nd,Ka1,100,0.662252 -36847.4,60,Nd,Ka2,55,0.364238 -42271.3,60,Nd,Kb1,19,0.125828 -43335,60,Nd,Kb2,6,0.039735 -42166.5,60,Nd,Kb3,10,0.066225 -5230.4,60,Nd,La1,100,0.900901 -5207.7,60,Nd,La2,11,0.099099 -5721.6,60,Nd,Lb1,60,0.540541 -6089.4,60,Nd,"Lb2,15",21,0.189189 -6602.1,60,Nd,Lg1,10,0.09009 -4633,60,Nd,Ll,4,0.036036 +6752.8,71,Lu,Ll,4,0.018868 +7604.9,71,Lu,La2,11,0.051887 +7655.5,71,Lu,La1,100,0.471698 +8709,71,Lu,Lb1,66,0.311321 +9048.9,71,Lu,Lb2,19,0.089623 +10143.4,71,Lu,Lg1,12,0.056604 +52965,71,Lu,Ka2,57,0.290816 +54069.8,71,Lu,Ka1,100,0.510204 +61050,71,Lu,Kb3,11,0.056122 +61283,71,Lu,Kb1,21,0.107143 +62970,71,Lu,Kb2,7,0.035714 +1253.6,12,Mg,"Ka1,2",150,1 +556.3,25,Mn,Ll,15,0.073892 +637.4,25,Mn,"La1,2",111,0.546798 +648.8,25,Mn,Lb1,77,0.37931 +5887.6,25,Mn,Ka2,50,0.299401 +5898.8,25,Mn,Ka1,100,0.598802 +6490.4,25,Mn,"Kb1,3",17,0.101796 +2015.7,42,Mo,Ll,5,0.028249 +2289.8,42,Mo,La2,11,0.062147 +2293.2,42,Mo,La1,100,0.564972 +2394.8,42,Mo,Lb1,53,0.299435 +2518.3,42,Mo,"Lb2,15",5,0.028249 +2623.5,42,Mo,Lg1,3,0.016949 +17374.3,42,Mo,Ka2,52,0.292135 +17479.3,42,Mo,Ka1,100,0.561798 +19590.3,42,Mo,Kb3,8,0.044944 +19608.3,42,Mo,Kb1,15,0.08427 +19965.2,42,Mo,Kb2,3,0.016854 +392.4,7,N,"Ka1,2",150,1 +1041,11,Na,"Ka1,2",150,1 +1902.2,41,Nb,Ll,5,0.028902 +2163,41,Nb,La2,11,0.063584 +2165.9,41,Nb,La1,100,0.578035 +2257.4,41,Nb,Lb1,52,0.300578 +2367,41,Nb,"Lb2,15",3,0.017341 +2461.8,41,Nb,Lg1,2,0.011561 +16521,41,Nb,Ka2,52,0.292135 +16615.1,41,Nb,Ka1,100,0.561798 +18606.3,41,Nb,Kb3,8,0.044944 +18622.5,41,Nb,Kb1,15,0.08427 +18953,41,Nb,Kb2,3,0.016854 978,60,Nd,Ma1,100,1 -848.6,10,Ne,"Ka1,2",150,0.993377 -7478.2,28,Ni,Ka1,100,0.662252 -7460.9,28,Ni,Ka2,51,0.337748 -8264.7,28,Ni,"Kb1,3",17,0.112583 -851.5,28,Ni,"La1,2",12,0.108108 -868.8,28,Ni,Lb1,68,0.612613 -742.7,28,Ni,Ll,9,0.081081 -524.9,8,O,"Ka1,2",12,0.07947 -63000.5,76,Os,Ka1,100,0.662252 -61486.7,76,Os,Ka2,58,0.384106 -71413,76,Os,Kb1,23,0.152318 -73363,76,Os,Kb2,8,0.05298 -71077,76,Os,Kb3,12,0.07947 -8911.7,76,Os,La1,100,0.900901 -8841,76,Os,La2,11,0.099099 -10355.3,76,Os,Lb1,67,0.603604 -10598.5,76,Os,Lb2,22,0.198198 -12095.3,76,Os,Lg1,13,0.117117 -7822.2,76,Os,Ll,5,0.045045 +4633,60,Nd,Ll,4,0.019417 +5207.7,60,Nd,La2,11,0.053398 +5230.4,60,Nd,La1,100,0.485437 +5721.6,60,Nd,Lb1,60,0.291262 +6089.4,60,Nd,"Lb2,15",21,0.101942 +6602.1,60,Nd,Lg1,10,0.048544 +36847.4,60,Nd,Ka2,55,0.289474 +37361,60,Nd,Ka1,100,0.526316 +42166.5,60,Nd,Kb3,10,0.052632 +42271.3,60,Nd,Kb1,19,0.1 +43335,60,Nd,Kb2,6,0.031579 +848.6,10,Ne,"Ka1,2",150,1 +742.7,28,Ni,Ll,9,0.047872 +851.5,28,Ni,"La1,2",111,0.590426 +868.8,28,Ni,Lb1,68,0.361702 +7460.9,28,Ni,Ka2,51,0.303571 +7478.2,28,Ni,Ka1,100,0.595238 +8264.7,28,Ni,"Kb1,3",17,0.10119 +524.9,8,O,"Ka1,2",151,1 1910.2,76,Os,Ma1,100,1 -2013.7,15,P,Ka1,100,0.662252 -2012.7,15,P,Ka2,50,0.331126 -2139.1,15,P,Kb1,3,0.019868 -74969.4,82,Pb,Ka1,100,0.662252 -72804.2,82,Pb,Ka2,60,0.397351 -84936,82,Pb,Kb1,23,0.152318 -87320,82,Pb,Kb2,8,0.05298 -84450,82,Pb,Kb3,12,0.07947 -10551.5,82,Pb,La1,100,0.900901 -10449.5,82,Pb,La2,11,0.099099 -12613.7,82,Pb,Lb1,66,0.594595 -12622.6,82,Pb,Lb2,25,0.225225 -14764.4,82,Pb,Lg1,14,0.126126 -9184.5,82,Pb,Ll,6,0.054054 +7822.2,76,Os,Ll,5,0.022936 +8841,76,Os,La2,11,0.050459 +8911.7,76,Os,La1,100,0.458716 +10355.3,76,Os,Lb1,67,0.307339 +10598.5,76,Os,Lb2,22,0.100917 +12095.3,76,Os,Lg1,13,0.059633 +61486.7,76,Os,Ka2,58,0.288557 +63000.5,76,Os,Ka1,100,0.497512 +71077,76,Os,Kb3,12,0.059701 +71413,76,Os,Kb1,23,0.114428 +73363,76,Os,Kb2,8,0.039801 +2012.7,15,P,Ka2,50,0.326797 +2013.7,15,P,Ka1,100,0.653595 +2139.1,15,P,Kb1,3,0.019608 2345.5,82,Pb,Ma1,100,1 -21177.1,46,Pd,Ka1,100,0.662252 -21020.1,46,Pd,Ka2,53,0.350993 -23818.7,46,Pd,Kb1,16,0.10596 -24299.1,46,Pd,Kb2,4,0.02649 -23791.1,46,Pd,Kb3,8,0.05298 -2838.6,46,Pd,La1,100,0.900901 -2833.3,46,Pd,La2,11,0.099099 -2990.2,46,Pd,Lb1,53,0.477477 -3171.8,46,Pd,"Lb2,15",12,0.108108 -3328.7,46,Pd,Lg1,6,0.054054 -2503.4,46,Pd,Ll,4,0.036036 -38724.7,61,Pm,Ka1,100,0.662252 -38171.2,61,Pm,Ka2,55,0.364238 -43826,61,Pm,Kb1,19,0.125828 -44942,61,Pm,Kb2,6,0.039735 -43713,61,Pm,Kb3,10,0.066225 -5432,61,Pm,La1,100,0.900901 -5408,61,Pm,La2,11,0.099099 -5961,61,Pm,Lb1,61,0.54955 -6339,61,Pm,Lb2,21,0.189189 -6892,61,Pm,Lg1,10,0.09009 -4809,61,Pm,Ll,4,0.036036 -36026.3,59,Pr,Ka1,100,0.662252 -35550.2,59,Pr,Ka2,55,0.364238 -40748.2,59,Pr,Kb1,19,0.125828 -41773,59,Pr,Kb2,6,0.039735 -40652.9,59,Pr,Kb3,10,0.066225 -5033.7,59,Pr,La1,100,0.900901 -5013.5,59,Pr,La2,11,0.099099 -5488.9,59,Pr,Lb1,61,0.54955 -5850,59,Pr,"Lb2,15",21,0.189189 -6322.1,59,Pr,Lg1,9,0.081081 -4453.2,59,Pr,Ll,4,0.036036 +9184.5,82,Pb,Ll,6,0.027027 +10449.5,82,Pb,La2,11,0.04955 +10551.5,82,Pb,La1,100,0.45045 +12613.7,82,Pb,Lb1,66,0.297297 +12622.6,82,Pb,Lb2,25,0.112613 +14764.4,82,Pb,Lg1,14,0.063063 +72804.2,82,Pb,Ka2,60,0.295567 +74969.4,82,Pb,Ka1,100,0.492611 +84450,82,Pb,Kb3,12,0.059113 +84936,82,Pb,Kb1,23,0.1133 +87320,82,Pb,Kb2,8,0.039409 +2503.4,46,Pd,Ll,4,0.021505 +2833.3,46,Pd,La2,11,0.05914 +2838.6,46,Pd,La1,100,0.537634 +2990.2,46,Pd,Lb1,53,0.284946 +3171.8,46,Pd,"Lb2,15",12,0.064516 +3328.7,46,Pd,Lg1,6,0.032258 +21020.1,46,Pd,Ka2,53,0.292818 +21177.1,46,Pd,Ka1,100,0.552486 +23791.1,46,Pd,Kb3,8,0.044199 +23818.7,46,Pd,Kb1,16,0.088398 +24299.1,46,Pd,Kb2,4,0.022099 +4809,61,Pm,Ll,4,0.019324 +5408,61,Pm,La2,11,0.05314 +5432,61,Pm,La1,100,0.483092 +5961,61,Pm,Lb1,61,0.294686 +6339,61,Pm,Lb2,21,0.101449 +6892,61,Pm,Lg1,10,0.048309 +38171.2,61,Pm,Ka2,55,0.289474 +38724.7,61,Pm,Ka1,100,0.526316 +43713,61,Pm,Kb3,10,0.052632 +43826,61,Pm,Kb1,19,0.1 +44942,61,Pm,Kb2,6,0.031579 929.2,59,Pr,Ma1,100,1 -66832,78,Pt,Ka1,100,0.662252 -65112,78,Pt,Ka2,58,0.384106 -75748,78,Pt,Kb1,23,0.152318 -77850,78,Pt,Kb2,8,0.05298 -75368,78,Pt,Kb3,12,0.07947 -9442.3,78,Pt,La1,100,0.900901 -9361.8,78,Pt,La2,11,0.099099 -11070.7,78,Pt,Lb1,67,0.603604 -11250.5,78,Pt,Lb1,2,0.018018 -12942,78,Pt,Lg1,13,0.117117 -8268,78,Pt,Ll,5,0.045045 +4453.2,59,Pr,Ll,4,0.019417 +5013.5,59,Pr,La2,11,0.053398 +5033.7,59,Pr,La1,100,0.485437 +5488.9,59,Pr,Lb1,61,0.296117 +5850,59,Pr,"Lb2,15",21,0.101942 +6322.1,59,Pr,Lg1,9,0.043689 +35550.2,59,Pr,Ka2,55,0.289474 +36026.3,59,Pr,Ka1,100,0.526316 +40652.9,59,Pr,Kb3,10,0.052632 +40748.2,59,Pr,Kb1,19,0.1 +41773,59,Pr,Kb2,6,0.031579 2050.5,78,Pt,Ma1,100,1 -13395.3,37,Rb,Ka1,100,0.662252 -13335.8,37,Rb,Ka2,52,0.344371 -14961.3,37,Rb,Kb1,14,0.092715 -15185,37,Rb,Kb2,2,0.013245 -14951.7,37,Rb,Kb3,7,0.046358 -1694.1,37,Rb,La1,100,0.900901 -1692.6,37,Rb,La2,11,0.099099 -1752.2,37,Rb,Lb1,58,0.522523 -1482.4,37,Rb,Ll,5,0.045045 -61140.3,75,Re,Ka1,100,0.662252 -59717.9,75,Re,Ka2,58,0.384106 -69310,75,Re,Kb1,22,0.145695 -71232,75,Re,Kb2,8,0.05298 -68994,75,Re,Kb3,12,0.07947 -8652.5,75,Re,La1,100,0.900901 -8586.2,75,Re,La2,11,0.099099 -10010,75,Re,Lb1,66,0.594595 -10275.2,75,Re,Lb2,22,0.198198 -11685.4,75,Re,Lg1,13,0.117117 -7603.6,75,Re,Ll,5,0.045045 +8268,78,Pt,Ll,5,0.022831 +9361.8,78,Pt,La2,11,0.050228 +9442.3,78,Pt,La1,100,0.456621 +11070.7,78,Pt,Lb1,67,0.305936 +11250.5,78,Pt,Lb2,23,0.105023 +12942,78,Pt,Lg1,13,0.059361 +65112,78,Pt,Ka2,58,0.288557 +66832,78,Pt,Ka1,100,0.497512 +75368,78,Pt,Kb3,12,0.059701 +75748,78,Pt,Kb1,23,0.114428 +77850,78,Pt,Kb2,8,0.039801 +1482.4,37,Rb,Ll,5,0.028736 +1692.6,37,Rb,La2,11,0.063218 +1694.1,37,Rb,La1,100,0.574713 +1752.2,37,Rb,Lb1,58,0.333333 +13335.8,37,Rb,Ka2,52,0.297143 +13395.3,37,Rb,Ka1,100,0.571429 +14951.7,37,Rb,Kb3,7,0.04 +14961.3,37,Rb,Kb1,14,0.08 +15185,37,Rb,Kb2,2,0.011429 1842.5,75,Re,Ma1,100,1 -20216.1,45,Rh,Ka1,100,0.662252 -20073.7,45,Rh,Ka2,53,0.350993 -22723.6,45,Rh,Kb1,16,0.10596 -23172.8,45,Rh,Kb2,4,0.02649 -22698.9,45,Rh,Kb3,8,0.05298 -2696.7,45,Rh,La1,100,0.900901 -2692,45,Rh,La2,11,0.099099 -2834.4,45,Rh,Lb1,52,0.468468 -3001.3,45,Rh,"Lb2,15",10,0.09009 -3143.8,45,Rh,Lg1,5,0.045045 -2376.5,45,Rh,Ll,4,0.036036 -19279.2,44,Ru,Ka1,100,0.662252 -19150.4,44,Ru,Ka2,53,0.350993 -21656.8,44,Ru,Kb1,16,0.10596 -22074,44,Ru,Kb2,4,0.02649 -21634.6,44,Ru,Kb3,8,0.05298 -2558.6,44,Ru,La1,100,0.900901 -2554.3,44,Ru,La2,11,0.099099 -2683.2,44,Ru,Lb1,54,0.486486 -2836,44,Ru,"Lb2,15",10,0.09009 -2964.5,44,Ru,Lg1,4,0.036036 -2252.8,44,Ru,Ll,4,0.036036 -2307.8,16,S,Ka1,100,0.662252 -2306.6,16,S,Ka2,50,0.331126 -2464,16,S,Kb1,5,0.033113 -26359.1,51,Sb,"Ka1,2",1,0.006623 -26110.8,51,Sb,Ka2,54,0.357616 -29725.6,51,Sb,Kb1,18,0.119205 -30389.5,51,Sb,Kb2,5,0.033113 -29679.2,51,Sb,Kb3,9,0.059603 -3604.7,51,Sb,La1,100,0.900901 -3595.3,51,Sb,La2,11,0.099099 -3843.6,51,Sb,Lb1,61,0.54955 -4100.8,51,Sb,"Lb2,15",17,0.153153 -4347.8,51,Sb,Lg1,8,0.072072 -3188.6,51,Sb,Ll,4,0.036036 -4090.6,21,Sc,Ka1,100,0.662252 -4086.1,21,Sc,Ka2,50,0.331126 -4460.5,21,Sc,"Kb1,3",15,0.099338 -395.4,21,Sc,"La1,2",111,1 -399.6,21,Sc,Lb1,77,0.693694 -348.3,21,Sc,Ll,21,0.189189 -11222.4,34,Se,Ka1,100,0.662252 -11181.4,34,Se,Ka2,52,0.344371 -12495.9,34,Se,Kb1,13,0.086093 -12652,34,Se,Kb2,1,0.006623 -12489.6,34,Se,Kb3,6,0.039735 -1379.1,34,Se,"La1,2",111,1 -1419.2,34,Se,Lb1,59,0.531532 -1204.4,34,Se,Ll,6,0.054054 -1740,14,Si,"Ka1,2",1,0.006623 -1739.4,14,Si,Ka2,50,0.331126 -1835.9,14,Si,Kb1,2,0.013245 -40118.1,62,Sm,Ka1,100,0.662252 -39522.4,62,Sm,Ka2,55,0.364238 -45413,62,Sm,Kb1,19,0.125828 -46578,62,Sm,Kb2,6,0.039735 -45289,62,Sm,Kb3,10,0.066225 -5636.1,62,Sm,La1,100,0.900901 -5609,62,Sm,La2,11,0.099099 -6205.1,62,Sm,Lb1,61,0.54955 -6587,62,Sm,"Lb2,15",21,0.189189 -7178,62,Sm,Lg1,10,0.09009 -4994.5,62,Sm,Ll,4,0.036036 +7603.6,75,Re,Ll,5,0.023041 +8586.2,75,Re,La2,11,0.050691 +8652.5,75,Re,La1,100,0.460829 +10010,75,Re,Lb1,66,0.304147 +10275.2,75,Re,Lb2,22,0.101382 +11685.4,75,Re,Lg1,13,0.059908 +59717.9,75,Re,Ka2,58,0.29 +61140.3,75,Re,Ka1,100,0.5 +68994,75,Re,Kb3,12,0.06 +69310,75,Re,Kb1,22,0.11 +71232,75,Re,Kb2,8,0.04 +2376.5,45,Rh,Ll,4,0.021978 +2692,45,Rh,La2,11,0.06044 +2696.7,45,Rh,La1,100,0.549451 +2834.4,45,Rh,Lb1,52,0.285714 +3001.3,45,Rh,"Lb2,15",10,0.054945 +3143.8,45,Rh,Lg1,5,0.027473 +20073.7,45,Rh,Ka2,53,0.292818 +20216.1,45,Rh,Ka1,100,0.552486 +22698.9,45,Rh,Kb3,8,0.044199 +22723.6,45,Rh,Kb1,16,0.088398 +23172.8,45,Rh,Kb2,4,0.022099 +2252.8,44,Ru,Ll,4,0.021858 +2554.3,44,Ru,La2,11,0.060109 +2558.6,44,Ru,La1,100,0.546448 +2683.2,44,Ru,Lb1,54,0.295082 +2836,44,Ru,"Lb2,15",10,0.054645 +2964.5,44,Ru,Lg1,4,0.021858 +19150.4,44,Ru,Ka2,53,0.292818 +19279.2,44,Ru,Ka1,100,0.552486 +21634.6,44,Ru,Kb3,8,0.044199 +21656.8,44,Ru,Kb1,16,0.088398 +22074,44,Ru,Kb2,4,0.022099 +2306.6,16,S,Ka2,50,0.322581 +2307.8,16,S,Ka1,100,0.645161 +2464,16,S,Kb1,5,0.032258 +3188.6,51,Sb,Ll,4,0.0199 +3595.3,51,Sb,La2,11,0.054726 +3604.7,51,Sb,La1,100,0.497512 +3843.6,51,Sb,Lb1,61,0.303483 +4100.8,51,Sb,"Lb2,15",17,0.084577 +4347.8,51,Sb,Lg1,8,0.039801 +26110.8,51,Sb,Ka2,54,0.290323 +26359.1,51,Sb,Ka1,100,0.537634 +29679.2,51,Sb,Kb3,9,0.048387 +29725.6,51,Sb,Kb1,18,0.096774 +30389.5,51,Sb,Kb2,5,0.026882 +348.3,21,Sc,Ll,21,0.100478 +395.4,21,Sc,"La1,2",111,0.5311 +399.6,21,Sc,Lb1,77,0.368421 +4086.1,21,Sc,Ka2,50,0.30303 +4090.6,21,Sc,Ka1,100,0.606061 +4460.5,21,Sc,"Kb1,3",15,0.090909 +1204.4,34,Se,Ll,6,0.034091 +1379.1,34,Se,"La1,2",111,0.630682 +1419.2,34,Se,Lb1,59,0.335227 +11181.4,34,Se,Ka2,52,0.302326 +11222.4,34,Se,Ka1,100,0.581395 +12489.6,34,Se,Kb3,6,0.034884 +12495.9,34,Se,Kb1,13,0.075581 +12652,34,Se,Kb2,1,0.005814 +1739.4,14,Si,Ka2,50,0.328947 +1740,14,Si,Ka1,100,0.657895 +1835.9,14,Si,Kb1,2,0.013158 1081,62,Sm,Ma1,100,1 -25271.3,50,Sn,Ka1,100,0.662252 -25044,50,Sn,Ka2,53,0.350993 -28486,50,Sn,Kb1,17,0.112583 -29109.3,50,Sn,Kb2,5,0.033113 -28444,50,Sn,Kb3,9,0.059603 -3444,50,Sn,La1,100,0.900901 -3435.4,50,Sn,La2,11,0.099099 -3662.8,50,Sn,Lb1,60,0.540541 -3904.9,50,Sn,"Lb2,15",16,0.144144 -4131.1,50,Sn,Lg1,7,0.063063 -3045,50,Sn,Ll,4,0.036036 -14165,38,Sr,Ka1,100,0.662252 -14097.9,38,Sr,Ka2,52,0.344371 -15835.7,38,Sr,Kb1,14,0.092715 -16084.6,38,Sr,Kb2,3,0.019868 -15824.9,38,Sr,Kb3,7,0.046358 -1806.6,38,Sr,La1,100,0.900901 -1804.7,38,Sr,La2,11,0.099099 -1871.7,38,Sr,Lb1,58,0.522523 -1582.2,38,Sr,Ll,5,0.045045 -57532,73,Ta,Ka1,100,0.662252 -56277,73,Ta,Ka2,57,0.377483 -65223,73,Ta,Kb1,22,0.145695 -66990,73,Ta,Kb2,7,0.046358 -64948.8,73,Ta,Kb3,11,0.072848 -8146.1,73,Ta,La1,100,0.900901 -8087.9,73,Ta,La2,11,0.099099 -9343.1,73,Ta,Lb1,67,0.603604 -9651.8,73,Ta,Lb2,20,0.18018 -10895.2,73,Ta,Lg1,12,0.108108 -7173.1,73,Ta,Ll,5,0.045045 +4994.5,62,Sm,Ll,4,0.019324 +5609,62,Sm,La2,11,0.05314 +5636.1,62,Sm,La1,100,0.483092 +6205.1,62,Sm,Lb1,61,0.294686 +6587,62,Sm,"Lb2,15",21,0.101449 +7178,62,Sm,Lg1,10,0.048309 +39522.4,62,Sm,Ka2,55,0.289474 +40118.1,62,Sm,Ka1,100,0.526316 +45289,62,Sm,Kb3,10,0.052632 +45413,62,Sm,Kb1,19,0.1 +46578,62,Sm,Kb2,6,0.031579 +3045,50,Sn,Ll,4,0.020202 +3435.4,50,Sn,La2,11,0.055556 +3444,50,Sn,La1,100,0.505051 +3662.8,50,Sn,Lb1,60,0.30303 +3904.9,50,Sn,"Lb2,15",16,0.080808 +4131.1,50,Sn,Lg1,7,0.035354 +25044,50,Sn,Ka2,53,0.288043 +25271.3,50,Sn,Ka1,100,0.543478 +28444,50,Sn,Kb3,9,0.048913 +28486,50,Sn,Kb1,17,0.092391 +29109.3,50,Sn,Kb2,5,0.027174 +1582.2,38,Sr,Ll,5,0.028736 +1804.7,38,Sr,La2,11,0.063218 +1806.6,38,Sr,La1,100,0.574713 +1871.7,38,Sr,Lb1,58,0.333333 +14097.9,38,Sr,Ka2,52,0.295455 +14165,38,Sr,Ka1,100,0.568182 +15824.9,38,Sr,Kb3,7,0.039773 +15835.7,38,Sr,Kb1,14,0.079545 +16084.6,38,Sr,Kb2,3,0.017045 1709.6,73,Ta,Ma1,100,1 -44481.6,65,Tb,Ka1,100,0.662252 -43744.1,65,Tb,Ka2,56,0.370861 -50382,65,Tb,Kb1,20,0.13245 -51698,65,Tb,Kb2,7,0.046358 -50229,65,Tb,Kb3,10,0.066225 -6272.8,65,Tb,La1,100,0.900901 -6238,65,Tb,La2,11,0.099099 -6978,65,Tb,Lb1,61,0.54955 -7366.7,65,Tb,"Lb2,15",21,0.189189 -8102,65,Tb,Lg1,11,0.099099 -5546.7,65,Tb,Ll,4,0.036036 +7173.1,73,Ta,Ll,5,0.023256 +8087.9,73,Ta,La2,11,0.051163 +8146.1,73,Ta,La1,100,0.465116 +9343.1,73,Ta,Lb1,67,0.311628 +9651.8,73,Ta,Lb2,20,0.093023 +10895.2,73,Ta,Lg1,12,0.055814 +56277,73,Ta,Ka2,57,0.28934 +57532,73,Ta,Ka1,100,0.507614 +64948.8,73,Ta,Kb3,11,0.055838 +65223,73,Ta,Kb1,22,0.111675 +66990,73,Ta,Kb2,7,0.035533 1240,65,Tb,Ma1,100,1 -18367.1,43,Tc,Ka1,100,0.662252 -18250.8,43,Tc,Ka2,53,0.350993 -20619,43,Tc,Kb1,16,0.10596 -21005,43,Tc,Kb2,4,0.02649 -20599,43,Tc,Kb3,8,0.05298 -2424,43,Tc,La1,100,0.900901 -2420,43,Tc,La2,11,0.099099 -2538,43,Tc,Lb1,54,0.486486 -2674,43,Tc,"Lb2,15",7,0.063063 -2792,43,Tc,Lg1,3,0.027027 -2122,43,Tc,Ll,5,0.045045 -27472.3,52,Te,Ka1,100,0.662252 -27201.7,52,Te,Ka2,54,0.357616 -30995.7,52,Te,Kb1,18,0.119205 -31700.4,52,Te,Kb2,5,0.033113 -30944.3,52,Te,Kb3,9,0.059603 -3769.3,52,Te,La1,100,0.900901 -3758.8,52,Te,La2,11,0.099099 -4029.6,52,Te,Lb1,61,0.54955 -4301.7,52,Te,"Lb2,15",18,0.162162 -4570.9,52,Te,Lg1,8,0.072072 -3335.6,52,Te,Ll,4,0.036036 -93350,90,Th,Ka1,100,0.662252 -89953,90,Th,Ka2,62,0.410596 -105609,90,Th,Kb1,24,0.15894 -108640,90,Th,Kb2,9,0.059603 -104831,90,Th,Kb3,12,0.07947 -12968.7,90,Th,La1,100,0.900901 -12809.6,90,Th,La2,11,0.099099 -16202.2,90,Th,Lb1,69,0.621622 -15623.7,90,Th,Lb2,26,0.234234 -18982.5,90,Th,Lg1,16,0.144144 -11118.6,90,Th,Ll,6,0.054054 +5546.7,65,Tb,Ll,4,0.019231 +6238,65,Tb,La2,11,0.052885 +6272.8,65,Tb,La1,100,0.480769 +6978,65,Tb,Lb1,61,0.293269 +7366.7,65,Tb,"Lb2,15",21,0.100962 +8102,65,Tb,Lg1,11,0.052885 +43744.1,65,Tb,Ka2,56,0.290155 +44481.6,65,Tb,Ka1,100,0.518135 +50229,65,Tb,Kb3,10,0.051813 +50382,65,Tb,Kb1,20,0.103627 +51698,65,Tb,Kb2,7,0.036269 +2122,43,Tc,Ll,5,0.027778 +2420,43,Tc,La2,11,0.061111 +2424,43,Tc,La1,100,0.555556 +2538,43,Tc,Lb1,54,0.3 +2674,43,Tc,"Lb2,15",7,0.038889 +2792,43,Tc,Lg1,3,0.016667 +18250.8,43,Tc,Ka2,53,0.292818 +18367.1,43,Tc,Ka1,100,0.552486 +20599,43,Tc,Kb3,8,0.044199 +20619,43,Tc,Kb1,16,0.088398 +21005,43,Tc,Kb2,4,0.022099 +3335.6,52,Te,Ll,4,0.019802 +3758.8,52,Te,La2,11,0.054455 +3769.3,52,Te,La1,100,0.49505 +4029.6,52,Te,Lb1,61,0.30198 +4301.7,52,Te,"Lb2,15",18,0.089109 +4570.9,52,Te,Lg1,8,0.039604 +27201.7,52,Te,Ka2,54,0.290323 +27472.3,52,Te,Ka1,100,0.537634 +30944.3,52,Te,Kb3,9,0.048387 +30995.7,52,Te,Kb1,18,0.096774 +31700.4,52,Te,Kb2,5,0.026882 2996.1,90,Th,Ma1,100,1 -4510.8,22,Ti,Ka1,100,0.662252 -4504.9,22,Ti,Ka2,50,0.331126 -4931.8,22,Ti,"Kb1,3",15,0.099338 -452.2,22,Ti,"La1,2",111,1 -458.4,22,Ti,Lb1,79,0.711712 -395.3,22,Ti,Ll,46,0.414414 -72871.5,81,Tl,Ka1,100,0.662252 -70831.9,81,Tl,Ka2,60,0.397351 -82576,81,Tl,Kb1,23,0.152318 -84910,81,Tl,Kb2,8,0.05298 -82118,81,Tl,Kb3,12,0.07947 -10268.5,81,Tl,La1,100,0.900901 -10172.8,81,Tl,La2,11,0.099099 -12213.3,81,Tl,Lb1,67,0.603604 -12271.5,81,Tl,Lb2,25,0.225225 -14291.5,81,Tl,Lg1,14,0.126126 -8953.2,81,Tl,Ll,6,0.054054 +11118.6,90,Th,Ll,6,0.026316 +12809.6,90,Th,La2,11,0.048246 +12968.7,90,Th,La1,100,0.438596 +15623.7,90,Th,Lb2,26,0.114035 +16202.2,90,Th,Lb1,69,0.302632 +18982.5,90,Th,Lg1,16,0.070175 +89953,90,Th,Ka2,62,0.299517 +93350,90,Th,Ka1,100,0.483092 +104831,90,Th,Kb3,12,0.057971 +105609,90,Th,Kb1,24,0.115942 +108640,90,Th,Kb2,9,0.043478 +395.3,22,Ti,Ll,46,0.194915 +452.2,22,Ti,"La1,2",111,0.470339 +458.4,22,Ti,Lb1,79,0.334746 +4504.9,22,Ti,Ka2,50,0.30303 +4510.8,22,Ti,Ka1,100,0.606061 +4931.8,22,Ti,"Kb1,3",15,0.090909 2270.6,81,Tl,Ma1,100,1 -50741.6,69,Tm,Ka1,100,0.662252 -49772.6,69,Tm,Ka2,57,0.377483 -57517,69,Tm,Kb1,21,0.139073 -59090,69,Tm,Kb2,7,0.046358 -57304,69,Tm,Kb3,11,0.072848 -7179.9,69,Tm,La1,100,0.900901 -7133.1,69,Tm,La2,11,0.099099 -8101,69,Tm,Lb1,64,0.576577 -8468,69,Tm,"Lb2,15",20,0.18018 -9426,69,Tm,Lg1,12,0.108108 -6341.9,69,Tm,Ll,4,0.036036 -1462,69,Tm,Ma1,1,0.01 -98439,92,U,Ka1,100,0.662252 -94665,92,U,Ka2,62,0.410596 -111300,92,U,Kb1,24,0.15894 -114530,92,U,Kb2,9,0.059603 -110406,92,U,Kb3,13,0.086093 -13614.7,92,U,La1,100,0.900901 -13438.8,92,U,La2,11,0.099099 -17220,92,U,Lb1,61,0.54955 -16428.3,92,U,Lb2,26,0.234234 -20167.1,92,U,Lg1,15,0.135135 -11618.3,92,U,Ll,7,0.063063 +8953.2,81,Tl,Ll,6,0.026906 +10172.8,81,Tl,La2,11,0.049327 +10268.5,81,Tl,La1,100,0.44843 +12213.3,81,Tl,Lb1,67,0.300448 +12271.5,81,Tl,Lb2,25,0.112108 +14291.5,81,Tl,Lg1,14,0.06278 +70831.9,81,Tl,Ka2,60,0.295567 +72871.5,81,Tl,Ka1,100,0.492611 +82118,81,Tl,Kb3,12,0.059113 +82576,81,Tl,Kb1,23,0.1133 +84910,81,Tl,Kb2,8,0.039409 +1462,69,Tm,Ma1,100,1 +6341.9,69,Tm,Ll,4,0.018957 +7133.1,69,Tm,La2,11,0.052133 +7179.9,69,Tm,La1,100,0.473934 +8101,69,Tm,Lb1,64,0.303318 +8468,69,Tm,"Lb2,15",20,0.094787 +9426,69,Tm,Lg1,12,0.056872 +49772.6,69,Tm,Ka2,57,0.290816 +50741.6,69,Tm,Ka1,100,0.510204 +57304,69,Tm,Kb3,11,0.056122 +57517,69,Tm,Kb1,21,0.107143 +59090,69,Tm,Kb2,7,0.035714 3170.8,92,U,Ma1,100,1 -4952.2,23,V,Ka1,100,0.662252 -4944.6,23,V,Ka2,50,0.331126 -5427.3,23,V,"Kb1,3",15,0.099338 -511.3,23,V,"La1,2",111,1 -519.2,23,V,Lb1,80,0.720721 -446.5,23,V,Ll,28,0.252252 -59318.2,74,W,Ka1,100,0.662252 -57981.7,74,W,Ka2,58,0.384106 -67244.3,74,W,Kb1,22,0.145695 -69067,74,W,Kb2,8,0.05298 -66951.4,74,W,Kb3,11,0.072848 -8397.6,74,W,La1,100,0.900901 -8335.2,74,W,La2,11,0.099099 -9672.4,74,W,Lb1,67,0.603604 -9961.5,74,W,Lb2,21,0.189189 -11285.9,74,W,Lg1,13,0.117117 -7387.8,74,W,Ll,5,0.045045 +11618.3,92,U,Ll,7,0.031818 +13438.8,92,U,La2,11,0.05 +13614.7,92,U,La1,100,0.454545 +16428.3,92,U,Lb2,26,0.118182 +17220,92,U,Lb1,61,0.277273 +20167.1,92,U,Lg1,15,0.068182 +94665,92,U,Ka2,62,0.298077 +98439,92,U,Ka1,100,0.480769 +110406,92,U,Kb3,13,0.0625 +111300,92,U,Kb1,24,0.115385 +114530,92,U,Kb2,9,0.043269 +446.5,23,V,Ll,28,0.127854 +511.3,23,V,"La1,2",111,0.506849 +519.2,23,V,Lb1,80,0.365297 +4944.6,23,V,Ka2,50,0.30303 +4952.2,23,V,Ka1,100,0.606061 +5427.3,23,V,"Kb1,3",15,0.090909 1775.4,74,W,Ma1,100,1 -29779,54,Xe,Ka1,100,0.662252 -29458,54,Xe,Ka2,54,0.357616 -33624,54,Xe,Kb1,18,0.119205 -34415,54,Xe,Kb2,5,0.033113 -33562,54,Xe,Kb3,9,0.059603 -4109.9,54,Xe,La1,100,0.900901 -4093,54,Xe,La2,11,0.099099 -4414,54,Xe,Lb1,60,0.540541 -4714,54,Xe,"Lb2,15",20,0.18018 -5034,54,Xe,Lg1,8,0.072072 -3636,54,Xe,Ll,4,0.036036 -14958.4,39,Y,Ka1,100,0.662252 -14882.9,39,Y,Ka2,52,0.344371 -16737.8,39,Y,Kb1,15,0.099338 -17015.4,39,Y,Kb2,3,0.019868 -16725.8,39,Y,Kb3,8,0.05298 -1922.6,39,Y,La1,100,0.900901 -1920.5,39,Y,La2,11,0.099099 -1995.8,39,Y,Lb1,57,0.513514 -1685.4,39,Y,Ll,5,0.045045 -52388.9,70,Yb,Ka1,100,0.662252 -51354,70,Yb,Ka2,57,0.377483 -59370,70,Yb,Kb1,1,0.006623 -60980,70,Yb,Kb2,7,0.046358 -59140,70,Yb,Kb3,11,0.072848 -7415.6,70,Yb,La1,100,0.900901 -7367.3,70,Yb,"La1,2",2,0.018018 -8401.8,70,Yb,Lb1,65,0.585586 -8758.8,70,Yb,"Lb2,15",20,0.18018 -9780.1,70,Yb,Lg1,12,0.108108 -6545.5,70,Yb,Ll,4,0.036036 +7387.8,74,W,Ll,5,0.023041 +8335.2,74,W,La2,11,0.050691 +8397.6,74,W,La1,100,0.460829 +9672.4,74,W,Lb1,67,0.308756 +9961.5,74,W,Lb2,21,0.096774 +11285.9,74,W,Lg1,13,0.059908 +57981.7,74,W,Ka2,58,0.291457 +59318.2,74,W,Ka1,100,0.502513 +66951.4,74,W,Kb3,11,0.055276 +67244.3,74,W,Kb1,22,0.110553 +69067,74,W,Kb2,8,0.040201 +3636,54,Xe,Ll,4,0.019704 +4093,54,Xe,La2,11,0.054187 +4109.9,54,Xe,La1,100,0.492611 +4414,54,Xe,Lb1,60,0.295567 +4714,54,Xe,"Lb2,15",20,0.098522 +5034,54,Xe,Lg1,8,0.039409 +29458,54,Xe,Ka2,54,0.290323 +29779,54,Xe,Ka1,100,0.537634 +33562,54,Xe,Kb3,9,0.048387 +33624,54,Xe,Kb1,18,0.096774 +34415,54,Xe,Kb2,5,0.026882 +1685.4,39,Y,Ll,5,0.028902 +1920.5,39,Y,La2,11,0.063584 +1922.6,39,Y,La1,100,0.578035 +1995.8,39,Y,Lb1,57,0.32948 +14882.9,39,Y,Ka2,52,0.292135 +14958.4,39,Y,Ka1,100,0.561798 +16725.8,39,Y,Kb3,8,0.044944 +16737.8,39,Y,Kb1,15,0.08427 +17015.4,39,Y,Kb2,3,0.016854 1521.4,70,Yb,Ma1,100,1 -8638.9,30,Zn,Ka1,100,0.662252 -8615.8,30,Zn,Ka2,51,0.337748 -9572,30,Zn,"Kb1,3",17,0.112583 -1011.7,30,Zn,"La1,2",111,1 -1034.7,30,Zn,Lb1,65,0.585586 -884,30,Zn,Ll,7,0.063063 -15775.1,40,Zr,Ka1,100,0.662252 -15690.9,40,Zr,Ka2,52,0.344371 -17667.8,40,Zr,Kb1,15,0.099338 -17970,40,Zr,Kb2,3,0.019868 -17654,40,Zr,Kb3,8,0.05298 -2042.4,40,Zr,La1,100,0.900901 -2039.9,40,Zr,La2,11,0.099099 -2124.4,40,Zr,Lb1,54,0.486486 -2219.4,40,Zr,"Lb2,15",1,0.009009 -2302.7,40,Zr,Lg1,2,0.018018 -1792,40,Zr,Ll,5,0.045045 \ No newline at end of file +6545.5,70,Yb,Ll,4,0.018868 +7367.3,70,Yb,La2,11,0.051887 +7415.6,70,Yb,La1,100,0.471698 +8401.8,70,Yb,Lb1,65,0.306604 +8758.8,70,Yb,"Lb2,15",20,0.09434 +9780.1,70,Yb,Lg1,12,0.056604 +51354,70,Yb,Ka2,57,0.290816 +52388.9,70,Yb,Ka1,100,0.510204 +59140,70,Yb,Kb3,11,0.056122 +59370,70,Yb,Kb1,21,0.107143 +60980,70,Yb,Kb2,7,0.035714 +884,30,Zn,Ll,7,0.038251 +1011.7,30,Zn,"La1,2",111,0.606557 +1034.7,30,Zn,Lb1,65,0.355191 +8615.8,30,Zn,Ka2,51,0.303571 +8638.9,30,Zn,Ka1,100,0.595238 +9572,30,Zn,"Kb1,3",17,0.10119 +1792,40,Zr,Ll,5,0.028902 +2039.9,40,Zr,La2,11,0.063584 +2042.4,40,Zr,La1,100,0.578035 +2124.4,40,Zr,Lb1,54,0.312139 +2219.4,40,Zr,"Lb2,15",1,0.00578 +2302.7,40,Zr,Lg1,2,0.011561 +15690.9,40,Zr,Ka2,52,0.292135 +15775.1,40,Zr,Ka1,100,0.561798 +17654,40,Zr,Kb3,8,0.044944 +17667.8,40,Zr,Kb1,15,0.08427 +17970,40,Zr,Kb2,3,0.016854 \ No newline at end of file From 3a3328014bbe783cf5b64d80541d6e1ae3d53524 Mon Sep 17 00:00:00 2001 From: smribet Date: Sun, 26 Apr 2026 09:09:30 -0700 Subject: [PATCH 110/136] _normalize_element_info --- src/quantem/spectroscopy/dataset3deds.py | 106 ++++++++++++++++++ .../spectroscopy/dataset3dspectroscopy.py | 8 +- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 3299392a..1f81fd2d 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -35,6 +35,7 @@ class Dataset3deds(Dataset3dspectroscopy): element_info = None element_info_path = "x_ray_lines.csv" + dataset_type = "eds" def __init__( self, @@ -98,6 +99,111 @@ def _ensure_element_info(cls): cls.load_element_info() return cls.element_info or {} + @classmethod + def _normalize_element_info(cls, combine_close_peaks=True, energy_threshold_ev=15): + """Normalize EDS X-ray lines and optionally merge unresolved line families.""" + if not isinstance(cls.element_info, dict): + return cls.element_info + + if combine_close_peaks is None: + combine_close_peaks = cls.combine_close_xray_lines + if energy_threshold_ev is None: + energy_threshold_ev = cls.close_xray_line_threshold_ev + threshold_kev = float(energy_threshold_ev) / 1000.0 + + def line_family(line_name): + canonical = cls._canonical_line_name(line_name).strip() + match = re.match(r"^([A-Za-z]+)", canonical) + return match.group(1) if match else canonical + + def normalized_line_name(line_name): + canonical = cls._canonical_line_name(line_name).strip() + match = re.match(r"^([A-Za-z]+)\d+(?:,\d+)+$", canonical) + return match.group(1) if match else canonical + + def unique_name(lines, name): + if name not in lines: + return name + idx = 2 + while f"{name}__{idx}" in lines: + idx += 1 + return f"{name}__{idx}" + + def merged_info(entries): + weights = np.asarray([entry["weight"] for entry in entries], dtype=float) + energies = np.asarray([entry["energy"] for entry in entries], dtype=float) + weight_sum = float(np.sum(weights)) + if weight_sum > 0.0: + energy = float(np.sum(energies * weights) / weight_sum) + else: + energy = float(np.mean(energies)) + return {"energy (keV)": energy, "weight": weight_sum} + + normalized_info = {} + for element, lines in cls.element_info.items(): + if not isinstance(lines, dict): + normalized_info[element] = lines + continue + + entries_by_family = {} + normalized_lines = {} + for line_name, line_info in lines.items(): + if not isinstance(line_info, dict): + continue + try: + energy = float(line_info.get("energy (keV)", line_info.get("energy"))) + except (TypeError, ValueError): + continue + try: + weight = float(line_info.get("weight", 0.0)) + except (TypeError, ValueError): + weight = 0.0 + + entry = { + "line": normalized_line_name(line_name), + "family": line_family(line_name), + "energy": energy, + "weight": weight, + } + entries_by_family.setdefault(entry["family"], []).append(entry) + + for family, entries in entries_by_family.items(): + entries = sorted(entries, key=lambda entry: entry["energy"]) + if not combine_close_peaks: + for entry in entries: + name = unique_name(normalized_lines, entry["line"]) + normalized_lines[name] = { + "energy (keV)": entry["energy"], + "weight": entry["weight"], + } + continue + + clusters = [] + current = [] + for entry in entries: + if not current or entry["energy"] - current[0]["energy"] <= threshold_kev: + current.append(entry) + else: + clusters.append(current) + current = [entry] + if current: + clusters.append(current) + + for cluster in clusters: + name = family if len(cluster) > 1 else cluster[0]["line"] + name = unique_name(normalized_lines, name) + normalized_lines[name] = merged_info(cluster) + + normalized_info[element] = dict( + sorted( + normalized_lines.items(), + key=lambda item: (item[1]["energy (keV)"], item[0]), + ) + ) + + cls.element_info = normalized_info + return cls.element_info + @classmethod def _parse_element_selectors(cls, specs, *, allow_none=False, param_name="spec"): """Parse element/line specifiers into a dict of {element: set_of_suffixes | None}.""" diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 77f8b338..49ee1d04 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -23,11 +23,11 @@ def __repr__(self): lines = ["Model Elements:"] for element, line_info in self.items(): if isinstance(line_info, dict) and line_info: - line_names = ', '.join(sorted(line_info.keys())) + line_names = ", ".join(sorted(line_info.keys())) lines.append(f" {element}: {line_names}") else: lines.append(f" {element}") - return '\n'.join(lines) + return "\n".join(lines) def _repr_html_(self): if not self: @@ -87,6 +87,10 @@ def load_element_info(cls): else: with open(full_path, "r", encoding="utf-8") as f: cls.element_info = json.load(f)["elements"] + + if str(getattr(cls, "dataset_type", "")).lower() == "eds": + cls._normalize_element_info() + return cls.element_info @classmethod From f50f5edc44dcdbe3a6c9a8565908a45e72696cac Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Mon, 27 Apr 2026 13:31:24 -0700 Subject: [PATCH 111/136] remove redundant functions --- src/quantem/spectroscopy/dataset3deels.py | 43 +---------------------- 1 file changed, 1 insertion(+), 42 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index 95ef9c59..0c2ad607 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -338,7 +338,7 @@ def calibrate_zero_loss_peak(self, center_guess=None, search_window=10): units=self.units, ) - def align_dual_eels_universal(ll, hl, approach="smooth", sigma=1.2): + def correct_zlp_shift(ll, hl, approach="smooth", sigma=1.2): """ Aligns ZLP jitter across the spatial map and synchronizes Dual-EELS pairs. """ @@ -359,20 +359,6 @@ def align_dual_eels_universal(ll, hl, approach="smooth", sigma=1.2): print("QuantEM: Alignment and Dual-EELS sync complete.") return ll, hl, shifts - def calibrate_energy_axis(ll, hl): - """ - Fine-tunes the origin so the absolute peak position is exactly 0.0 eV. - """ - # Find the peak of the average spectrum - current_peak_idx = np.argmax(np.mean(ll.array, axis=(1, 2))) - peak_ev = ll.origin[0] + (current_peak_idx * ll.sampling[0]) - - # Apply global shift to both datasets - ll.origin[0] -= peak_ev - hl.origin[0] -= peak_ev - - print(f"QuantEM: Final calibration shift of {peak_ev:.4f} eV applied.") - def plot_absolute_zlp_shift(dataset, search_window=(-10, 10)): """ Calculates the ZLP shift per pixel and plots the absolute deviation from 0.0 eV. @@ -406,33 +392,6 @@ def plot_absolute_zlp_shift(dataset, search_window=(-10, 10)): return absolute_shift - def plot_alignment_verification(dataset, shift_map, coords=(9, 9)): - """ - Plots the drift map and a specific spectrum to verify alignment quality. - """ - y, x = coords - spec = dataset.array[:, y, x] - energies = dataset.origin[0] + np.arange(len(spec)) * dataset.sampling[0] - - fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5)) - - # Drift Map - im = ax1.imshow(shift_map, cmap="RdBu_r", origin="lower") - ax1.plot(x, y, "yo", markeredgecolor="k") - ax1.set_title("Drift Map") - plt.colorbar(im, ax=ax1, label="Relative Shift") - - # Spectrum Verification - ax2.plot(energies, spec, color="black", label="Aligned Spec") - ax2.axvline(0, color="red", linestyle="--", alpha=0.7, label="0.0 eV Target") - ax2.set_xlim(-5, 5) - ax2.set_title(f"ZLP Detail at Pixel ({x}, {y})") - ax2.set_xlabel("Energy Loss (eV)") - ax2.legend() - - plt.tight_layout() - plt.show() - def visualize_thickness_windows(dataset, zlp_window=(-3.0, 3.0), total_window=(-3.0, 75.0)): """ Visualizes integration windows for I0 (ZLP) and It (Total). From 652e8626e94260c05c74826fc1fd78138d5a45d7 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Mon, 27 Apr 2026 16:51:36 -0700 Subject: [PATCH 112/136] beginning refactor of zlp code to separate "measure" and "correct" functions --- src/quantem/spectroscopy/dataset3deels.py | 69 +++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index 0c2ad607..a961fbaa 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -236,6 +236,75 @@ def smooth_eels_rollingaverage( return smoothed_data3d + def measure_zlp_offset(self, zlp_guess_x=None, fit_window=0.8): + """ + Measure ZLP offset at each pixel position by fitting each spectrum to a Gaussian and returning a 2D plane fit of ZLP positions. + """ + + # Define Gaussian constraint to fit ZLP to + def gaussian_fit(x, A, mu, sigma): + return A * np.exp(-0.5 * ((x - mu) / sigma) ** 2) + + n_energy, n_y, n_x = self.array.shape() + + dE = float(self.sampling[0]) + E0 = float(self.origin[0]) + energy_axis = E0 + np.arange(n_energy) * dE + + # For each pixel, measure the zlp position by fitting a Gaussian to the measured zero-loss signal and taking its center as the zlp position. + + zlp_measured = np.zeros((n_y, n_x)) + + for iy in range(n_y): + for ix in range(n_x): + # Apply median filter to discount hot pixels that might spuriously produce the maximum intensity of the spectrum + spec_filt = median_filter(self.array[:, iy, ix], size=3) + + # Use initial guess for ZLP to define window for Gaussian fitting. If zlp_guess_x=None (default) use the maximum value of the spectrum + if zlp_guess_x is not None: + zlp_crude_idx = int(np.argmin(np.abs(energy_axis - zlp_guess_x))) + else: + zlp_crude_idx = int(np.argmax(spec_filt)) + + mu0 = float(energy_axis[zlp_crude_idx]) + + lo = mu0 - fit_window + hi = mu0 + fit_window + + x_mask = (energy_axis >= lo) & (energy_axis <= hi) + + xw = energy_axis[x_mask] + yw = spec_filt[x_mask] + + A0 = float(spec_filt[zlp_crude_idx]) + sigma0 = fit_window / 2 + + p0 = (A0, mu0, sigma0) + + bounds = ( + ( + 0.0, + lo, + 1e-12, + ), + ( + np.inf, + hi, + np.inf, + ), + ) + + popt, _ = curve_fit(gaussian_fit, xw, yw, p0=p0, bounds=bounds) + + zlp_measured[n_y, n_x] = popt[1] + + # Fit a 2D plane to the array of measured ZLPs + + return + + def apply_zlp_correction(): + return + def calibrate_zero_loss_peak(self, center_guess=None, search_window=10): """ Calibrate the energy axis by centering the zero loss peak at 0 eV. From 9bf4680f7f2b31d1f1640239e30bfb7c2aedd130 Mon Sep 17 00:00:00 2001 From: smribet Date: Thu, 30 Apr 2026 09:13:53 -0700 Subject: [PATCH 113/136] bug fixes --- src/quantem/spectroscopy/dataset3deds.py | 14 +++++++++++--- src/quantem/spectroscopy/dataset3dspectroscopy.py | 3 --- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 1f81fd2d..15d7f8c7 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -984,7 +984,7 @@ def peak_autoid( requested_elements = set(edge_filters) if edge_filters else None mode = (str(mode).strip().lower() if mode is not None else None) or ( - "elements_only" if requested_elements else "autofill" + "elements_preferred" if requested_elements else "autofill" ) search_elements = requested_elements if mode == "elements_only" else None preferred_elements = ( @@ -1006,12 +1006,20 @@ def peak_autoid( energy_range=energy_range, ignore_range=ignore_range, mask=mask, + attach_mean_spectrum=False, ) - E = float(self.origin[0]) + float(self.sampling[0]) * np.arange(self.shape[0]) + spec = np.asarray(spec, dtype=float) + E = np.asarray(self.energy_axis, dtype=float) + if mask is not None: + E = E[np.asarray(mask, dtype=bool)] if energy_range is not None: keep = (energy_range[0] <= E) & (E <= energy_range[1]) E = E[keep] - spec = spec[keep] + if E.shape[0] != spec.shape[0]: + raise RuntimeError( + "Energy axis and mean spectrum lengths do not match after applying " + "mask and energy_range." + ) def in_ignore(energy): return len(ignore_range) == 2 and ignore_range[0] <= float(energy) <= ignore_range[1] diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 49ee1d04..3df1baa1 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -872,9 +872,6 @@ def show_mean_spectrum( }, **kwargs, ) - ax_img.set_xlabel("X (pixels)") - ax_img.set_ylabel("Y (pixels)") - # Highlight the ROI with a rectangle rect = Rectangle( (x - 0.5, y - 0.5), dx, dy, linewidth=2, edgecolor="red", facecolor="none", alpha=0.8 From 4f50f9e94fba8e8fecff703782d59777a0b41f4f Mon Sep 17 00:00:00 2001 From: smribet Date: Thu, 30 Apr 2026 09:48:12 -0700 Subject: [PATCH 114/136] some plotting and bug fixes --- src/quantem/spectroscopy/dataset3deds.py | 27 ++++-- .../spectroscopy/dataset3dspectroscopy.py | 91 +++++++++++-------- 2 files changed, 70 insertions(+), 48 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 15d7f8c7..a9c076e3 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -1020,27 +1020,38 @@ def peak_autoid( "Energy axis and mean spectrum lengths do not match after applying " "mask and energy_range." ) + spec_for_peaks = np.nan_to_num(spec, nan=0.0, posinf=0.0, neginf=0.0) + if spec_for_peaks.size: + spec_for_peaks = spec_for_peaks - float(np.nanmin(spec_for_peaks)) + peak_scale = float(np.nanmax(np.abs(spec_for_peaks))) + if np.isfinite(peak_scale) and peak_scale > 0: + spec_for_peaks = spec_for_peaks / peak_scale def in_ignore(energy): return len(ignore_range) == 2 and ignore_range[0] <= float(energy) <= ignore_range[1] - peak_indices, props = find_peaks(spec, height=0, distance=5) - peak_heights = props["peak_heights"] - background_std = np.nanstd(spec[spec <= np.nanpercentile(spec, 50)]) + peak_indices, props = find_peaks(spec_for_peaks, height=0, distance=5) + peak_signal_heights = props["peak_heights"] + peak_heights = spec[peak_indices] + background_std = np.nanstd( + spec_for_peaks[spec_for_peaks <= np.nanpercentile(spec_for_peaks, 50)] + ) if not np.isfinite(background_std) or background_std <= 0: - background_std = np.nanstd(spec) + background_std = np.nanstd(spec_for_peaks) if not np.isfinite(background_std) or background_std <= 0: background_std = 1.0 - snr_values = np.asarray([height / background_std for height in peak_heights], dtype=float) + snr_values = np.asarray( + [height / background_std for height in peak_signal_heights], dtype=float + ) snr_min, snr_threshold = type(self)._estimate_snr_thresholds( snr_values, peaks, snr_min, snr_threshold ) display_peaks = [ - (int(i), float(h), float(E[i]), float(h / background_std)) - for i, h in zip(peak_indices, peak_heights) - if not in_ignore(E[i]) and h / background_std >= snr_min + (int(i), float(raw_h), float(E[i]), float(signal_h / background_std)) + for i, raw_h, signal_h in zip(peak_indices, peak_heights, peak_signal_heights) + if not in_ignore(E[i]) and signal_h / background_std >= snr_min ] display_peaks.sort(key=lambda item: item[3], reverse=True) significant_peaks = list(display_peaks) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 3df1baa1..2471569b 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -449,39 +449,37 @@ def perform_pca( "reconstructed": reconstructed_data3d if mask is None else reconstructed_data3d, } - def _run_pca(self, data: NDArray | Any, n_components: int): - array = np.asarray(data, dtype=float) - n_samples, n_features = array.shape - max_components = min(n_samples, n_features) - if not 1 <= n_components <= max_components: - raise ValueError( - f"n_components={n_components} must be between 1 and {max_components}" - ) - - mean = np.mean(array, axis=0) - centered = torch.as_tensor(array - mean, dtype=torch.float64) - _, s, vh = torch.linalg.svd(centered, full_matrices=False) - - components = vh[:n_components].cpu().numpy() - loadings = (centered @ vh[:n_components].T).cpu().numpy() - - denom = max(n_samples - 1, 1) - explained_variance = ((s[:n_components] ** 2) / denom).cpu().numpy() - total_variance = torch.sum((s**2) / denom).item() - explained_variance_ratio = ( - explained_variance / total_variance - if total_variance > 0 - else np.zeros_like(explained_variance) - ) - reconstructed = loadings @ components + mean + def _run_pca(self, data: NDArray | Any, n_components: int): + array = np.asarray(data, dtype=float) + n_samples, n_features = array.shape + max_components = min(n_samples, n_features) + if not 1 <= n_components <= max_components: + raise ValueError(f"n_components={n_components} must be between 1 and {max_components}") + + mean = np.mean(array, axis=0) + centered = torch.as_tensor(array - mean, dtype=torch.float64) + _, s, vh = torch.linalg.svd(centered, full_matrices=False) + + components = vh[:n_components].cpu().numpy() + loadings = (centered @ vh[:n_components].T).cpu().numpy() + + denom = max(n_samples - 1, 1) + explained_variance = ((s[:n_components] ** 2) / denom).cpu().numpy() + total_variance = torch.sum((s**2) / denom).item() + explained_variance_ratio = ( + explained_variance / total_variance + if total_variance > 0 + else np.zeros_like(explained_variance) + ) + reconstructed = loadings @ components + mean - return ( - components, - loadings, - explained_variance, - explained_variance_ratio, - reconstructed, - ) + return ( + components, + loadings, + explained_variance, + explained_variance_ratio, + reconstructed, + ) def _plot_pca_results( self, @@ -694,7 +692,16 @@ def calculate_mean_spectrum( mask=None, attach_mean_spectrum=True, roi_cal=None, + normalize=True, ): + """Calculate a spectrum from a spatial ROI. + + Parameters + ---------- + normalize : bool, optional + If ``True``, average over the ROI pixels. If ``False``, sum counts + over the ROI pixels. + """ y, x, dy, dx = self._resolve_roi(roi=roi, roi_cal=roi_cal) # SPECTRUM CALCULATION -------------------------------------------------------------- @@ -732,8 +739,11 @@ def calculate_mean_spectrum( f"Mask shape {mask.shape} does not match energy axis shape ({arr.shape[0]},)" ) - arr = arr[mask, :, :] # select only masked energy channels - spec = arr.sum(axis=(1, 2)) if arr.shape[0] > 0 else np.zeros(0) + arr = arr[mask, y : y + dy, x : x + dx] # select masked energies and ROI + if arr.shape[0] > 0: + spec = arr.mean(axis=(1, 2)) if normalize else arr.sum(axis=(1, 2)) + else: + spec = np.zeros(0) E = E[mask] # Mask the energy axis as well else: spec = np.empty(self.shape[0], dtype=float) @@ -742,7 +752,7 @@ def calculate_mean_spectrum( roi_data = img[y : y + dy, x : x + dx] if roi_data.size == 0: raise ValueError("ROI is empty; check y/x/dy/dx.") - spec[k] = roi_data.mean() + spec[k] = roi_data.mean() if normalize else roi_data.sum() # APPLY ENERGY RANGE --------------------------------------------------------------- @@ -834,12 +844,16 @@ def show_mean_spectrum( roi_cal=roi_cal, energy_range=energy_range, mask=mask, + normalize=False, ) dE = float(self.sampling[0]) E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 E = E0 + dE * np.arange(self.shape[0]) + if mask is not None: + E = E[np.asarray(mask, dtype=bool)] + if energy_range is not None: indices = np.where((E >= energy_range[0]) & (E <= energy_range[1]))[0] E = E[indices] @@ -879,7 +893,7 @@ def show_mean_spectrum( ax_img.add_patch(rect) # RIGHT PLOT: Show spectrum - ax_spec.plot(E, spec, linewidth=1.5) + ax_spec.plot(E, spec, linewidth=1.5, color="k") if self.dataset_type == "eds": ax_spec.set_xlabel("Energy (keV)") else: @@ -1104,9 +1118,6 @@ def show_energy_window_map( }, ) - ax_map.set_xlabel("X (pixels)") - ax_map.set_ylabel("Y (pixels)") - if has_roi_overlay: rect = Rectangle( (x - 0.5, y - 0.5), @@ -1119,7 +1130,7 @@ def show_energy_window_map( ) ax_map.add_patch(rect) - ax_spec.plot(E_spec, spec, linewidth=1.5) + ax_spec.plot(E_spec, spec, linewidth=1.5, color="k") ax_spec.axvspan(emin, emax, color="orange", alpha=0.2, label="Selected window") ax_spec.set_xlabel(f"Energy ({unit_label})") ax_spec.set_ylabel("Intensity") From b05c57b3b8f9f5599edd14e2be4c4d701d210083 Mon Sep 17 00:00:00 2001 From: smribet Date: Thu, 30 Apr 2026 15:33:28 -0700 Subject: [PATCH 115/136] changing data_type to self.dataset_type --- .../spectroscopy/dataset3dspectroscopy.py | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 2471569b..ab62c151 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -310,7 +310,7 @@ def add_spectrum_to_data(self, spectrum, energy_axis): def clear_attached_spectra(self): self.attached_spectra = None - def plot_attached_spectrum(self, data_type="eds", spectrum_index=0): + def plot_attached_spectrum(self, spectrum_index=0): fig, (ax_spec) = plt.subplots(1, 1, figsize=(12, 4)) ax_spec.plot( @@ -318,9 +318,9 @@ def plot_attached_spectrum(self, data_type="eds", spectrum_index=0): self.attached_spectra[spectrum_index][0], linewidth=1.5, ) - if data_type == "eds": + if self.dataset_type == "eds": ax_spec.set_xlabel("Energy (keV)") - elif data_type == "eels": + elif self.dataset_type == "eels": ax_spec.set_xlabel("Energy (eV)") ax_spec.set_ylabel("Intensity") ax_spec.set_title(f"Spectrum in index {spectrum_index}") @@ -1023,7 +1023,6 @@ def show_energy_window_map( roi=None, roi_cal=None, mask=None, - data_type="eds", cmap="viridis", show=True, ): @@ -1044,8 +1043,6 @@ def show_energy_window_map( mask : array-like | None, optional Optional boolean mask over energy channels. If provided, it is combined with ``energy_window``. - data_type : str, optional - "eds" (keV) or "eels" (eV), used for title/unit text. cmap : str, optional Matplotlib colormap for the map. show : bool, optional @@ -1103,7 +1100,7 @@ def show_energy_window_map( else: E_spec = E - unit_label = "keV" if str(data_type).lower() == "eds" else "eV" + unit_label = "keV" if str(self.dataset_type).lower() == "eds" else "eV" fig, (ax_map, ax_spec) = plt.subplots(1, 2, figsize=(12, 4)) show_2d( energy_map, @@ -1155,7 +1152,6 @@ def subtract_background( mask=None, target_edge=None, window_size=10, - data_type="eds", method="powerlaw", return_dataset=True, attach_spectrum=True, @@ -1178,9 +1174,9 @@ def subtract_background( spec = self.calculate_mean_spectrum(roi, energy_range, ignore_range, mask) - if data_type == "eds": + if self.dataset_type == "eds": background = self.calculate_background_powerlaw(spec) - elif data_type == "eels": + elif self.dataset_type == "eels": if method == "powerlaw": background = self.powerlaw_backgroundfit_eels( spec, energy_range, target_edge, window_size @@ -1212,7 +1208,7 @@ def subtract_background( fig, (ax_specbacksub) = plt.subplots(1, 1, figsize=(12, 4)) ax_specbacksub.plot(E, subtracted_mean_spectrum, linewidth=1.5) - if data_type == "eds": + if self.dataset_type == "eds": ax_specbacksub.set_xlabel("Energy (keV)") else: ax_specbacksub.set_xlabel("Energy (eV)") @@ -1234,7 +1230,7 @@ def subtract_background( spec3D_subtracted[:, p, q] = np.maximum(self.array[indices, p, q] - background, 0) if return_dataset: - if data_type == "eds": + if self.dataset_type == "eds": return Dataset3deds.from_array( array=spec3D_subtracted, sampling=self.sampling, @@ -1242,7 +1238,7 @@ def subtract_background( units=self.units, ) - elif data_type == "eels": + elif self.dataset_type == "eels": return Dataset3deels.from_array( array=spec3D_subtracted, sampling=self.sampling, From 48c980f2583fe82c19f440acc07bf748bc03aee5 Mon Sep 17 00:00:00 2001 From: smribet Date: Thu, 30 Apr 2026 16:03:18 -0700 Subject: [PATCH 116/136] bug fix for background subtraction --- src/quantem/spectroscopy/dataset3dspectroscopy.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index ab62c151..68538148 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -1205,6 +1205,9 @@ def subtract_background( indices = np.arange(self.shape[0]) ### + output_origin = np.array(self.origin, dtype=float, copy=True) + output_origin[0] = E[0] + fig, (ax_specbacksub) = plt.subplots(1, 1, figsize=(12, 4)) ax_specbacksub.plot(E, subtracted_mean_spectrum, linewidth=1.5) @@ -1234,7 +1237,7 @@ def subtract_background( return Dataset3deds.from_array( array=spec3D_subtracted, sampling=self.sampling, - origin=self.origin, + origin=output_origin, units=self.units, ) @@ -1242,7 +1245,7 @@ def subtract_background( return Dataset3deels.from_array( array=spec3D_subtracted, sampling=self.sampling, - origin=self.origin, + origin=output_origin, units=self.units, ) else: From c8ee683d568f0bc941bb4b6b7525067d8cb2d98a Mon Sep 17 00:00:00 2001 From: smribet Date: Mon, 4 May 2026 05:59:50 -0700 Subject: [PATCH 117/136] pca plotting --- .../spectroscopy/dataset3dspectroscopy.py | 207 +++++++++--------- 1 file changed, 98 insertions(+), 109 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 68538148..03200b4f 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -337,6 +337,7 @@ def perform_pca( mask: Optional[NDArray] = None, plot_results: bool = True, random_state: Optional[int] = 42, + return_results=False, ) -> dict: """ Perform Principal Component Analysis (PCA) on the spectroscopy dataset. @@ -348,70 +349,73 @@ def perform_pca( standardize : bool If True, standardize the data before PCA (zero mean, unit variance) mask : Optional[NDArray] - Optional spatial mask to select pixels for analysis + Optional spatial mask to select pixels for analysis. Accepts shape + (scan_y, scan_x) or a flattened spatial mask. plot_results : bool If True, plot the explained variance and first few components random_state : Optional[int] - Random state for reproducibility + Accepted for API compatibility. PCA uses deterministic SVD. Returns ------- dict Dictionary containing: - - 'pca': fitted PCA object + - 'pca': PCA result attributes - 'components': principal component spectra (n_components x n_energy) - - 'loadings': spatial loadings (n_components x n_pixels) + - 'loadings': spatial loadings (n_components x scan_y x scan_x) - 'explained_variance_ratio': explained variance for each component - 'reconstructed': reconstructed dataset (dataset3dspectroscopy) using n_components """ - from quantem.spectroscopy import ( - Dataset3deds as Dataset3deds, - ) - from quantem.spectroscopy import ( - Dataset3deels as Dataset3deels, - ) + from quantem.spectroscopy import Dataset3deds, Dataset3deels data = np.asarray(self.array, dtype=float) n_energy, ny, nx = data.shape + n_pixels = ny * nx - # Reshape data to (n_pixels, n_energy) for PCA - data_reshaped = data.reshape(n_energy, -1).T # (n_pixels, n_energy) + spectra = np.moveaxis(data, 0, -1).reshape(n_pixels, n_energy) + pixel_mask = np.ones(n_pixels, dtype=bool) if mask is not None: - mask_flat = mask.flatten() - data_masked = data_reshaped[mask_flat] - else: - data_masked = data_reshaped + mask_array = np.asarray(mask, dtype=bool) + if mask_array.shape == (ny, nx): + pixel_mask = mask_array.reshape(-1) + elif mask_array.shape == (n_pixels,): + pixel_mask = mask_array + else: + raise ValueError( + f"mask shape {mask_array.shape} must match spatial shape {(ny, nx)} " + f"or flattened shape {(n_pixels,)}" + ) + + if not np.any(pixel_mask): + raise ValueError("mask must select at least one spatial pixel") + + selected_spectra = spectra[pixel_mask] if standardize: - mean = np.mean(data_masked, axis=0) - std = np.std(data_masked, axis=0) + mean = np.mean(selected_spectra, axis=0) + std = np.std(selected_spectra, axis=0) std[std == 0] = 1 # Avoid division by zero - data_processed = (data_masked - mean) / std + pca_input = (selected_spectra - mean) / std else: - data_processed = data_masked + mean = np.zeros(n_energy) + std = np.ones(n_energy) + pca_input = selected_spectra - # Perform PCA - del random_state ( components, loadings, explained_variance, explained_variance_ratio, reconstructed, - ) = self._run_pca(data_processed, n_components) + ) = self._run_pca(pca_input, n_components) - # Reconstruct data - if standardize: - reconstructed = reconstructed * std + mean + reconstructed = reconstructed * std + mean - if mask is None: - loadings_spatial = loadings.T.reshape(n_components, ny, nx) - else: - loadings_spatial = np.zeros((n_components, ny * nx)) - loadings_spatial[:, mask_flat] = loadings.T - loadings_spatial = loadings_spatial.reshape(n_components, ny, nx) + loadings_flat = np.zeros((n_components, n_pixels), dtype=loadings.dtype) + loadings_flat[:, pixel_mask] = loadings.T + loadings_spatial = loadings_flat.reshape(n_components, ny, nx) if plot_results: self._plot_pca_results( @@ -421,33 +425,38 @@ def perform_pca( n_show=min(4, n_components), ) - if self.dataset_type == "eds": - reconstructed_data3d = Dataset3deds.from_array( - array=reconstructed.T.reshape(n_energy, ny, nx), - sampling=self.sampling, - origin=self.origin, - units=self.units, - ) - elif self.dataset_type == "eels": - reconstructed_data3d = Dataset3deels.from_array( - array=reconstructed.T.reshape(n_energy, ny, nx), - sampling=self.sampling, - origin=self.origin, - units=self.units, - ) + reconstructed_spectra = spectra.copy() + reconstructed_spectra[pixel_mask] = reconstructed + reconstructed_array = reconstructed_spectra.reshape(ny, nx, n_energy).transpose(2, 0, 1) - return { - "pca": { - "components_": components, - "explained_variance_": explained_variance, - "explained_variance_ratio_": explained_variance_ratio, - }, - "components": components, - "loadings": loadings_spatial, - "explained_variance_ratio": explained_variance_ratio, - "explained_variance": explained_variance, - "reconstructed": reconstructed_data3d if mask is None else reconstructed_data3d, - } + dataset_type = str(self.dataset_type).lower() + if dataset_type == "eds": + dataset_class = Dataset3deds + elif dataset_type == "eels": + dataset_class = Dataset3deels + else: + raise ValueError(f"Unsupported spectroscopy dataset_type {self.dataset_type!r}") + + reconstructed_data3d = dataset_class.from_array( + array=reconstructed_array, + sampling=self.sampling, + origin=self.origin, + units=self.units, + ) + + if return_results: + return { + "pca": { + "components_": components, + "explained_variance_": explained_variance, + "explained_variance_ratio_": explained_variance_ratio, + }, + "components": components, + "loadings": loadings_spatial, + "explained_variance_ratio": explained_variance_ratio, + "explained_variance": explained_variance, + "reconstructed": reconstructed_data3d, + } def _run_pca(self, data: NDArray | Any, n_components: int): array = np.asarray(data, dtype=float) @@ -502,76 +511,56 @@ def _plot_pca_results( n_show : int Number of components to show """ - fig = plt.figure(figsize=(15, 10)) - gs = fig.add_gridspec(3, n_show + 1, width_ratios=[1.5] + [1] * n_show) - - # Plot 1: Scree plot (explained variance) - ax_scree = fig.add_subplot(gs[0, 0]) + fig, (ax_scree, ax_components) = plt.subplots(1, 2, figsize=(12, 4)) cumsum_var = np.cumsum(explained_variance_ratio) + component_numbers = np.arange(1, len(explained_variance_ratio) + 1) ax_scree.bar( - range(1, len(explained_variance_ratio) + 1), + component_numbers, explained_variance_ratio * 100, alpha=0.6, label="Individual", ) - ax_scree.plot( - range(1, len(explained_variance_ratio) + 1), - cumsum_var * 100, - "ro-", - label="Cumulative", - ) + ax_scree.plot(component_numbers, cumsum_var * 100, "ro-", label="Cumulative") ax_scree.set_xlabel("Component Number") ax_scree.set_ylabel("Explained Variance (%)") ax_scree.set_title("Scree Plot") ax_scree.legend() ax_scree.grid(True, alpha=0.3) - # Get energy axis energy_sampling = float(self.sampling[0]) energy_origin = float(self.origin[0]) energy_axis = energy_origin + energy_sampling * np.arange(components.shape[1]) - # Plot components and loadings for i in range(n_show): - ax_comp = fig.add_subplot(gs[1, i + 1]) - ax_comp.plot(energy_axis, components[i]) - ax_comp.set_title(f"PC{i + 1} ({explained_variance_ratio[i] * 100:.1f}%)") - ax_comp.set_xlabel("Energy") - if i == 0: - ax_comp.set_ylabel("Component") - ax_comp.grid(True, alpha=0.3) - - ax_load = fig.add_subplot(gs[2, i + 1]) - im = ax_load.imshow(loadings[i], cmap="RdBu_r", origin="lower") - ax_load.set_title(f"Loading {i + 1}") - ax_load.axis("off") - plt.colorbar(im, ax=ax_load, fraction=0.046, pad=0.04) - - ax_stats = fig.add_subplot(gs[1:, 0]) - ax_stats.axis("off") - - stats_text = "PCA Summary\n" + "=" * 20 + "\n\n" - stats_text += f"Total components: {len(explained_variance_ratio)}\n" - stats_text += f"Components for 95% var: {np.argmax(cumsum_var >= 0.95) + 1}\n" - stats_text += f"Components for 99% var: {np.argmax(cumsum_var >= 0.99) + 1}\n\n" - - for i in range(min(5, len(explained_variance_ratio))): - stats_text += f"PC{i + 1}: {explained_variance_ratio[i] * 100:.2f}%\n" - - ax_stats.text( - 0.1, - 0.9, - stats_text, - transform=ax_stats.transAxes, - fontsize=10, - verticalalignment="top", - fontfamily="monospace", - bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.5), - ) + ax_components.plot( + energy_axis, + components[i], + label=f"PC{i + 1} ({explained_variance_ratio[i] * 100:.1f}%)", + ) + ax_components.set_xlabel("Energy") + ax_components.set_ylabel("Component") + ax_components.set_title("Principal Component Spectra") + ax_components.legend() + ax_components.grid(True, alpha=0.3) - plt.suptitle("PCA Analysis Results", fontsize=14, fontweight="bold") - plt.tight_layout() + fig.suptitle("PCA Analysis") + fig.tight_layout() + plt.show() + + show_2d( + [loadings[i] for i in range(n_show)], + title=[ + f"Loading {i + 1} ({explained_variance_ratio[i] * 100:.1f}%)" + for i in range(n_show) + ], + cmap="RdBu_r", + cbar=True, + scalebar={ + "sampling": float(self.sampling[1]), + "units": str(self.units[1]), + }, + ) plt.show() def _calibrated_position_to_pixel(self, value, axis): From 1507702520ef120a5ebb1378a788e8c6350b826c Mon Sep 17 00:00:00 2001 From: smribet Date: Mon, 4 May 2026 08:31:23 -0700 Subject: [PATCH 118/136] small fix --- src/quantem/spectroscopy/dataset3dspectroscopy.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 03200b4f..e08dbb51 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -1176,13 +1176,7 @@ def subtract_background( subtracted_mean_spectrum = np.maximum(spec - background, 0) # PLOT MEAN BACKGROUND-SUBTRACTED SPECTRUM --------------------------------------------------------------------------- - - # TODO: store energy axis variable so it doesn't have to be reinitialized repeatedly. - # for now, this chunk is borrowed from calculate_mean_spectrum - ### - dE = float(self.sampling[0]) - E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 - E = E0 + dE * np.arange(self.shape[0]) + E = self.energy_axis if energy_range is not None: energy_range[0] = np.maximum(energy_range[0], E[0]) From 8c3a4cc1b80d68cf79131f5b324ffe647e4958c8 Mon Sep 17 00:00:00 2001 From: smribet Date: Mon, 4 May 2026 08:39:19 -0700 Subject: [PATCH 119/136] adding local EELS background fitting --- .../spectroscopy/dataset3dspectroscopy.py | 316 ++++++++++++++---- 1 file changed, 249 insertions(+), 67 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index e08dbb51..895ab178 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -1144,101 +1144,283 @@ def subtract_background( method="powerlaw", return_dataset=True, attach_spectrum=True, + fit_mode="global", + kernel_width=1, + show=True, + return_background=False, ): """ - Perform appropriate background subtraction routine on mean spectrum from a 3D spectroscopy dataset. - + Subtract fitted background from a 3D spectroscopy dataset. - returns: - A dataset3dspectroscopy object with background subtraction performed at all probe positions + Parameters + ---------- + fit_mode : {"global", "local"}, optional + ``"global"`` fits one background to the ROI mean spectrum and subtracts + it from every probe position. ``"local"`` fits a background at each + probe position from the average spectrum of its nearest spatial + neighbors. + kernel_width : int, optional + Number of nearest spatial neighbors to average for each local + background fit. The current pixel is included. Used only when + ``fit_mode="local"``. + show : bool, optional + If True, plot the mean raw spectrum, fitted background, and + background-subtracted spectrum. + return_background : bool, optional + If True, return ``(dataset, background_cube)`` when ``return_dataset`` + is True, otherwise return the background cube. + Returns + ------- + Dataset3dspectroscopy or tuple or ndarray or None + Background-subtracted dataset by default. If ``return_background`` is + True, also returns the fitted background cube. """ - from quantem.spectroscopy import ( - Dataset3deds as Dataset3deds, - ) - from quantem.spectroscopy import ( - Dataset3deels as Dataset3deels, - ) - - spec = self.calculate_mean_spectrum(roi, energy_range, ignore_range, mask) + del ignore_range + from quantem.spectroscopy import Dataset3deds, Dataset3deels - if self.dataset_type == "eds": - background = self.calculate_background_powerlaw(spec) - elif self.dataset_type == "eels": - if method == "powerlaw": - background = self.powerlaw_backgroundfit_eels( - spec, energy_range, target_edge, window_size - ) - elif method == "iterative": - background = self.calculate_background_iterative(spec) + fit_mode = str(fit_mode).lower() + if fit_mode not in {"global", "local"}: + raise ValueError("fit_mode must be 'global' or 'local'") + + E, indices = self._background_energy_axis_and_indices(energy_range, mask) + array3d = np.asarray(self.array, dtype=float)[indices, :, :] + y, x, dy, dx = self._resolve_roi(roi=roi) + + if fit_mode == "global": + input_spectrum = array3d[:, y : y + dy, x : x + dx].mean(axis=(1, 2)) + background = self._fit_background_spectrum( + input_spectrum, + E, + method=method, + target_edge=target_edge, + window_size=window_size, + ) + background_cube = np.broadcast_to(background[:, None, None], array3d.shape) + else: + background_cube = self._fit_local_background_cube( + array3d, + E, + method=method, + target_edge=target_edge, + window_size=window_size, + kernel_width=kernel_width, + ) - subtracted_mean_spectrum = np.maximum(spec - background, 0) + spec3D_subtracted = np.maximum(array3d - background_cube, 0) + input_mean_spectrum = array3d[:, y : y + dy, x : x + dx].mean(axis=(1, 2)) + background_mean_spectrum = background_cube[:, y : y + dy, x : x + dx].mean(axis=(1, 2)) + subtracted_mean_spectrum = spec3D_subtracted[:, y : y + dy, x : x + dx].mean(axis=(1, 2)) - # PLOT MEAN BACKGROUND-SUBTRACTED SPECTRUM --------------------------------------------------------------------------- - E = self.energy_axis + if attach_spectrum: + self.add_spectrum_to_data(subtracted_mean_spectrum, E) - if energy_range is not None: - energy_range[0] = np.maximum(energy_range[0], E[0]) - energy_range[1] = np.minimum(energy_range[1], E[-1]) + if show: + self._plot_background_subtraction( + E, + input_mean_spectrum, + background_mean_spectrum, + subtracted_mean_spectrum, + fit_mode=fit_mode, + ) - indices = np.where((E >= energy_range[0]) & (E <= energy_range[1]))[0] - E = E[indices] + dataset_type = str(self.dataset_type).lower() + if dataset_type == "eds": + dataset_class = Dataset3deds + elif dataset_type == "eels": + dataset_class = Dataset3deels else: - indices = np.arange(self.shape[0]) - ### + raise ValueError(f"Unsupported spectroscopy dataset_type {self.dataset_type!r}") output_origin = np.array(self.origin, dtype=float, copy=True) output_origin[0] = E[0] + if return_dataset: + subtracted_dataset = dataset_class.from_array( + array=spec3D_subtracted, + sampling=self.sampling, + origin=output_origin, + units=self.units, + ) + if return_background: + background_dataset = dataset_class.from_array( + array=np.array(background_cube, copy=True), + sampling=self.sampling, + origin=output_origin, + units=self.units, + ) + return subtracted_dataset, background_dataset + return subtracted_dataset + + if return_background: + return background_cube + + print("Notice: no 3D dataset was returned") + + def _background_energy_axis_and_indices(self, energy_range, mask): + E = np.asarray(self.energy_axis, dtype=float) + selected = np.ones(E.shape, dtype=bool) + + if energy_range is not None: + if len(energy_range) != 2: + raise ValueError("energy_range must be [min_energy, max_energy]") + e_min = float(energy_range[0]) + e_max = float(energy_range[1]) + if e_min >= e_max: + raise ValueError("Invalid energy range parameter.") + if e_max < E[0] or e_min > E[-1]: + raise ValueError("Energy range parameter is outside of data bounds.") + e_min = max(e_min, float(E[0])) + e_max = min(e_max, float(E[-1])) + selected &= (E >= e_min) & (E <= e_max) + + if mask is not None: + mask = np.asarray(mask, dtype=bool) + if mask.shape != E.shape: + raise ValueError( + f"Mask shape {mask.shape} does not match energy axis shape {E.shape}" + ) + selected &= mask + + if not np.any(selected): + raise ValueError("No energy channels selected. Adjust energy_range or mask") + + indices = np.where(selected)[0] + return E[indices], indices + + def _fit_background_spectrum(self, spectrum, energy_axis, method, target_edge, window_size): + dataset_type = str(self.dataset_type).lower() + spectrum = np.asarray(spectrum, dtype=float) + + if dataset_type == "eds": + return self.calculate_background_powerlaw(spectrum) + + if dataset_type != "eels": + raise ValueError(f"Unsupported spectroscopy dataset_type {self.dataset_type!r}") + + method = str(method).lower() + if method == "iterative": + return np.full_like(spectrum, float(self.calculate_background_iterative(spectrum))) + if method != "powerlaw": + raise ValueError("EELS background method must be 'powerlaw' or 'iterative'") + if target_edge is None: + raise ValueError("target_edge is required for EELS powerlaw background fitting") + + return self._fit_eels_powerlaw_background( + spectrum, + np.asarray(energy_axis, dtype=float), + target_edge=target_edge, + window_size=window_size, + ) + + def _fit_eels_powerlaw_background(self, spectrum, energy_axis, target_edge, window_size): + from scipy.optimize import curve_fit + + if window_size < 10 or window_size > 30: + raise ValueError("Invalid window size. Please input a value of between 10 and 30.") + + target_edge = float(target_edge) + if target_edge < energy_axis[0] or target_edge > energy_axis[-1]: + raise ValueError("Target edge is outside of energy range.") + + window_minE = (target_edge - 5) - target_edge * (float(window_size) / 100) + window_maxE = target_edge - 5 + if window_minE < energy_axis[0]: + raise ValueError( + "Insufficient pre-edge background fitting region for this target edge " + "and window size within given energy range." + ) + + window_indices = np.where((energy_axis >= window_minE) & (energy_axis <= window_maxE))[0] + if len(window_indices) < 2: + raise ValueError("Insufficient points in EELS pre-edge background fitting window.") + + window_E = energy_axis[window_indices] + window_I = np.asarray(spectrum, dtype=float)[window_indices] + + def powerlaw_function(E, A, r): + return A * (E ** (-r)) + + popt, _ = curve_fit(powerlaw_function, window_E, window_I, maxfev=2000) + return powerlaw_function(energy_axis, popt[0], popt[1]) + + def _fit_local_background_cube( + self, + array3d, + energy_axis, + method, + target_edge, + window_size, + kernel_width, + ): + from scipy.spatial import cKDTree + + n_energy, ny, nx = array3d.shape + n_pixels = ny * nx + try: + n_neighbors = int(kernel_width) + except (TypeError, ValueError) as exc: + raise TypeError("kernel_width must be an integer") from exc + if n_neighbors < 1: + raise ValueError("kernel_width must be >= 1") + n_neighbors = min(n_neighbors, n_pixels) + + yy, xx = np.indices((ny, nx)) + coords = np.column_stack((yy.reshape(-1), xx.reshape(-1))) + _, neighbor_indices = cKDTree(coords).query(coords, k=n_neighbors) + if n_neighbors == 1: + neighbor_indices = neighbor_indices[:, None] + + spectra = np.moveaxis(array3d, 0, -1).reshape(n_pixels, n_energy) + background = np.empty_like(spectra) + + for pixel_index, neighbors in enumerate(neighbor_indices): + local_spectrum = spectra[neighbors].mean(axis=0) + try: + background[pixel_index] = self._fit_background_spectrum( + local_spectrum, + energy_axis, + method=method, + target_edge=target_edge, + window_size=window_size, + ) + except Exception as exc: + y, x = divmod(pixel_index, nx) + raise RuntimeError(f"Background fit failed at pixel ({y}, {x})") from exc + + return background.reshape(ny, nx, n_energy).transpose(2, 0, 1) + + def _plot_background_subtraction( + self, + energy_axis, + input_spectrum, + background_spectrum, + subtracted_spectrum, + fit_mode, + ): fig, (ax_specbacksub) = plt.subplots(1, 1, figsize=(12, 4)) - ax_specbacksub.plot(E, subtracted_mean_spectrum, linewidth=1.5) + ax_specbacksub.plot(energy_axis, input_spectrum, linewidth=1.2, label="Input") + ax_specbacksub.plot(energy_axis, background_spectrum, linewidth=1.2, label="Background") + ax_specbacksub.plot( + energy_axis, + subtracted_spectrum, + linewidth=1.5, + label="Background-subtracted", + ) if self.dataset_type == "eds": ax_specbacksub.set_xlabel("Energy (keV)") else: ax_specbacksub.set_xlabel("Energy (eV)") ax_specbacksub.set_ylabel("Intensity") - ax_specbacksub.set_title("Background-subtracted spectrum from ROI") + ax_specbacksub.set_title(f"Background-subtracted spectrum from ROI ({fit_mode})") ax_specbacksub.grid(True, alpha=0.1) + ax_specbacksub.legend() fig.tight_layout() plt.show() - # NOTE: currently, if an energy_range parameter is set, subtract_background considers ONLY - # the spectrum data within that energy range, and the output dataset3dspectroscopy object - # only includes data from that energy range embedded. Not sure that's the best way to implement this. - - spec3D_subtracted = np.empty([spec.shape[0], self.shape[1], self.shape[2]], dtype=float) - - for p in range(self.shape[1]): - for q in range(self.shape[2]): - spec3D_subtracted[:, p, q] = np.maximum(self.array[indices, p, q] - background, 0) - - if return_dataset: - if self.dataset_type == "eds": - return Dataset3deds.from_array( - array=spec3D_subtracted, - sampling=self.sampling, - origin=output_origin, - units=self.units, - ) - - elif self.dataset_type == "eels": - return Dataset3deels.from_array( - array=spec3D_subtracted, - sampling=self.sampling, - origin=output_origin, - units=self.units, - ) - else: - print("Notice: no 3D dataset was returned") - - if attach_spectrum: - self.add_spectrum_to_data(subtracted_mean_spectrum, E) - else: - print(f"Notice: no spectrum recorded to attached_spectra in {self}") - @property def energy_axis(self): energy_axis = np.arange(self.shape[0]) * self.sampling[0] + self.origin[0] From 108e16b823c490461c17128ba6990959be12e3eb Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Mon, 4 May 2026 08:48:28 -0700 Subject: [PATCH 120/136] measure zlp refactor changes, a couple bugs to fix --- src/quantem/spectroscopy/dataset3deels.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index a961fbaa..75fdc021 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -242,10 +242,14 @@ def measure_zlp_offset(self, zlp_guess_x=None, fit_window=0.8): """ # Define Gaussian constraint to fit ZLP to - def gaussian_fit(x, A, mu, sigma): + def _gaussian_fit(x, A, mu, sigma): return A * np.exp(-0.5 * ((x - mu) / sigma) ** 2) - n_energy, n_y, n_x = self.array.shape() + def _plane_fit_2d(M, a, b, c): + x, y = M + return (a * x) + (b * y) + c + + n_energy, n_y, n_x = self.array.shape dE = float(self.sampling[0]) E0 = float(self.origin[0]) @@ -258,7 +262,7 @@ def gaussian_fit(x, A, mu, sigma): for iy in range(n_y): for ix in range(n_x): # Apply median filter to discount hot pixels that might spuriously produce the maximum intensity of the spectrum - spec_filt = median_filter(self.array[:, iy, ix], size=3) + spec_filt = np.median_filter(self.array[:, iy, ix], 3) # Use initial guess for ZLP to define window for Gaussian fitting. If zlp_guess_x=None (default) use the maximum value of the spectrum if zlp_guess_x is not None: @@ -294,13 +298,20 @@ def gaussian_fit(x, A, mu, sigma): ), ) - popt, _ = curve_fit(gaussian_fit, xw, yw, p0=p0, bounds=bounds) + popt, _ = curve_fit(_gaussian_fit, xw, yw, p0=p0, bounds=bounds) - zlp_measured[n_y, n_x] = popt[1] + zlp_measured[n_y - 1, n_x - 1] = popt[1] # Fit a 2D plane to the array of measured ZLPs + xdata = np.arange(n_x) + ydata = np.arange(n_y) - return + xdata_unpacked = np.vstack(xdata.ravel(), ydata.ravel()) + ydata_unpacked = zlp_measured.ravel() + + popt, _ = curve_fit(_plane_fit_2d, xdata_unpacked, ydata_unpacked) + + return _plane_fit_2d(xdata_unpacked, popt[0], popt[1], popt[2]) def apply_zlp_correction(): return From 4b9557fcd2f31730eba34e8c8f71fac27b5b42a6 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Mon, 4 May 2026 13:04:45 -0700 Subject: [PATCH 121/136] zlp 2d plane fit --- src/quantem/spectroscopy/dataset3deels.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index 75fdc021..d52d0f63 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -8,6 +8,7 @@ from scipy.optimize import curve_fit from scipy.stats import norm +from quantem.core.visualization import show_2d from quantem.spectroscopy.dataset3dspectroscopy import Dataset3dspectroscopy @@ -262,7 +263,7 @@ def _plane_fit_2d(M, a, b, c): for iy in range(n_y): for ix in range(n_x): # Apply median filter to discount hot pixels that might spuriously produce the maximum intensity of the spectrum - spec_filt = np.median_filter(self.array[:, iy, ix], 3) + spec_filt = median_filter(self.array[:, iy, ix], 3) # Use initial guess for ZLP to define window for Gaussian fitting. If zlp_guess_x=None (default) use the maximum value of the spectrum if zlp_guess_x is not None: @@ -300,20 +301,28 @@ def _plane_fit_2d(M, a, b, c): popt, _ = curve_fit(_gaussian_fit, xw, yw, p0=p0, bounds=bounds) - zlp_measured[n_y - 1, n_x - 1] = popt[1] + zlp_measured[iy - 1, ix - 1] = float(popt[1]) # Fit a 2D plane to the array of measured ZLPs - xdata = np.arange(n_x) - ydata = np.arange(n_y) + xdata, ydata = np.meshgrid(np.arange(n_x), np.arange(n_y)) - xdata_unpacked = np.vstack(xdata.ravel(), ydata.ravel()) + xdata_unpacked = np.vstack((xdata.ravel(), ydata.ravel())) ydata_unpacked = zlp_measured.ravel() popt, _ = curve_fit(_plane_fit_2d, xdata_unpacked, ydata_unpacked) - return _plane_fit_2d(xdata_unpacked, popt[0], popt[1], popt[2]) + zlp_plane_1d = _plane_fit_2d(xdata_unpacked, popt[0], popt[1], popt[2]) + zlp_plane_2d = zlp_plane_1d.reshape(n_y, n_x) - def apply_zlp_correction(): + show_2d( + [zlp_measured, zlp_plane_2d], + cmap="magma", + title=["Measured ZLP (Gaussian)", "ZLP plane fit"], + ) + + return zlp_plane_2d + + def apply_zlp_correction(self): return def calibrate_zero_loss_peak(self, center_guess=None, search_window=10): From 01e67bae7e93c303537c1b69769279e674fae91d Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Mon, 4 May 2026 13:12:00 -0700 Subject: [PATCH 122/136] user flag to make plane fit optional --- src/quantem/spectroscopy/dataset3deels.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index d52d0f63..9311fe4c 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -237,9 +237,9 @@ def smooth_eels_rollingaverage( return smoothed_data3d - def measure_zlp_offset(self, zlp_guess_x=None, fit_window=0.8): + def measure_zlp_offset(self, zlp_guess_x=None, fit_window=0.8, fit_to_plane=True): """ - Measure ZLP offset at each pixel position by fitting each spectrum to a Gaussian and returning a 2D plane fit of ZLP positions. + Measure ZLP offset at each pixel position by using a guess of ZLP posfitting each spectrum to a Gaussian """ # Define Gaussian constraint to fit ZLP to @@ -320,7 +320,10 @@ def _plane_fit_2d(M, a, b, c): title=["Measured ZLP (Gaussian)", "ZLP plane fit"], ) - return zlp_plane_2d + if fit_to_plane: + return zlp_plane_2d + else: + return zlp_measured def apply_zlp_correction(self): return From f98ac752bfb5b62c6816a31228ff57fdf8930103 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Mon, 4 May 2026 13:53:28 -0700 Subject: [PATCH 123/136] apply zlp correction function --- src/quantem/spectroscopy/dataset3deels.py | 35 +++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index 9311fe4c..6d66ccee 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -325,8 +325,39 @@ def _plane_fit_2d(M, a, b, c): else: return zlp_measured - def apply_zlp_correction(self): - return + def apply_zlp_correction( + self, zlp_guess_x=None, fit_window=0.8, fit_to_plane=True, return_3d_dataset=True + ): + zlp_array = self.measure_zlp_offset(zlp_guess_x, fit_window, fit_to_plane) + + corrected_array = np.zeros_like(self.array) + + n_energy, n_y, n_x = self.array.shape + + dE = float(self.sampling[0]) + E0 = float(self.origin[0]) + energy_axis = E0 + np.arange(n_energy) * dE + + for iy in range(n_y): + for ix in range(n_x): + E_shift = energy_axis - zlp_array[iy, ix] + interpolator = interp1d( + E_shift, + self.array[:, iy, ix], + kind="linear", + bounds_error=False, + fill_value=0.0, + ) + corrected_array[:, iy, ix] = interpolator(energy_axis) + + if return_3d_dataset: + return Dataset3deels.from_array( + array=corrected_array, + name=self.name, + sampling=self.sampling, + origin=self.origin, + units=self.units, + ) def calibrate_zero_loss_peak(self, center_guess=None, search_window=10): """ From 0e8f97e1c1babc166eaab33918a45d1c07c58dc3 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Mon, 4 May 2026 14:35:30 -0700 Subject: [PATCH 124/136] add plotting --- src/quantem/spectroscopy/dataset3deels.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index 6d66ccee..fa637f96 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -350,6 +350,19 @@ def apply_zlp_correction( ) corrected_array[:, iy, ix] = interpolator(energy_axis) + mean_spectrum_raw = self.array.mean(axis=(1, 2)) + mean_spectrum_corrected = corrected_array.mean(axis=(1, 2)) + + fig, ax = plt.subplots() + ax.plot(energy_axis, mean_spectrum_corrected, label="ZLP-corrected spectrum", color="b") + ax.plot(energy_axis, mean_spectrum_raw, label="Raw mean spectrum", color="r") + ax.set_xlabel("Energy (eV)") + ax.set_ylabel("Intensity") + ax.grid(True, alpha=0.1) + ax.legend() + + fig.tight_layout() + if return_3d_dataset: return Dataset3deels.from_array( array=corrected_array, From 494ad093cd785612fb760bb6c82c027982670149 Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Tue, 5 May 2026 14:37:19 -0700 Subject: [PATCH 125/136] optional flags for measure and apply zlp functions --- src/quantem/spectroscopy/dataset3deels.py | 33 ++++++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index fa637f96..9d6230a9 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -237,7 +237,9 @@ def smooth_eels_rollingaverage( return smoothed_data3d - def measure_zlp_offset(self, zlp_guess_x=None, fit_window=0.8, fit_to_plane=True): + def measure_zlp_offset( + self, zlp_guess_x=None, fit_window=0.8, use_gaussian_fit=True, fit_to_plane=True + ): """ Measure ZLP offset at each pixel position by using a guess of ZLP posfitting each spectrum to a Gaussian """ @@ -317,7 +319,7 @@ def _plane_fit_2d(M, a, b, c): show_2d( [zlp_measured, zlp_plane_2d], cmap="magma", - title=["Measured ZLP (Gaussian)", "ZLP plane fit"], + title=["Measured ZLP (mean of Gaussian fit)", "ZLP plane fit"], ) if fit_to_plane: @@ -326,9 +328,32 @@ def _plane_fit_2d(M, a, b, c): return zlp_measured def apply_zlp_correction( - self, zlp_guess_x=None, fit_window=0.8, fit_to_plane=True, return_3d_dataset=True + self, + zlp_guess_x=None, + zlp_shifts_array=None, + fit_window=0.8, + measure_offset=True, + fit_to_plane=True, + return_3d_dataset=True, ): - zlp_array = self.measure_zlp_offset(zlp_guess_x, fit_window, fit_to_plane) + # Default behavior is to automatically call measure_zlp_offset to generate an array of ZLP shifts for each scan position. + # Alternatively, a 2D array matching the x and y dimensions of the 3D dataset can be supplied as the value of zlp_shifts_array to skip this step. + # If measure_offset is False and no 2D ZLP shifts array is provided, a scalar input for zlp_guess_x can be used to shift the energy axis at every scan position by that amount. + if measure_offset: + zlp_array = self.measure_zlp_offset(zlp_guess_x, fit_window, fit_to_plane) + elif zlp_shifts_array is not None: + if zlp_shifts_array.shape == self.array.shape[1:3]: + zlp_array = zlp_shifts_array + else: + raise ValueError( + "Dimensions of input array for ZLP shifts do not match X and Y dimensions of 3D spectroscopy dataset." + ) + elif zlp_guess_x is not None: + zlp_array = np.ones(self.array.shape[1:3]) * zlp_guess_x + else: + raise ValueError( + "measure_offset was set to False and no input argument for ZLP shifts was provided." + ) corrected_array = np.zeros_like(self.array) From 74a49bda91e24f163b111cba29b212f6c402eb85 Mon Sep 17 00:00:00 2001 From: smribet Date: Wed, 6 May 2026 10:52:11 -0700 Subject: [PATCH 126/136] updating eels edges --- src/quantem/spectroscopy/dataset3deels.py | 2 +- .../spectroscopy/dataset3dspectroscopy.py | 12 +- .../spectroscopy/eels_binding_energies.json | 3223 ----------------- src/quantem/spectroscopy/eels_edges.csv | 1047 ++++++ src/quantem/spectroscopy/utils.py | 53 + 5 files changed, 1109 insertions(+), 3228 deletions(-) delete mode 100644 src/quantem/spectroscopy/eels_binding_energies.json create mode 100644 src/quantem/spectroscopy/eels_edges.csv diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index 9d6230a9..4a873c82 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -23,7 +23,7 @@ class Dataset3deels(Dataset3dspectroscopy): """ element_info = None - element_info_path = "eels_binding_energies.json" + element_info_path = "eels_edges.csv" dataset_type = "EELS" def __init__( diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 895ab178..c5c5bcd7 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -11,7 +11,7 @@ from quantem.core.datastructures.dataset3d import Dataset3d from quantem.core.visualization import show_2d -from quantem.spectroscopy.utils import load_xray_lines_database +from quantem.spectroscopy.utils import load_eels_edges_database, load_xray_lines_database class _ModelElementsDict(dict): @@ -71,7 +71,7 @@ def __init__( # loads elemental information @classmethod def load_element_info(cls): - """Load element database for EDS (X-ray lines) or EELS (binding energies).""" + """Load element database for EDS X-ray lines or EELS edges.""" if cls.element_info is not None: return cls.element_info @@ -82,13 +82,17 @@ def load_element_info(cls): ) full_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), path) + dataset_type = str(getattr(cls, "dataset_type", "")).lower() if path.lower().endswith(".csv"): - cls.element_info = load_xray_lines_database(full_path) + if dataset_type == "eels": + cls.element_info = load_eels_edges_database(full_path) + else: + cls.element_info = load_xray_lines_database(full_path) else: with open(full_path, "r", encoding="utf-8") as f: cls.element_info = json.load(f)["elements"] - if str(getattr(cls, "dataset_type", "")).lower() == "eds": + if dataset_type == "eds": cls._normalize_element_info() return cls.element_info diff --git a/src/quantem/spectroscopy/eels_binding_energies.json b/src/quantem/spectroscopy/eels_binding_energies.json deleted file mode 100644 index 05f21b36..00000000 --- a/src/quantem/spectroscopy/eels_binding_energies.json +++ /dev/null @@ -1,3223 +0,0 @@ -{ - "elements": { - "Ac": { - "L1": { - "edge": "", - "onset_energy (eV)": 19840.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "", - "onset_energy (eV)": 19083.0, - "relevance": "Minor", - "threshold": "" - }, - "L3": { - "edge": "", - "onset_energy (eV)": 15871.0, - "relevance": "Major", - "threshold": "" - }, - "M1": { - "edge": "", - "onset_energy (eV)": 5002.0, - "relevance": "Minor", - "threshold": "" - }, - "M2": { - "edge": "", - "onset_energy (eV)": 4656.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 3909.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "", - "onset_energy (eV)": 3370.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "", - "onset_energy (eV)": 3219.0, - "relevance": "Major", - "threshold": "" - }, - "N1": { - "edge": "", - "onset_energy (eV)": 1269.0, - "relevance": "Minor", - "threshold": "" - }, - "N2": { - "edge": "", - "onset_energy (eV)": 1080.0, - "relevance": "Minor", - "threshold": "" - }, - "N3": { - "edge": "", - "onset_energy (eV)": 890.0, - "relevance": "Minor", - "threshold": "" - }, - "N4": { - "edge": "", - "onset_energy (eV)": 675.0, - "relevance": "Minor", - "threshold": "" - } - }, - "Ag": { - "M2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 602.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 571.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 373.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 367.0, - "relevance": "Major", - "threshold": "" - } - }, - "Al": { - "K": { - "edge": "Abrupt onset", - "onset_energy (eV)": 1560.0, - "relevance": "Major", - "threshold": "" - }, - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 118.0, - "relevance": "Minor", - "threshold": "" - }, - "L2,3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 73.0, - "relevance": "Major", - "threshold": "Sharp peak" - } - }, - "Am": { - "L1": { - "edge": "", - "onset_energy (eV)": 23773.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "", - "onset_energy (eV)": 22944.0, - "relevance": "Minor", - "threshold": "" - }, - "L3": { - "edge": "", - "onset_energy (eV)": 18504.0, - "relevance": "Major", - "threshold": "" - }, - "M1": { - "edge": "", - "onset_energy (eV)": 6121.0, - "relevance": "Minor", - "threshold": "" - }, - "M2": { - "edge": "", - "onset_energy (eV)": 5710.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 4667.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "", - "onset_energy (eV)": 4092.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "", - "onset_energy (eV)": 3887.0, - "relevance": "Major", - "threshold": "" - }, - "N1": { - "edge": "", - "onset_energy (eV)": 1617.0, - "relevance": "Minor", - "threshold": "" - }, - "N2": { - "edge": "", - "onset_energy (eV)": 1412.0, - "relevance": "Minor", - "threshold": "" - }, - "N3": { - "edge": "", - "onset_energy (eV)": 1136.0, - "relevance": "Minor", - "threshold": "" - }, - "N4": { - "edge": "", - "onset_energy (eV)": 879.0, - "relevance": "Minor", - "threshold": "" - }, - "N5": { - "edge": "", - "onset_energy (eV)": 828.0, - "relevance": "Minor", - "threshold": "" - }, - "O4": { - "edge": "", - "onset_energy (eV)": 116.0, - "relevance": "Major", - "threshold": "" - }, - "O5": { - "edge": "", - "onset_energy (eV)": 103.0, - "relevance": "Major", - "threshold": "" - } - }, - "Ar": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 320.0, - "relevance": "Minor", - "threshold": "" - }, - "L2,3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 245.0, - "relevance": "Major", - "threshold": "" - } - }, - "As": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 1526.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1359.0, - "relevance": "Major", - "threshold": "" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1323.0, - "relevance": "Major", - "threshold": "" - } - }, - "At": { - "L1": { - "edge": "", - "onset_energy (eV)": 17493.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "", - "onset_energy (eV)": 16785.0, - "relevance": "Minor", - "threshold": "" - }, - "L3": { - "edge": "", - "onset_energy (eV)": 14214.0, - "relevance": "Major", - "threshold": "" - }, - "M1": { - "edge": "", - "onset_energy (eV)": 4317.0, - "relevance": "Minor", - "threshold": "" - }, - "M2": { - "edge": "", - "onset_energy (eV)": 4008.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 3426.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "", - "onset_energy (eV)": 2908.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "", - "onset_energy (eV)": 2787.0, - "relevance": "Major", - "threshold": "" - }, - "N1": { - "edge": "", - "onset_energy (eV)": 1042.0, - "relevance": "Minor", - "threshold": "" - }, - "N2": { - "edge": "", - "onset_energy (eV)": 886.0, - "relevance": "Minor", - "threshold": "" - }, - "N3": { - "edge": "", - "onset_energy (eV)": 740.0, - "relevance": "Minor", - "threshold": "" - }, - "N4": { - "edge": "", - "onset_energy (eV)": 879.0, - "relevance": "Minor", - "threshold": "" - }, - "N5": { - "edge": "", - "onset_energy (eV)": 533.0, - "relevance": "Minor", - "threshold": "" - } - }, - "Au": { - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2291.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2206.0, - "relevance": "Major", - "threshold": "" - } - }, - "B": { - "K": { - "edge": "Abrupt onset", - "onset_energy (eV)": 188.0, - "relevance": "Major", - "threshold": "" - } - }, - "Ba": { - "M2": { - "edge": "", - "onset_energy (eV)": 1137.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 1062.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 796.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 781.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "N4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 90.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "N5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 90.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Be": { - "K": { - "edge": "Abrupt onset", - "onset_energy (eV)": 111.0, - "relevance": "Major", - "threshold": "" - } - }, - "Bi": { - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2688.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2580.0, - "relevance": "Major", - "threshold": "" - } - }, - "Br": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 1782.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1596.0, - "relevance": "Major", - "threshold": "" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1550.0, - "relevance": "Major", - "threshold": "" - } - }, - "C": { - "K": { - "edge": "Abrupt onset", - "onset_energy (eV)": 284.0, - "relevance": "Major", - "threshold": "" - } - }, - "Ca": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 438.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 350.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 346.0, - "relevance": "Major", - "threshold": "Sharp peak" - } - }, - "Cd": { - "M2": { - "edge": "", - "onset_energy (eV)": 651.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 616.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 411.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 404.0, - "relevance": "Major", - "threshold": "" - } - }, - "Ce": { - "M2": { - "edge": "", - "onset_energy (eV)": 1273.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 1185.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 901.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 883.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "N4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 110.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "N5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 110.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Cl": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 270.0, - "relevance": "Minor", - "threshold": "" - }, - "L2,3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 200.0, - "relevance": "Major", - "threshold": "" - } - }, - "Co": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 926.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 794.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 779.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 59.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "M3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 59.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Cr": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 695.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 584.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 575.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 43.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "M3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 43.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Cs": { - "M2": { - "edge": "", - "onset_energy (eV)": 1065.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 998.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 740.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 726.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "N4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 78.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "N5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 78.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Cu": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 1096.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 951.0, - "relevance": "Major", - "threshold": "" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 931.0, - "relevance": "Major", - "threshold": "" - }, - "M2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 74.0, - "relevance": "Major", - "threshold": "" - }, - "M3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 74.0, - "relevance": "Major", - "threshold": "" - } - }, - "Dy": { - "M2": { - "edge": "", - "onset_energy (eV)": 1842.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 1676.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1332.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1295.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "N4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 154.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "N5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 154.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Er": { - "M2": { - "edge": "", - "onset_energy (eV)": 2006.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 1812.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1453.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1409.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "N4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 168.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "N5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 168.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Eu": { - "M2": { - "edge": "", - "onset_energy (eV)": 1614.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 1481.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1161.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1131.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "N4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 134.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "N5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 134.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "F": { - "K": { - "edge": "Abrupt onset", - "onset_energy (eV)": 685.0, - "relevance": "Major", - "threshold": "" - } - }, - "Fe": { - "K": { - "edge": "", - "onset_energy (eV)": 7113.0, - "relevance": "Minor", - "threshold": "" - }, - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 846.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 721.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 708.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 57.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "M3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 57.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Fr": { - "L1": { - "edge": "", - "onset_energy (eV)": 18639.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "", - "onset_energy (eV)": 17907.0, - "relevance": "Minor", - "threshold": "" - }, - "L3": { - "edge": "", - "onset_energy (eV)": 15031.0, - "relevance": "Major", - "threshold": "" - }, - "M1": { - "edge": "", - "onset_energy (eV)": 4652.0, - "relevance": "Minor", - "threshold": "" - }, - "M2": { - "edge": "", - "onset_energy (eV)": 4327.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 3663.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "", - "onset_energy (eV)": 3136.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "", - "onset_energy (eV)": 3000.0, - "relevance": "Major", - "threshold": "" - }, - "N1": { - "edge": "", - "onset_energy (eV)": 1153.0, - "relevance": "Minor", - "threshold": "" - }, - "N2": { - "edge": "", - "onset_energy (eV)": 980.0, - "relevance": "Minor", - "threshold": "" - }, - "N3": { - "edge": "", - "onset_energy (eV)": 810.0, - "relevance": "Minor", - "threshold": "" - }, - "N4": { - "edge": "", - "onset_energy (eV)": 603.0, - "relevance": "Minor", - "threshold": "" - }, - "N5": { - "edge": "", - "onset_energy (eV)": 577.0, - "relevance": "Minor", - "threshold": "" - } - }, - "Ga": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 1298.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1142.0, - "relevance": "Major", - "threshold": "" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1115.0, - "relevance": "Major", - "threshold": "" - } - }, - "Gd": { - "M2": { - "edge": "", - "onset_energy (eV)": 1688.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 1544.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1217.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1185.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "N4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 141.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "N5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 141.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Ge": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 1414.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1248.0, - "relevance": "Major", - "threshold": "" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1217.0, - "relevance": "Major", - "threshold": "" - } - }, - "H": { - "K": { - "edge": "Abrupt onset", - "onset_energy (eV)": 13.598, - "relevance": "Major", - "threshold": "" - } - }, - "He": { - "K": { - "edge": "Abrupt onset", - "onset_energy (eV)": 24.587, - "relevance": "Major", - "threshold": "" - } - }, - "Hf": { - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1716.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1662.0, - "relevance": "Major", - "threshold": "" - } - }, - "Hg": { - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2385.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2295.0, - "relevance": "Major", - "threshold": "" - } - }, - "Ho": { - "M2": { - "edge": "", - "onset_energy (eV)": 1923.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 1741.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1391.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1351.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "N4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 161.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "N5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 161.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "I": { - "M2": { - "edge": "", - "onset_energy (eV)": 930.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 875.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 631.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 620.0, - "relevance": "Major", - "threshold": "" - } - }, - "In": { - "M2": { - "edge": "", - "onset_energy (eV)": 702.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 664.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 451.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 443.0, - "relevance": "Major", - "threshold": "" - } - }, - "Ir": { - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2116.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2040.0, - "relevance": "Major", - "threshold": "" - } - }, - "K": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 377.0, - "relevance": "Minor", - "threshold": "" - }, - "L1a": { - "edge": "Abrupt onset", - "onset_energy (eV)": 377.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 296.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 294.0, - "relevance": "Major", - "threshold": "Sharp peak" - } - }, - "Kr": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 1921.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1727.0, - "relevance": "Major", - "threshold": "" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1675.0, - "relevance": "Major", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 89.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 89.0, - "relevance": "Major", - "threshold": "Sharp peak" - } - }, - "La": { - "M2": { - "edge": "", - "onset_energy (eV)": 1204.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 1123.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 849.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 832.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "N4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 99.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "N5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 99.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Li": { - "K": { - "edge": "Abrupt onset", - "onset_energy (eV)": 55.0, - "relevance": "Major", - "threshold": "" - } - }, - "Lu": { - "M2": { - "edge": "", - "onset_energy (eV)": 2263.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 2024.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1639.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1588.0, - "relevance": "Major", - "threshold": "" - }, - "N4": { - "edge": "Very delayed", - "onset_energy (eV)": 195.0, - "relevance": "Major", - "threshold": "" - }, - "N5": { - "edge": "Very delayed", - "onset_energy (eV)": 195.0, - "relevance": "Major", - "threshold": "" - } - }, - "Mg": { - "K": { - "edge": "Abrupt onset", - "onset_energy (eV)": 1305.0, - "relevance": "Major", - "threshold": "" - }, - "L1": { - "edge": "", - "onset_energy (eV)": 89.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 51.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 51.0, - "relevance": "Major", - "threshold": "Sharp peak" - } - }, - "Mn": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 769.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 651.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 640.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 51.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "M3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 51.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Mo": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 2866.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2625.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2520.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M2": { - "edge": "", - "onset_energy (eV)": 410.0, - "relevance": "Minor", - "threshold": "Sharp peak" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 392.0, - "relevance": "Minor", - "threshold": "Sharp peak" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 228.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 227.0, - "relevance": "Major", - "threshold": "" - } - }, - "N": { - "K": { - "edge": "Abrupt onset", - "onset_energy (eV)": 401.0, - "relevance": "Major", - "threshold": "" - } - }, - "Na": { - "K": { - "edge": "Abrupt onset", - "onset_energy (eV)": 1072.0, - "relevance": "Major", - "threshold": "" - }, - "L1": { - "edge": "", - "onset_energy (eV)": 63.0, - "relevance": "Minor", - "threshold": "" - }, - "L2,3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 31.0, - "relevance": "Major", - "threshold": "Sharp peak" - } - }, - "Nb": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 2698.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2465.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2371.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M2": { - "edge": "", - "onset_energy (eV)": 378.0, - "relevance": "Minor", - "threshold": "Sharp peak" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 363.0, - "relevance": "Minor", - "threshold": "Sharp peak" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 205.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 202.3, - "relevance": "Major", - "threshold": "" - } - }, - "Nd": { - "M2": { - "edge": "", - "onset_energy (eV)": 1403.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 1297.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1000.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 978.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "N4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 118.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "N5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 118.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Ne": { - "K": { - "edge": "Abrupt onset", - "onset_energy (eV)": 867.0, - "relevance": "Major", - "threshold": "" - } - }, - "Ni": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 1008.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 872.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 855.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 68.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "M3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 68.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Np": { - "L1": { - "edge": "", - "onset_energy (eV)": 22427.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "", - "onset_energy (eV)": 21601.0, - "relevance": "Minor", - "threshold": "" - }, - "L3": { - "edge": "", - "onset_energy (eV)": 17610.0, - "relevance": "Major", - "threshold": "" - }, - "M1": { - "edge": "", - "onset_energy (eV)": 5723.0, - "relevance": "Minor", - "threshold": "" - }, - "M2": { - "edge": "", - "onset_energy (eV)": 5366.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 4435.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "", - "onset_energy (eV)": 3850.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "", - "onset_energy (eV)": 3666.0, - "relevance": "Major", - "threshold": "" - }, - "N1": { - "edge": "", - "onset_energy (eV)": 1501.0, - "relevance": "Minor", - "threshold": "" - }, - "N2": { - "edge": "", - "onset_energy (eV)": 1328.0, - "relevance": "Minor", - "threshold": "" - }, - "N3": { - "edge": "", - "onset_energy (eV)": 1087.0, - "relevance": "Minor", - "threshold": "" - }, - "N4": { - "edge": "", - "onset_energy (eV)": 816.0, - "relevance": "Minor", - "threshold": "" - }, - "N5": { - "edge": "", - "onset_energy (eV)": 770.0, - "relevance": "Minor", - "threshold": "" - }, - "N6": { - "edge": "", - "onset_energy (eV)": 415.0, - "relevance": "Major", - "threshold": "" - }, - "N7": { - "edge": "", - "onset_energy (eV)": 404.0, - "relevance": "Major", - "threshold": "" - }, - "O1": { - "edge": "", - "onset_energy (eV)": 206.0, - "relevance": "Minor", - "threshold": "" - }, - "O2": { - "edge": "", - "onset_energy (eV)": 283.0, - "relevance": "Minor", - "threshold": "" - }, - "O4": { - "edge": "", - "onset_energy (eV)": 109.0, - "relevance": "Major", - "threshold": "" - }, - "O5": { - "edge": "", - "onset_energy (eV)": 101.0, - "relevance": "Major", - "threshold": "" - } - }, - "O": { - "K": { - "edge": "Abrupt onset", - "onset_energy (eV)": 532.0, - "relevance": "Major", - "threshold": "" - } - }, - "Os": { - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2031.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1960.0, - "relevance": "Major", - "threshold": "" - } - }, - "P": { - "K": { - "edge": "Abrupt onset", - "onset_energy (eV)": 2146.0, - "relevance": "Major", - "threshold": "" - }, - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 189.0, - "relevance": "Minor", - "threshold": "" - }, - "L2,3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 132.0, - "relevance": "Major", - "threshold": "Sharp peak" - } - }, - "Pa": { - "L1": { - "edge": "", - "onset_energy (eV)": 21105.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "", - "onset_energy (eV)": 20314.0, - "relevance": "Minor", - "threshold": "" - }, - "L3": { - "edge": "", - "onset_energy (eV)": 16733.0, - "relevance": "Major", - "threshold": "" - }, - "M1": { - "edge": "", - "onset_energy (eV)": 5367.0, - "relevance": "Minor", - "threshold": "" - }, - "M2": { - "edge": "", - "onset_energy (eV)": 5001.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 4174.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "", - "onset_energy (eV)": 3611.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "", - "onset_energy (eV)": 3442.0, - "relevance": "Major", - "threshold": "" - }, - "N1": { - "edge": "", - "onset_energy (eV)": 1387.0, - "relevance": "Minor", - "threshold": "" - }, - "N2": { - "edge": "", - "onset_energy (eV)": 1224.0, - "relevance": "Minor", - "threshold": "" - }, - "N3": { - "edge": "", - "onset_energy (eV)": 1007.0, - "relevance": "Minor", - "threshold": "" - }, - "N4": { - "edge": "", - "onset_energy (eV)": 743.0, - "relevance": "Minor", - "threshold": "" - }, - "N5": { - "edge": "", - "onset_energy (eV)": 708.0, - "relevance": "Minor", - "threshold": "" - }, - "N6": { - "edge": "", - "onset_energy (eV)": 371.0, - "relevance": "Major", - "threshold": "" - }, - "N7": { - "edge": "", - "onset_energy (eV)": 360.0, - "relevance": "Major", - "threshold": "" - }, - "O1": { - "edge": "", - "onset_energy (eV)": 223.0, - "relevance": "Minor", - "threshold": "" - }, - "O2": { - "edge": "", - "onset_energy (eV)": 310.0, - "relevance": "Minor", - "threshold": "" - }, - "O4": { - "edge": "", - "onset_energy (eV)": 94.0, - "relevance": "Major", - "threshold": "" - }, - "O5": { - "edge": "", - "onset_energy (eV)": 94.0, - "relevance": "Major", - "threshold": "" - } - }, - "Pb": { - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2586.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2484.0, - "relevance": "Major", - "threshold": "" - } - }, - "Pd": { - "M2": { - "edge": "", - "onset_energy (eV)": 559.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 531.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 340.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 335.0, - "relevance": "Major", - "threshold": "" - } - }, - "Pm": { - "L1": { - "edge": "", - "onset_energy (eV)": 7428.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "", - "onset_energy (eV)": 7013.0, - "relevance": "Minor", - "threshold": "" - }, - "L3": { - "edge": "", - "onset_energy (eV)": 6459.0, - "relevance": "Major", - "threshold": "" - }, - "M1": { - "edge": "Abrupt Onset", - "onset_energy (eV)": 1646.0, - "relevance": "Minor", - "threshold": "" - }, - "M2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1471.0, - "relevance": "Minor", - "threshold": "Sharp Peak" - }, - "M3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1357.0, - "relevance": "Minor", - "threshold": "Sharp Peak" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1052.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1027.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "N1": { - "edge": "Abrupt Onset", - "onset_energy (eV)": 330.0, - "relevance": "Minor", - "threshold": "" - }, - "N2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 242.0, - "relevance": "Minor", - "threshold": "Sharp Peak" - }, - "N4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 120.0, - "relevance": "Minor", - "threshold": "Broad peak" - }, - "N5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 121.0, - "relevance": "Minor", - "threshold": "Broad peak" - }, - "O2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 24.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "O3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 24.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Po": { - "L1": { - "edge": "", - "onset_energy (eV)": 16939.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "", - "onset_energy (eV)": 16244.0, - "relevance": "Minor", - "threshold": "" - }, - "L3": { - "edge": "", - "onset_energy (eV)": 13814.0, - "relevance": "Major", - "threshold": "" - }, - "M1": { - "edge": "", - "onset_energy (eV)": 4149.0, - "relevance": "Minor", - "threshold": "" - }, - "M2": { - "edge": "", - "onset_energy (eV)": 3854.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 3302.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "", - "onset_energy (eV)": 2798.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "", - "onset_energy (eV)": 2683.0, - "relevance": "Major", - "threshold": "" - }, - "N1": { - "edge": "", - "onset_energy (eV)": 995.0, - "relevance": "Minor", - "threshold": "" - }, - "N2": { - "edge": "", - "onset_energy (eV)": 851.0, - "relevance": "Minor", - "threshold": "" - }, - "O4": { - "edge": "", - "onset_energy (eV)": 31.0, - "relevance": "Major", - "threshold": "" - }, - "O5": { - "edge": "", - "onset_energy (eV)": 31.0, - "relevance": "Major", - "threshold": "" - } - }, - "Pr": { - "M2": { - "edge": "", - "onset_energy (eV)": 1337.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 1242.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 951.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 931.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "N4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 114.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "N5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 114.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Pt": { - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2202.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2122.0, - "relevance": "Major", - "threshold": "" - } - }, - "Pu": { - "L1": { - "edge": "", - "onset_energy (eV)": 23097.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "", - "onset_energy (eV)": 22266.0, - "relevance": "Minor", - "threshold": "" - }, - "L3": { - "edge": "", - "onset_energy (eV)": 18057.0, - "relevance": "Major", - "threshold": "" - }, - "M1": { - "edge": "", - "onset_energy (eV)": 5933.0, - "relevance": "Minor", - "threshold": "" - }, - "M2": { - "edge": "", - "onset_energy (eV)": 5541.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 4557.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "", - "onset_energy (eV)": 3973.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "", - "onset_energy (eV)": 3778.0, - "relevance": "Major", - "threshold": "" - }, - "N1": { - "edge": "", - "onset_energy (eV)": 1559.0, - "relevance": "Minor", - "threshold": "" - }, - "N2": { - "edge": "", - "onset_energy (eV)": 1372.0, - "relevance": "Minor", - "threshold": "" - }, - "N3": { - "edge": "", - "onset_energy (eV)": 1115.0, - "relevance": "Minor", - "threshold": "" - }, - "N4": { - "edge": "", - "onset_energy (eV)": 849.0, - "relevance": "Minor", - "threshold": "" - }, - "N5": { - "edge": "", - "onset_energy (eV)": 801.0, - "relevance": "Minor", - "threshold": "" - }, - "N6": { - "edge": "", - "onset_energy (eV)": 446.0, - "relevance": "Major", - "threshold": "" - }, - "N7": { - "edge": "", - "onset_energy (eV)": 432.0, - "relevance": "Major", - "threshold": "" - }, - "O1": { - "edge": "", - "onset_energy (eV)": 352.0, - "relevance": "Minor", - "threshold": "" - }, - "O2": { - "edge": "", - "onset_energy (eV)": 274.0, - "relevance": "Minor", - "threshold": "" - }, - "O3": { - "edge": "", - "onset_energy (eV)": 207.0, - "relevance": "Minor", - "threshold": "" - }, - "O4": { - "edge": "", - "onset_energy (eV)": 116.0, - "relevance": "Major", - "threshold": "" - }, - "O5": { - "edge": "", - "onset_energy (eV)": 105.0, - "relevance": "Major", - "threshold": "" - } - }, - "Ra": { - "L1": { - "edge": "", - "onset_energy (eV)": 19237.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "", - "onset_energy (eV)": 18484.0, - "relevance": "Minor", - "threshold": "" - }, - "L3": { - "edge": "", - "onset_energy (eV)": 15444.0, - "relevance": "Major", - "threshold": "" - }, - "M1": { - "edge": "", - "onset_energy (eV)": 4822.0, - "relevance": "Minor", - "threshold": "" - }, - "M2": { - "edge": "", - "onset_energy (eV)": 4490.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 3792.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "", - "onset_energy (eV)": 3248.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "", - "onset_energy (eV)": 3105.0, - "relevance": "Major", - "threshold": "" - }, - "N1": { - "edge": "", - "onset_energy (eV)": 1208.0, - "relevance": "Minor", - "threshold": "" - }, - "N2": { - "edge": "", - "onset_energy (eV)": 1058.0, - "relevance": "Minor", - "threshold": "" - }, - "N3": { - "edge": "", - "onset_energy (eV)": 879.0, - "relevance": "Minor", - "threshold": "" - }, - "N4": { - "edge": "", - "onset_energy (eV)": 636.0, - "relevance": "Minor", - "threshold": "" - }, - "N5": { - "edge": "", - "onset_energy (eV)": 603.0, - "relevance": "Minor", - "threshold": "" - }, - "N6": { - "edge": "", - "onset_energy (eV)": 299.0, - "relevance": "Major", - "threshold": "" - }, - "N7": { - "edge": "", - "onset_energy (eV)": 299.0, - "relevance": "Major", - "threshold": "" - }, - "O1": { - "edge": "", - "onset_energy (eV)": 254.0, - "relevance": "Minor", - "threshold": "" - }, - "O2": { - "edge": "", - "onset_energy (eV)": 254.0, - "relevance": "Minor", - "threshold": "" - }, - "O3": { - "edge": "", - "onset_energy (eV)": 153.0, - "relevance": "Minor", - "threshold": "" - }, - "O4": { - "edge": "", - "onset_energy (eV)": 67.0, - "relevance": "Major", - "threshold": "" - }, - "O5": { - "edge": "", - "onset_energy (eV)": 67.0, - "relevance": "Major", - "threshold": "" - } - }, - "Rb": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 2065.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1864.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1804.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 247.0, - "relevance": "Minor", - "threshold": "Sharp peak" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 238.0, - "relevance": "Minor", - "threshold": "Sharp peak" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 110.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 110.0, - "relevance": "Major", - "threshold": "" - } - }, - "Re": { - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1949.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1883.0, - "relevance": "Major", - "threshold": "" - } - }, - "Rh": { - "M2": { - "edge": "", - "onset_energy (eV)": 521.0, - "relevance": "Minor", - "threshold": "Sharp peak" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 496.0, - "relevance": "Minor", - "threshold": "Sharp peak" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 312.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 308.0, - "relevance": "Major", - "threshold": "" - } - }, - "Rn": { - "L1": { - "edge": "", - "onset_energy (eV)": 18049.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "", - "onset_energy (eV)": 17337.0, - "relevance": "Minor", - "threshold": "" - }, - "L3": { - "edge": "", - "onset_energy (eV)": 14619.0, - "relevance": "Major", - "threshold": "" - }, - "M1": { - "edge": "", - "onset_energy (eV)": 4482.0, - "relevance": "Minor", - "threshold": "" - }, - "M2": { - "edge": "", - "onset_energy (eV)": 4159.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 3538.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "", - "onset_energy (eV)": 3022.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "", - "onset_energy (eV)": 2892.0, - "relevance": "Major", - "threshold": "" - }, - "N1": { - "edge": "", - "onset_energy (eV)": 1097.0, - "relevance": "Minor", - "threshold": "" - }, - "N2": { - "edge": "", - "onset_energy (eV)": 929.0, - "relevance": "Minor", - "threshold": "" - }, - "N3": { - "edge": "", - "onset_energy (eV)": 768.0, - "relevance": "Minor", - "threshold": "" - }, - "N4": { - "edge": "", - "onset_energy (eV)": 567.0, - "relevance": "Minor", - "threshold": "" - } - }, - "Ru": { - "M2": { - "edge": "", - "onset_energy (eV)": 483.0, - "relevance": "Minor", - "threshold": "Sharp peak" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 461.0, - "relevance": "Minor", - "threshold": "Sharp peak" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 279.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 279.0, - "relevance": "Major", - "threshold": "" - } - }, - "S": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 229.0, - "relevance": "Minor", - "threshold": "" - }, - "L2,3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 165.0, - "relevance": "Major", - "threshold": "" - } - }, - "Sb": { - "M2": { - "edge": "", - "onset_energy (eV)": 812.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 766.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 537.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 528.0, - "relevance": "Major", - "threshold": "" - } - }, - "Sc": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 500.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 407.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 402.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 32.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "M3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 32.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Se": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 1654.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1476.0, - "relevance": "Major", - "threshold": "" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1436.0, - "relevance": "Major", - "threshold": "" - } - }, - "Si": { - "K": { - "edge": "Abrupt onset", - "onset_energy (eV)": 1839.0, - "relevance": "Major", - "threshold": "" - }, - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 149.7, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 99.8, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 99.2, - "relevance": "Major", - "threshold": "Sharp peak" - } - }, - "Sm": { - "M2": { - "edge": "", - "onset_energy (eV)": 1541.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 1420.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1106.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1080.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "N4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 130.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "N5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 130.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Sn": { - "M2": { - "edge": "", - "onset_energy (eV)": 756.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 714.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 494.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 485.0, - "relevance": "Major", - "threshold": "" - } - }, - "Sr": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 2216.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2007.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1940.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M2": { - "edge": "", - "onset_energy (eV)": 280.0, - "relevance": "Minor", - "threshold": "Sharp peak" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 269.0, - "relevance": "Minor", - "threshold": "Sharp peak" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 134.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 134.0, - "relevance": "Major", - "threshold": "" - } - }, - "Ta": { - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1793.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1735.0, - "relevance": "Major", - "threshold": "" - } - }, - "Tb": { - "M2": { - "edge": "", - "onset_energy (eV)": 1768.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 1611.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1275.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1241.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "N4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 148.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "N5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 148.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Tc": { - "K": { - "edge": "", - "onset_energy (eV)": 21044.0, - "relevance": "Major", - "threshold": "" - }, - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 3043.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2793.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2677.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 544.0, - "relevance": "Minor", - "threshold": "" - }, - "M2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 445.0, - "relevance": "Minor", - "threshold": "Sharp peak" - }, - "M3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 425.0, - "relevance": "Minor", - "threshold": "Sharp peak" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 256.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 253.0, - "relevance": "Major", - "threshold": "" - }, - "N1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 68.0, - "relevance": "Minor", - "threshold": "" - }, - "N2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 39.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "N3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 39.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Te": { - "M2": { - "edge": "", - "onset_energy (eV)": 870.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 819.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 582.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 572.0, - "relevance": "Major", - "threshold": "" - } - }, - "Th": { - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 3491.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 3332.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "O4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 83.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "O5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 83.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Ti": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 564.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 462.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 456.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 35.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "M3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 35.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Tl": { - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2485.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2389.0, - "relevance": "Major", - "threshold": "" - } - }, - "Tm": { - "M2": { - "edge": "", - "onset_energy (eV)": 2090.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 1884.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1515.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1468.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "N4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 180.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "N5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 180.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "U": { - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 3728.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 3552.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "O4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 96.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "O5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 96.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "V": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 628.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 521.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 513.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 38.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "M3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 38.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "W": { - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1872.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1809.0, - "relevance": "Major", - "threshold": "" - } - }, - "Xe": { - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 685.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 672.0, - "relevance": "Major", - "threshold": "" - } - }, - "Y": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 2372.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2155.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2080.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M2": { - "edge": "", - "onset_energy (eV)": 312.0, - "relevance": "Minor", - "threshold": "Sharp peak" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 300.0, - "relevance": "Minor", - "threshold": "Sharp peak" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 160.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 160.0, - "relevance": "Major", - "threshold": "" - } - }, - "Yb": { - "M2": { - "edge": "", - "onset_energy (eV)": 2173.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 1950.0, - "relevance": "Minor", - "threshold": "" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1576.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1528.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "N4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 184.0, - "relevance": "Major", - "threshold": "Broad peak" - }, - "N5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 184.0, - "relevance": "Major", - "threshold": "Broad peak" - } - }, - "Zn": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 1194.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1043.0, - "relevance": "Major", - "threshold": "" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 1020.0, - "relevance": "Major", - "threshold": "" - }, - "M2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 87.0, - "relevance": "Minor", - "threshold": "" - }, - "M3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 87.0, - "relevance": "Minor", - "threshold": "" - } - }, - "Zr": { - "L1": { - "edge": "Abrupt onset", - "onset_energy (eV)": 2532.0, - "relevance": "Minor", - "threshold": "" - }, - "L2": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2307.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "L3": { - "edge": "Delayed maximum", - "onset_energy (eV)": 2222.0, - "relevance": "Major", - "threshold": "Sharp peak" - }, - "M2": { - "edge": "", - "onset_energy (eV)": 344.0, - "relevance": "Minor", - "threshold": "Sharp peak" - }, - "M3": { - "edge": "", - "onset_energy (eV)": 330.0, - "relevance": "Minor", - "threshold": "Sharp peak" - }, - "M4": { - "edge": "Delayed maximum", - "onset_energy (eV)": 181.0, - "relevance": "Major", - "threshold": "" - }, - "M5": { - "edge": "Delayed maximum", - "onset_energy (eV)": 181.0, - "relevance": "Major", - "threshold": "" - } - } - }, - "metadata": { - "last_updated": "2025-06-04", - "units": "eV", - "version": "1.0" - } -} diff --git a/src/quantem/spectroscopy/eels_edges.csv b/src/quantem/spectroscopy/eels_edges.csv new file mode 100644 index 00000000..08a780e9 --- /dev/null +++ b/src/quantem/spectroscopy/eels_edges.csv @@ -0,0 +1,1047 @@ +atomic_number,symbol,element,edge_label,edge_energy_eV +1,H,Hydrogen,major,14 +2,He,Helium,major,25 +3,Li,Lithium,major,55 +4,Be,Beryllium,major,111 +5,B,Boron,major,188 +6,C,Carbon,major,284 +7,N,Nitrogen,major,402 +8,O,Oxygen,major,532 +8,O,Oxygen,minor,24 +9,F,Fluorine,major,685 +9,F,Fluorine,minor,31 +10,Ne,Neon,major,867 +10,Ne,Neon,major,18 +10,Ne,Neon,minor,45 +11,Na,Sodium,major,1072 +11,Na,Sodium,major,31 +11,Na,Sodium,minor,63 +12,Mg,Magnesium,major,1305 +12,Mg,Magnesium,major,51 +12,Mg,Magnesium,minor,89 +13,Al,Aluminum,major,1560 +13,Al,Aluminum,major,73 +13,Al,Aluminum,minor,118 +14,Si,Silicon,major,1839 +14,Si,Silicon,major,99 +14,Si,Silicon,minor,149 +15,P,Phosphorus,major,2146 +15,P,Phosphorus,major,132 +15,P,Phosphorus,minor,189 +16,S,Sulfur,major,2472 +16,S,Sulfur,major,165 +16,S,Sulfur,minor,229 +17,Cl,Chlorine,major,2822 +17,Cl,Chlorine,major,202 +17,Cl,Chlorine,major,200 +17,Cl,Chlorine,minor,270 +17,Cl,Chlorine,minor,18 +18,Ar,Argon,major,3203 +18,Ar,Argon,major,247 +18,Ar,Argon,major,245 +18,Ar,Argon,major,12 +18,Ar,Argon,minor,320 +18,Ar,Argon,minor,25 +19,K,Potassium,major,3607 +19,K,Potassium,major,296 +19,K,Potassium,major,294 +19,K,Potassium,major,18 +19,K,Potassium,minor,377 +19,K,Potassium,minor,34 +20,Ca,Calcium,major,4038 +20,Ca,Calcium,major,350 +20,Ca,Calcium,major,346 +20,Ca,Calcium,major,25 +20,Ca,Calcium,minor,438 +20,Ca,Calcium,minor,44 +21,Sc,Scandium,major,4493 +21,Sc,Scandium,major,407 +21,Sc,Scandium,major,402 +21,Sc,Scandium,major,32 +21,Sc,Scandium,minor,501 +21,Sc,Scandium,minor,54 +22,Ti,Titanium,major,4966 +22,Ti,Titanium,major,462 +22,Ti,Titanium,major,456 +22,Ti,Titanium,major,35 +22,Ti,Titanium,minor,564 +22,Ti,Titanium,minor,60 +23,V,Vanadium,major,5465 +23,V,Vanadium,major,521 +23,V,Vanadium,major,513 +23,V,Vanadium,major,38 +23,V,Vanadium,minor,628 +23,V,Vanadium,minor,67 +24,Cr,Chromium,major,5989 +24,Cr,Chromium,major,584 +24,Cr,Chromium,major,575 +24,Cr,Chromium,major,43 +24,Cr,Chromium,minor,695 +24,Cr,Chromium,minor,74 +25,Mn,Manganese,major,6539 +25,Mn,Manganese,major,651 +25,Mn,Manganese,major,640 +25,Mn,Manganese,major,49 +25,Mn,Manganese,minor,769 +25,Mn,Manganese,minor,84 +26,Fe,Iron,major,7112 +26,Fe,Iron,major,721 +26,Fe,Iron,major,708 +26,Fe,Iron,major,54 +26,Fe,Iron,minor,846 +26,Fe,Iron,minor,93 +27,Co,Cobalt,major,7709 +27,Co,Cobalt,major,794 +27,Co,Cobalt,major,779 +27,Co,Cobalt,major,60 +27,Co,Cobalt,minor,926 +27,Co,Cobalt,minor,101 +28,Ni,Nickel,major,8333 +28,Ni,Nickel,major,872 +28,Ni,Nickel,major,855 +28,Ni,Nickel,major,68 +28,Ni,Nickel,minor,1008 +28,Ni,Nickel,minor,112 +29,Cu,Copper,major,8979 +29,Cu,Copper,major,951 +29,Cu,Copper,major,931 +29,Cu,Copper,major,74 +29,Cu,Copper,minor,1097 +29,Cu,Copper,minor,120 +30,Zn,Zinc,major,9659 +30,Zn,Zinc,major,1043 +30,Zn,Zinc,major,1020 +30,Zn,Zinc,minor,1194 +30,Zn,Zinc,minor,136 +30,Zn,Zinc,minor,87 +31,Ga,Gallium,major,10367 +31,Ga,Gallium,major,1142 +31,Ga,Gallium,major,1115 +31,Ga,Gallium,minor,1298 +31,Ga,Gallium,minor,158 +31,Ga,Gallium,minor,107 +31,Ga,Gallium,minor,103 +31,Ga,Gallium,minor,17 +32,Ge,Germanium,major,11103 +32,Ge,Germanium,major,1248 +32,Ge,Germanium,major,1217 +32,Ge,Germanium,major,29 +32,Ge,Germanium,minor,1414 +32,Ge,Germanium,minor,180 +32,Ge,Germanium,minor,128 +32,Ge,Germanium,minor,121 +33,As,Arsenic,major,11867 +33,As,Arsenic,major,1359 +33,As,Arsenic,major,1323 +33,As,Arsenic,major,41 +33,As,Arsenic,minor,1527 +33,As,Arsenic,minor,204 +33,As,Arsenic,minor,146 +33,As,Arsenic,minor,141 +34,Se,Selenium,major,12658 +34,Se,Selenium,major,1476 +34,Se,Selenium,major,1436 +34,Se,Selenium,major,57 +34,Se,Selenium,minor,1654 +34,Se,Selenium,minor,232 +34,Se,Selenium,minor,168 +34,Se,Selenium,minor,162 +35,Br,Bromine,major,13474 +35,Br,Bromine,major,1596 +35,Br,Bromine,major,1550 +35,Br,Bromine,major,70 +35,Br,Bromine,major,69 +35,Br,Bromine,minor,1782 +35,Br,Bromine,minor,257 +35,Br,Bromine,minor,189 +35,Br,Bromine,minor,182 +35,Br,Bromine,minor,27 +36,Kr,Krypton,major,14326 +36,Kr,Krypton,major,1727 +36,Kr,Krypton,major,1675 +36,Kr,Krypton,major,89 +36,Kr,Krypton,major,11 +36,Kr,Krypton,minor,1921 +36,Kr,Krypton,minor,287 +36,Kr,Krypton,minor,223 +36,Kr,Krypton,minor,214 +36,Kr,Krypton,minor,24 +37,Rb,Rubidium,major,15200 +37,Rb,Rubidium,major,1864 +37,Rb,Rubidium,major,1804 +37,Rb,Rubidium,major,112 +37,Rb,Rubidium,major,110 +37,Rb,Rubidium,major,15 +37,Rb,Rubidium,major,14 +37,Rb,Rubidium,minor,2065 +37,Rb,Rubidium,minor,322 +37,Rb,Rubidium,minor,247 +37,Rb,Rubidium,minor,239 +37,Rb,Rubidium,minor,29 +38,Sr,Strontium,major,16105 +38,Sr,Strontium,major,2007 +38,Sr,Strontium,major,1940 +38,Sr,Strontium,major,135 +38,Sr,Strontium,major,133 +38,Sr,Strontium,major,20 +38,Sr,Strontium,minor,2216 +38,Sr,Strontium,minor,358 +38,Sr,Strontium,minor,280 +38,Sr,Strontium,minor,269 +38,Sr,Strontium,minor,38 +39,Y,Yttrium,major,17038 +39,Y,Yttrium,major,2156 +39,Y,Yttrium,major,2080 +39,Y,Yttrium,major,160 +39,Y,Yttrium,major,157 +39,Y,Yttrium,major,26 +39,Y,Yttrium,minor,2373 +39,Y,Yttrium,minor,394 +39,Y,Yttrium,minor,312 +39,Y,Yttrium,minor,300 +39,Y,Yttrium,minor,45 +40,Zr,Zirconium,major,17998 +40,Zr,Zirconium,major,2307 +40,Zr,Zirconium,major,2222 +40,Zr,Zirconium,major,182 +40,Zr,Zirconium,major,180 +40,Zr,Zirconium,major,29 +40,Zr,Zirconium,minor,2532 +40,Zr,Zirconium,minor,430 +40,Zr,Zirconium,minor,344 +40,Zr,Zirconium,minor,331 +40,Zr,Zirconium,minor,51 +41,Nb,Niobium,major,18986 +41,Nb,Niobium,major,2465 +41,Nb,Niobium,major,2371 +41,Nb,Niobium,major,207 +41,Nb,Niobium,major,205 +41,Nb,Niobium,major,34 +41,Nb,Niobium,minor,2698 +41,Nb,Niobium,minor,468 +41,Nb,Niobium,minor,378 +41,Nb,Niobium,minor,363 +41,Nb,Niobium,minor,58 +42,Mo,Molybdenum,major,20000 +42,Mo,Molybdenum,major,2625 +42,Mo,Molybdenum,major,2520 +42,Mo,Molybdenum,major,230 +42,Mo,Molybdenum,major,227 +42,Mo,Molybdenum,major,35 +42,Mo,Molybdenum,minor,2866 +42,Mo,Molybdenum,minor,505 +42,Mo,Molybdenum,minor,410 +42,Mo,Molybdenum,minor,392 +42,Mo,Molybdenum,minor,62 +43,Tc,Technetium,major,21044 +43,Tc,Technetium,major,2793 +43,Tc,Technetium,major,2677 +43,Tc,Technetium,major,256 +43,Tc,Technetium,major,253 +43,Tc,Technetium,major,39 +43,Tc,Technetium,minor,3043 +43,Tc,Technetium,minor,544 +43,Tc,Technetium,minor,445 +43,Tc,Technetium,minor,425 +43,Tc,Technetium,minor,68 +44,Ru,Ruthenium,major,22117 +44,Ru,Ruthenium,major,2967 +44,Ru,Ruthenium,major,2838 +44,Ru,Ruthenium,major,284 +44,Ru,Ruthenium,major,279 +44,Ru,Ruthenium,major,43 +44,Ru,Ruthenium,minor,3224 +44,Ru,Ruthenium,minor,585 +44,Ru,Ruthenium,minor,483 +44,Ru,Ruthenium,minor,407 +44,Ru,Ruthenium,minor,75 +45,Rh,Rhodium,major,23220 +45,Rh,Rhodium,major,3146 +45,Rh,Rhodium,major,3004 +45,Rh,Rhodium,major,312 +45,Rh,Rhodium,major,307 +45,Rh,Rhodium,major,48 +45,Rh,Rhodium,minor,3412 +45,Rh,Rhodium,minor,627 +45,Rh,Rhodium,minor,521 +45,Rh,Rhodium,minor,496 +45,Rh,Rhodium,minor,81 +46,Pd,Palladium,major,24350 +46,Pd,Palladium,major,3330 +46,Pd,Palladium,major,3173 +46,Pd,Palladium,major,340 +46,Pd,Palladium,major,335 +46,Pd,Palladium,major,51 +46,Pd,Palladium,minor,3604 +46,Pd,Palladium,minor,670 +46,Pd,Palladium,minor,559 +46,Pd,Palladium,minor,532 +46,Pd,Palladium,minor,86 +47,Ag,Silver,major,25514 +47,Ag,Silver,major,3524 +47,Ag,Silver,major,3351 +47,Ag,Silver,major,373 +47,Ag,Silver,major,367 +47,Ag,Silver,minor,3806 +47,Ag,Silver,minor,718 +47,Ag,Silver,minor,602 +47,Ag,Silver,minor,571 +47,Ag,Silver,minor,95 +47,Ag,Silver,minor,63 +47,Ag,Silver,minor,56 +48,Cd,Cadmium,major,26711 +48,Cd,Cadmium,major,3727 +48,Cd,Cadmium,major,3538 +48,Cd,Cadmium,major,411 +48,Cd,Cadmium,major,404 +48,Cd,Cadmium,minor,4018 +48,Cd,Cadmium,minor,770 +48,Cd,Cadmium,minor,651 +48,Cd,Cadmium,minor,617 +48,Cd,Cadmium,minor,108 +48,Cd,Cadmium,minor,67 +49,In,Indium,major,27940 +49,In,Indium,major,3938 +49,In,Indium,major,3730 +49,In,Indium,major,451 +49,In,Indium,major,443 +49,In,Indium,minor,4238 +49,In,Indium,minor,826 +49,In,Indium,minor,702 +49,In,Indium,minor,664 +49,In,Indium,minor,122 +49,In,Indium,minor,77 +50,Sn,Tin,major,29200 +50,Sn,Tin,major,4156 +50,Sn,Tin,major,3929 +50,Sn,Tin,major,493 +50,Sn,Tin,major,485 +50,Sn,Tin,major,24 +50,Sn,Tin,minor,4465 +50,Sn,Tin,minor,884 +50,Sn,Tin,minor,756 +50,Sn,Tin,minor,714 +50,Sn,Tin,minor,137 +50,Sn,Tin,minor,89 +51,Sb,Antimony,major,30491 +51,Sb,Antimony,major,4380 +51,Sb,Antimony,major,4132 +51,Sb,Antimony,major,537 +51,Sb,Antimony,major,528 +51,Sb,Antimony,major,31 +51,Sb,Antimony,minor,4698 +51,Sb,Antimony,minor,944 +51,Sb,Antimony,minor,812 +51,Sb,Antimony,minor,766 +51,Sb,Antimony,minor,152 +51,Sb,Antimony,minor,98 +52,Te,Tellurium,major,31814 +52,Te,Tellurium,major,4341 +52,Te,Tellurium,major,583 +52,Te,Tellurium,major,572 +52,Te,Tellurium,major,40 +52,Te,Tellurium,minor,4939 +52,Te,Tellurium,minor,4612 +52,Te,Tellurium,minor,1006 +52,Te,Tellurium,minor,870 +52,Te,Tellurium,minor,819 +52,Te,Tellurium,minor,168 +52,Te,Tellurium,minor,110 +53,I,Iodine,major,33169 +53,I,Iodine,major,4557 +53,I,Iodine,major,631 +53,I,Iodine,major,619 +53,I,Iodine,major,50 +53,I,Iodine,minor,5188 +53,I,Iodine,minor,4852 +53,I,Iodine,minor,1072 +53,I,Iodine,minor,931 +53,I,Iodine,minor,875 +53,I,Iodine,minor,186 +53,I,Iodine,minor,123 +54,Xe,Xenon,major,34561 +54,Xe,Xenon,major,4782 +54,Xe,Xenon,major,684 +54,Xe,Xenon,major,672 +54,Xe,Xenon,major,63 +54,Xe,Xenon,minor,5453 +54,Xe,Xenon,minor,5104 +54,Xe,Xenon,minor,1143 +54,Xe,Xenon,minor,999 +54,Xe,Xenon,minor,937 +54,Xe,Xenon,minor,208 +54,Xe,Xenon,minor,147 +55,Cs,Cesium,major,5012 +55,Cs,Cesium,major,740 +55,Cs,Cesium,major,726 +55,Cs,Cesium,major,79 +55,Cs,Cesium,major,77 +55,Cs,Cesium,major,13 +55,Cs,Cesium,major,11 +55,Cs,Cesium,minor,5715 +55,Cs,Cesium,minor,5359 +55,Cs,Cesium,minor,1217 +55,Cs,Cesium,minor,1065 +55,Cs,Cesium,minor,998 +55,Cs,Cesium,minor,231 +55,Cs,Cesium,minor,172 +55,Cs,Cesium,minor,162 +55,Cs,Cesium,minor,23 +56,Ba,Barium,major,5247 +56,Ba,Barium,major,796 +56,Ba,Barium,major,781 +56,Ba,Barium,major,93 +56,Ba,Barium,major,90 +56,Ba,Barium,major,17 +56,Ba,Barium,major,15 +56,Ba,Barium,minor,5989 +56,Ba,Barium,minor,5624 +56,Ba,Barium,minor,1293 +56,Ba,Barium,minor,1137 +56,Ba,Barium,minor,1062 +56,Ba,Barium,minor,253 +56,Ba,Barium,minor,180 +56,Ba,Barium,minor,180 +56,Ba,Barium,minor,39 +57,La,Lanthanum,major,849 +57,La,Lanthanum,major,832 +57,La,Lanthanum,major,99 +57,La,Lanthanum,major,14 +57,La,Lanthanum,minor,6266 +57,La,Lanthanum,minor,5891 +57,La,Lanthanum,minor,1361 +57,La,Lanthanum,minor,1204 +57,La,Lanthanum,minor,1123 +57,La,Lanthanum,minor,270 +57,La,Lanthanum,minor,206 +57,La,Lanthanum,minor,191 +57,La,Lanthanum,minor,32 +58,Ce,Cerium,major,5723 +58,Ce,Cerium,major,901 +58,Ce,Cerium,major,883 +58,Ce,Cerium,major,110 +58,Ce,Cerium,major,20 +58,Ce,Cerium,minor,6549 +58,Ce,Cerium,minor,6164 +58,Ce,Cerium,minor,1435 +58,Ce,Cerium,minor,1273 +58,Ce,Cerium,minor,1185 +58,Ce,Cerium,minor,290 +58,Ce,Cerium,minor,233 +58,Ce,Cerium,minor,207 +58,Ce,Cerium,minor,38 +59,Pr,Praseodymium,major,5964 +59,Pr,Praseodymium,major,951 +59,Pr,Praseodymium,major,931 +59,Pr,Praseodymium,major,113 +59,Pr,Praseodymium,major,22 +59,Pr,Praseodymium,minor,6835 +59,Pr,Praseodymium,minor,6440 +59,Pr,Praseodymium,minor,1511 +59,Pr,Praseodymium,minor,1337 +59,Pr,Praseodymium,minor,1242 +59,Pr,Praseodymium,minor,305 +59,Pr,Praseodymium,minor,236 +59,Pr,Praseodymium,minor,218 +59,Pr,Praseodymium,minor,37 +60,Nd,Neodymium,major,5964 +60,Nd,Neodymium,major,1000 +60,Nd,Neodymium,major,978 +60,Nd,Neodymium,major,118 +60,Nd,Neodymium,major,21 +60,Nd,Neodymium,minor,6835 +60,Nd,Neodymium,minor,5964 +60,Nd,Neodymium,minor,1575 +60,Nd,Neodymium,minor,1403 +60,Nd,Neodymium,minor,1297 +60,Nd,Neodymium,minor,315 +60,Nd,Neodymium,minor,225 +60,Nd,Neodymium,minor,225 +60,Nd,Neodymium,minor,38 +61,Pm,Promethium,major,6459 +61,Pm,Promethium,major,1052 +61,Pm,Promethium,major,1027 +61,Pm,Promethium,major,120 +61,Pm,Promethium,major,121 +61,Pm,Promethium,major,24 +61,Pm,Promethium,minor,7428 +61,Pm,Promethium,minor,7013 +61,Pm,Promethium,minor,1646 +61,Pm,Promethium,minor,1471 +61,Pm,Promethium,minor,1357 +61,Pm,Promethium,minor,330 +61,Pm,Promethium,minor,242 +62,Sm,Samarium,major,6716 +62,Sm,Samarium,major,1106 +62,Sm,Samarium,major,1080 +62,Sm,Samarium,major,129 +62,Sm,Samarium,major,21 +62,Sm,Samarium,minor,7737 +62,Sm,Samarium,minor,7312 +62,Sm,Samarium,minor,1723 +62,Sm,Samarium,minor,1541 +62,Sm,Samarium,minor,1420 +62,Sm,Samarium,minor,346 +62,Sm,Samarium,minor,266 +62,Sm,Samarium,minor,247 +62,Sm,Samarium,minor,37 +63,Eu,Europium,major,6977 +63,Eu,Europium,major,1161 +63,Eu,Europium,major,1131 +63,Eu,Europium,major,133 +63,Eu,Europium,major,22 +63,Eu,Europium,minor,8052 +63,Eu,Europium,minor,7617 +63,Eu,Europium,minor,1800 +63,Eu,Europium,minor,1614 +63,Eu,Europium,minor,1481 +63,Eu,Europium,minor,360 +63,Eu,Europium,minor,284 +63,Eu,Europium,minor,257 +63,Eu,Europium,minor,32 +64,Gd,Gadolinium,major,7243 +64,Gd,Gadolinium,major,1217 +64,Gd,Gadolinium,major,1185 +64,Gd,Gadolinium,major,141 +64,Gd,Gadolinium,major,20 +64,Gd,Gadolinium,minor,8376 +64,Gd,Gadolinium,minor,7930 +64,Gd,Gadolinium,minor,1881 +64,Gd,Gadolinium,minor,1688 +64,Gd,Gadolinium,minor,1544 +64,Gd,Gadolinium,minor,376 +64,Gd,Gadolinium,minor,289 +64,Gd,Gadolinium,minor,271 +64,Gd,Gadolinium,minor,36 +65,Tb,Terbium,major,7514 +65,Tb,Terbium,major,1275 +65,Tb,Terbium,major,1241 +65,Tb,Terbium,major,147 +65,Tb,Terbium,major,25 +65,Tb,Terbium,minor,8708 +65,Tb,Terbium,minor,8252 +65,Tb,Terbium,minor,1968 +65,Tb,Terbium,minor,1768 +65,Tb,Terbium,minor,1611 +65,Tb,Terbium,minor,398 +65,Tb,Terbium,minor,310 +65,Tb,Terbium,minor,285 +65,Tb,Terbium,minor,39 +66,Dy,Dysprosium,major,1333 +66,Dy,Dysprosium,major,1295 +66,Dy,Dysprosium,major,154 +66,Dy,Dysprosium,major,26 +66,Dy,Dysprosium,major,7790 +66,Dy,Dysprosium,minor,9046 +66,Dy,Dysprosium,minor,8581 +66,Dy,Dysprosium,minor,2047 +66,Dy,Dysprosium,minor,1842 +66,Dy,Dysprosium,minor,1676 +66,Dy,Dysprosium,minor,416 +66,Dy,Dysprosium,minor,332 +66,Dy,Dysprosium,minor,293 +66,Dy,Dysprosium,minor,63 +67,Ho,Holmium,major,8071 +67,Ho,Holmium,major,1392 +67,Ho,Holmium,major,1351 +67,Ho,Holmium,major,161 +67,Ho,Holmium,major,20 +67,Ho,Holmium,minor,9394 +67,Ho,Holmium,minor,8918 +67,Ho,Holmium,minor,2128 +67,Ho,Holmium,minor,1923 +67,Ho,Holmium,minor,1741 +67,Ho,Holmium,minor,436 +67,Ho,Holmium,minor,344 +67,Ho,Holmium,minor,307 +67,Ho,Holmium,minor,51 +68,Er,Erbium,major,8358 +68,Er,Erbium,major,1453 +68,Er,Erbium,major,1409 +68,Er,Erbium,major,177 +68,Er,Erbium,major,168 +68,Er,Erbium,major,29 +68,Er,Erbium,minor,9751 +68,Er,Erbium,minor,9264 +68,Er,Erbium,minor,2207 +68,Er,Erbium,minor,2006 +68,Er,Erbium,minor,1812 +68,Er,Erbium,minor,449 +68,Er,Erbium,minor,366 +68,Er,Erbium,minor,320 +68,Er,Erbium,minor,60 +69,Tm,Thulium,major,8648 +69,Tm,Thulium,major,1515 +69,Tm,Thulium,major,1468 +69,Tm,Thulium,major,180 +69,Tm,Thulium,major,32 +69,Tm,Thulium,minor,10116 +69,Tm,Thulium,minor,9617 +69,Tm,Thulium,minor,2307 +69,Tm,Thulium,minor,2090 +69,Tm,Thulium,minor,1885 +69,Tm,Thulium,minor,472 +69,Tm,Thulium,minor,386 +69,Tm,Thulium,minor,337 +69,Tm,Thulium,minor,53 +70,Yb,Ytterbium,major,8944 +70,Yb,Ytterbium,major,1576 +70,Yb,Ytterbium,major,1528 +70,Yb,Ytterbium,major,198 +70,Yb,Ytterbium,major,185 +70,Yb,Ytterbium,major,23 +70,Yb,Ytterbium,minor,10486 +70,Yb,Ytterbium,minor,9978 +70,Yb,Ytterbium,minor,2398 +70,Yb,Ytterbium,minor,2173 +70,Yb,Ytterbium,minor,1950 +70,Yb,Ytterbium,minor,487 +70,Yb,Ytterbium,minor,397 +70,Yb,Ytterbium,minor,344 +70,Yb,Ytterbium,minor,54 +71,Lu,Lutetium,major,9244 +71,Lu,Lutetium,major,1639 +71,Lu,Lutetium,major,1589 +71,Lu,Lutetium,major,195 +71,Lu,Lutetium,major,195 +71,Lu,Lutetium,major,28 +71,Lu,Lutetium,minor,10870 +71,Lu,Lutetium,minor,10349 +71,Lu,Lutetium,minor,2491 +71,Lu,Lutetium,minor,2264 +71,Lu,Lutetium,minor,2024 +71,Lu,Lutetium,minor,506 +71,Lu,Lutetium,minor,410 +71,Lu,Lutetium,minor,359 +71,Lu,Lutetium,minor,57 +72,Hf,Hafnium,major,9561 +72,Hf,Hafnium,major,1716 +72,Hf,Hafnium,major,1662 +72,Hf,Hafnium,major,38 +72,Hf,Hafnium,major,31 +72,Hf,Hafnium,minor,11271 +72,Hf,Hafnium,minor,10739 +72,Hf,Hafnium,minor,2601 +72,Hf,Hafnium,minor,2365 +72,Hf,Hafnium,minor,2108 +72,Hf,Hafnium,minor,538 +72,Hf,Hafnium,minor,437 +72,Hf,Hafnium,minor,380 +72,Hf,Hafnium,minor,224 +72,Hf,Hafnium,minor,214 +72,Hf,Hafnium,minor,65 +73,Ta,Tantalum,major,9881 +73,Ta,Tantalum,major,1793 +73,Ta,Tantalum,major,1735 +73,Ta,Tantalum,major,45 +73,Ta,Tantalum,major,36 +73,Ta,Tantalum,minor,11682 +73,Ta,Tantalum,minor,11136 +73,Ta,Tantalum,minor,2708 +73,Ta,Tantalum,minor,2469 +73,Ta,Tantalum,minor,2194 +73,Ta,Tantalum,minor,566 +73,Ta,Tantalum,minor,465 +73,Ta,Tantalum,minor,405 +73,Ta,Tantalum,minor,241 +73,Ta,Tantalum,minor,229 +73,Ta,Tantalum,minor,71 +74,W,Tungsten,major,10207 +74,W,Tungsten,major,1872 +74,W,Tungsten,major,1809 +74,W,Tungsten,major,47 +74,W,Tungsten,major,36 +74,W,Tungsten,minor,12100 +74,W,Tungsten,minor,11544 +74,W,Tungsten,minor,2820 +74,W,Tungsten,minor,2575 +74,W,Tungsten,minor,2281 +74,W,Tungsten,minor,595 +74,W,Tungsten,minor,492 +74,W,Tungsten,minor,425 +74,W,Tungsten,minor,259 +74,W,Tungsten,minor,245 +74,W,Tungsten,minor,37 +74,W,Tungsten,minor,34 +74,W,Tungsten,minor,77 +75,Re,Rhenium,major,10535 +75,Re,Rhenium,major,1949 +75,Re,Rhenium,major,1883 +75,Re,Rhenium,major,46 +75,Re,Rhenium,major,35 +75,Re,Rhenium,minor,12527 +75,Re,Rhenium,minor,11959 +75,Re,Rhenium,minor,2932 +75,Re,Rhenium,minor,2682 +75,Re,Rhenium,minor,2367 +75,Re,Rhenium,minor,625 +75,Re,Rhenium,minor,518 +75,Re,Rhenium,minor,444 +75,Re,Rhenium,minor,274 +75,Re,Rhenium,minor,260 +75,Re,Rhenium,minor,41 +75,Re,Rhenium,minor,83 +76,Os,Osmium,major,10871 +76,Os,Osmium,major,2031 +76,Os,Osmium,major,1960 +76,Os,Osmium,major,58 +76,Os,Osmium,major,45 +76,Os,Osmium,minor,12968 +76,Os,Osmium,minor,12385 +76,Os,Osmium,minor,3049 +76,Os,Osmium,minor,2792 +76,Os,Osmium,minor,2457 +76,Os,Osmium,minor,654 +76,Os,Osmium,minor,547 +76,Os,Osmium,minor,468 +76,Os,Osmium,minor,289 +76,Os,Osmium,minor,273 +76,Os,Osmium,minor,46 +76,Os,Osmium,minor,84 +77,Ir,Iridium,major,11215 +77,Ir,Iridium,major,2116 +77,Ir,Iridium,major,2040 +77,Ir,Iridium,major,63 +77,Ir,Iridium,major,51 +77,Ir,Iridium,minor,13419 +77,Ir,Iridium,minor,12824 +77,Ir,Iridium,minor,3174 +77,Ir,Iridium,minor,2909 +77,Ir,Iridium,minor,2551 +77,Ir,Iridium,minor,690 +77,Ir,Iridium,minor,577 +77,Ir,Iridium,minor,494 +77,Ir,Iridium,minor,311 +77,Ir,Iridium,minor,295 +77,Ir,Iridium,minor,63 +77,Ir,Iridium,minor,61 +77,Ir,Iridium,minor,95 +78,Pt,Platinum,major,11564 +78,Pt,Platinum,major,2202 +78,Pt,Platinum,major,2122 +78,Pt,Platinum,minor,13880 +78,Pt,Platinum,minor,13273 +78,Pt,Platinum,minor,3296 +78,Pt,Platinum,minor,3027 +78,Pt,Platinum,minor,2645 +78,Pt,Platinum,minor,722 +78,Pt,Platinum,minor,609 +78,Pt,Platinum,minor,519 +78,Pt,Platinum,minor,331 +78,Pt,Platinum,minor,313 +78,Pt,Platinum,minor,74 +78,Pt,Platinum,minor,71 +78,Pt,Platinum,minor,102 +78,Pt,Platinum,minor,65 +78,Pt,Platinum,minor,52 +79,Au,Gold,major,11919 +79,Au,Gold,major,2291 +79,Au,Gold,major,2206 +79,Au,Gold,minor,14353 +79,Au,Gold,minor,13734 +79,Au,Gold,minor,3425 +79,Au,Gold,minor,3148 +79,Au,Gold,minor,2743 +79,Au,Gold,minor,759 +79,Au,Gold,minor,644 +79,Au,Gold,minor,545 +79,Au,Gold,minor,352 +79,Au,Gold,minor,334 +79,Au,Gold,minor,86 +79,Au,Gold,minor,83 +79,Au,Gold,minor,108 +79,Au,Gold,minor,72 +79,Au,Gold,minor,54 +80,Hg,Mercury,major,12284 +80,Hg,Mercury,major,2385 +80,Hg,Mercury,major,2295 +80,Hg,Mercury,minor,14839 +80,Hg,Mercury,minor,14209 +80,Hg,Mercury,minor,3562 +80,Hg,Mercury,minor,3279 +80,Hg,Mercury,minor,2847 +80,Hg,Mercury,minor,800 +80,Hg,Mercury,minor,677 +80,Hg,Mercury,minor,571 +80,Hg,Mercury,minor,378 +80,Hg,Mercury,minor,360 +80,Hg,Mercury,minor,102 +80,Hg,Mercury,minor,99 +80,Hg,Mercury,minor,120 +80,Hg,Mercury,minor,81 +80,Hg,Mercury,minor,58 +81,Tl,Thallium,major,2485 +81,Tl,Thallium,major,2389 +81,Tl,Thallium,major,15 +81,Tl,Thallium,major,13 +81,Tl,Thallium,minor,15347 +81,Tl,Thallium,minor,14698 +81,Tl,Thallium,minor,12658 +81,Tl,Thallium,minor,3704 +81,Tl,Thallium,minor,3416 +81,Tl,Thallium,minor,2957 +81,Tl,Thallium,minor,846 +81,Tl,Thallium,minor,721 +81,Tl,Thallium,minor,609 +81,Tl,Thallium,minor,407 +81,Tl,Thallium,minor,386 +81,Tl,Thallium,minor,123 +81,Tl,Thallium,minor,119 +81,Tl,Thallium,minor,136 +81,Tl,Thallium,minor,100 +81,Tl,Thallium,minor,75 +82,Pb,Lead,major,13035 +82,Pb,Lead,major,2586 +82,Pb,Lead,major,2484 +82,Pb,Lead,major,22 +82,Pb,Lead,major,19 +82,Pb,Lead,minor,15861 +82,Pb,Lead,minor,15200 +82,Pb,Lead,minor,3851 +82,Pb,Lead,minor,3554 +82,Pb,Lead,minor,3066 +82,Pb,Lead,minor,894 +82,Pb,Lead,minor,764 +82,Pb,Lead,minor,645 +82,Pb,Lead,minor,435 +82,Pb,Lead,minor,413 +82,Pb,Lead,minor,143 +82,Pb,Lead,minor,138 +82,Pb,Lead,minor,147 +82,Pb,Lead,minor,105 +82,Pb,Lead,minor,86 +83,Bi,Bismuth,major,13419 +83,Bi,Bismuth,major,2688 +83,Bi,Bismuth,major,2580 +83,Bi,Bismuth,major,27 +83,Bi,Bismuth,major,24 +83,Bi,Bismuth,minor,16388 +83,Bi,Bismuth,minor,15711 +83,Bi,Bismuth,minor,3999 +83,Bi,Bismuth,minor,3696 +83,Bi,Bismuth,minor,3177 +83,Bi,Bismuth,minor,938 +83,Bi,Bismuth,minor,805 +83,Bi,Bismuth,minor,679 +83,Bi,Bismuth,minor,464 +83,Bi,Bismuth,minor,440 +83,Bi,Bismuth,minor,162 +83,Bi,Bismuth,minor,157 +83,Bi,Bismuth,minor,159 +83,Bi,Bismuth,minor,117 +83,Bi,Bismuth,minor,93 +84,Po,Polonium,major,13814 +84,Po,Polonium,major,2798 +84,Po,Polonium,major,2683 +84,Po,Polonium,major,31 +84,Po,Polonium,minor,16939 +84,Po,Polonium,minor,16244 +84,Po,Polonium,minor,4149 +84,Po,Polonium,minor,3854 +84,Po,Polonium,minor,3302 +84,Po,Polonium,minor,995 +84,Po,Polonium,minor,851 +84,Po,Polonium,minor,705 +84,Po,Polonium,minor,500 +84,Po,Polonium,minor,473 +85,At,Astatine,major,14214 +85,At,Astatine,major,2908 +85,At,Astatine,major,2787 +85,At,Astatine,minor,17493 +85,At,Astatine,minor,16785 +85,At,Astatine,minor,4317 +85,At,Astatine,minor,4008 +85,At,Astatine,minor,3426 +85,At,Astatine,minor,1042 +85,At,Astatine,minor,886 +85,At,Astatine,minor,740 +85,At,Astatine,minor,533 +86,Rn,Radon,major,14619 +86,Rn,Radon,major,3022 +86,Rn,Radon,major,2892 +86,Rn,Radon,minor,18049 +86,Rn,Radon,minor,17337 +86,Rn,Radon,minor,4482 +86,Rn,Radon,minor,4159 +86,Rn,Radon,minor,3538 +86,Rn,Radon,minor,1097 +86,Rn,Radon,minor,929 +86,Rn,Radon,minor,768 +86,Rn,Radon,minor,567 +87,Fr,Francium,major,15031 +87,Fr,Francium,major,3136 +87,Fr,Francium,major,3000 +87,Fr,Francium,minor,18639 +87,Fr,Francium,minor,17907 +87,Fr,Francium,minor,4652 +87,Fr,Francium,minor,4327 +87,Fr,Francium,minor,3663 +87,Fr,Francium,minor,1153 +87,Fr,Francium,minor,980 +87,Fr,Francium,minor,810 +87,Fr,Francium,minor,603 +87,Fr,Francium,minor,577 +88,Ra,Radium,major,15444 +88,Ra,Radium,major,3248 +88,Ra,Radium,major,3105 +88,Ra,Radium,major,299 +88,Ra,Radium,major,67 +88,Ra,Radium,minor,19237 +88,Ra,Radium,minor,18484 +88,Ra,Radium,minor,4822 +88,Ra,Radium,minor,4490 +88,Ra,Radium,minor,3792 +88,Ra,Radium,minor,1208 +88,Ra,Radium,minor,1058 +88,Ra,Radium,minor,879 +88,Ra,Radium,minor,636 +88,Ra,Radium,minor,603 +88,Ra,Radium,minor,254 +88,Ra,Radium,minor,153 +89,Ac,Actinium,major,15871 +89,Ac,Actinium,major,3370 +89,Ac,Actinium,major,3219 +89,Ac,Actinium,minor,19840 +89,Ac,Actinium,minor,19083 +89,Ac,Actinium,minor,5002 +89,Ac,Actinium,minor,4656 +89,Ac,Actinium,minor,3909 +89,Ac,Actinium,minor,1269 +89,Ac,Actinium,minor,1080 +89,Ac,Actinium,minor,890 +89,Ac,Actinium,minor,675 +90,Th,Thorium,major,16300 +90,Th,Thorium,major,3491 +90,Th,Thorium,major,3332 +90,Th,Thorium,major,344 +90,Th,Thorium,major,335 +90,Th,Thorium,major,94 +90,Th,Thorium,major,88 +90,Th,Thorium,minor,20472 +90,Th,Thorium,minor,19693 +90,Th,Thorium,minor,5182 +90,Th,Thorium,minor,4830 +90,Th,Thorium,minor,4046 +90,Th,Thorium,minor,1330 +90,Th,Thorium,minor,1168 +90,Th,Thorium,minor,967 +90,Th,Thorium,minor,714 +90,Th,Thorium,minor,676 +90,Th,Thorium,minor,290 +90,Th,Thorium,minor,229 +90,Th,Thorium,minor,182 +91,Pa,Protactinium,major,16733 +91,Pa,Protactinium,major,3611 +91,Pa,Protactinium,major,3442 +91,Pa,Protactinium,major,371 +91,Pa,Protactinium,major,360 +91,Pa,Protactinium,major,94 +91,Pa,Protactinium,minor,21105 +91,Pa,Protactinium,minor,20314 +91,Pa,Protactinium,minor,5367 +91,Pa,Protactinium,minor,5001 +91,Pa,Protactinium,minor,4174 +91,Pa,Protactinium,minor,1387 +91,Pa,Protactinium,minor,1224 +91,Pa,Protactinium,minor,1007 +91,Pa,Protactinium,minor,743 +91,Pa,Protactinium,minor,708 +91,Pa,Protactinium,minor,310 +91,Pa,Protactinium,minor,223 +92,U,Uranium,major,17166 +92,U,Uranium,major,3728 +92,U,Uranium,major,3552 +92,U,Uranium,major,391 +92,U,Uranium,major,381 +92,U,Uranium,major,105 +92,U,Uranium,major,96 +92,U,Uranium,minor,21757 +92,U,Uranium,minor,20948 +92,U,Uranium,minor,5548 +92,U,Uranium,minor,5182 +92,U,Uranium,minor,4303 +92,U,Uranium,minor,1441 +92,U,Uranium,minor,1273 +92,U,Uranium,minor,1045 +92,U,Uranium,minor,780 +92,U,Uranium,minor,738 +92,U,Uranium,minor,324 +92,U,Uranium,minor,259 +92,U,Uranium,minor,195 +93,Np,Neptunium,major,17610 +93,Np,Neptunium,major,3850 +93,Np,Neptunium,major,3666 +93,Np,Neptunium,major,415 +93,Np,Neptunium,major,404 +93,Np,Neptunium,major,109 +93,Np,Neptunium,major,101 +93,Np,Neptunium,minor,22427 +93,Np,Neptunium,minor,21601 +93,Np,Neptunium,minor,5723 +93,Np,Neptunium,minor,5366 +93,Np,Neptunium,minor,4435 +93,Np,Neptunium,minor,1501 +93,Np,Neptunium,minor,1328 +93,Np,Neptunium,minor,1087 +93,Np,Neptunium,minor,816 +93,Np,Neptunium,minor,770 +93,Np,Neptunium,minor,283 +93,Np,Neptunium,minor,206 +94,Pu,Plutonium,major,18057 +94,Pu,Plutonium,major,3973 +94,Pu,Plutonium,major,3778 +94,Pu,Plutonium,major,446 +94,Pu,Plutonium,major,432 +94,Pu,Plutonium,major,116 +94,Pu,Plutonium,major,105 +94,Pu,Plutonium,minor,23097 +94,Pu,Plutonium,minor,22266 +94,Pu,Plutonium,minor,5933 +94,Pu,Plutonium,minor,5541 +94,Pu,Plutonium,minor,4557 +94,Pu,Plutonium,minor,1559 +94,Pu,Plutonium,minor,1372 +94,Pu,Plutonium,minor,1115 +94,Pu,Plutonium,minor,849 +94,Pu,Plutonium,minor,801 +94,Pu,Plutonium,minor,352 +94,Pu,Plutonium,minor,274 +94,Pu,Plutonium,minor,207 +95,Am,Americium,major,18504 +95,Am,Americium,major,4092 +95,Am,Americium,major,3887 +95,Am,Americium,major,116 +95,Am,Americium,major,103 +95,Am,Americium,minor,23773 +95,Am,Americium,minor,22944 +95,Am,Americium,minor,6121 +95,Am,Americium,minor,5710 +95,Am,Americium,minor,4667 +95,Am,Americium,minor,1617 +95,Am,Americium,minor,1412 +95,Am,Americium,minor,1136 +95,Am,Americium,minor,879 +95,Am,Americium,minor,828 +96,Cm,Curium,, +97,Bk,Berkelium,, +98,Cf,Californium,, +99,Es,Einsteinium,, +100,Fm,Fermium,, +101,Md,Mendelevium,, +102,No,Nobelium,, +103,Lr,Lawrencium,, +104,Rf,Rutherfordium,, +105,Db,Dubnium,, +106,Sg,Seaborgium,, +107,Bh,Bohrium,, +108,Hs,Hassium,, +109,Mt,Meitnerium,, +110,Ds,Darmstadtium,, +111,Rg,Roentgenium,, +112,Cn,Copernicium,, +113,Uut,Ununtrium,, +114,Fl,Flerovium,, +115,Uup,Ununpentium,, +116,Lv,Livermorium,, +117,Uus,Ununseptium,, +118,Uuo,Ununoctium,, \ No newline at end of file diff --git a/src/quantem/spectroscopy/utils.py b/src/quantem/spectroscopy/utils.py index f1742176..3ca1712f 100644 --- a/src/quantem/spectroscopy/utils.py +++ b/src/quantem/spectroscopy/utils.py @@ -55,3 +55,56 @@ def load_xray_lines_database(path: Union[Path, str]) -> dict[str, dict[str, dict } return elements + + +def load_eels_edges_database(path: Union[Path, str]) -> dict[str, dict[str, dict[str, object]]]: + """Load EELS edge CSV into the legacy element->edge metadata mapping.""" + elements: dict[str, dict[str, dict[str, object]]] = {} + duplicate_counts: dict[tuple[str, str], int] = {} + + with open(path, "r", encoding="utf-8", newline="") as f: + reader = csv.DictReader(f) + fieldnames = set(reader.fieldnames or []) + required_columns = ("symbol", "edge_label", "edge_energy_eV") + missing_columns = [column for column in required_columns if column not in fieldnames] + if missing_columns: + raise ValueError( + f"{path} is missing required EELS edge columns: {', '.join(missing_columns)}" + ) + + for row in reader: + element_symbol = str(row.get("symbol", "")).strip() + if not element_symbol: + continue + + energy_ev = _parse_float(row, ("edge_energy_eV", "onset_energy (eV)", "energy_eV")) + if energy_ev is None: + continue + + edge_label = str(row.get("edge_label", "")).strip() + element_edges = elements.setdefault(element_symbol, {}) + edge_name = f"{energy_ev:g} eV" + key = (element_symbol, edge_name) + if edge_name in element_edges: + duplicate_counts[key] = duplicate_counts.get(key, 1) + 1 + edge_name = f"{edge_name}__{duplicate_counts[key]}" + + edge_info: dict[str, object] = { + "onset_energy (eV)": float(energy_ev), + } + if edge_label: + edge_info["edge_label"] = edge_label + + atomic_number = _parse_float(row, ("atomic_number",)) + if atomic_number is not None: + edge_info["atomic_number"] = ( + int(atomic_number) if atomic_number.is_integer() else float(atomic_number) + ) + + element_name = str(row.get("element", "")).strip() + if element_name: + edge_info["element"] = element_name + + element_edges[edge_name] = edge_info + + return elements From d8b06838ebdd352a803d788e5d8e1c19d0e87174 Mon Sep 17 00:00:00 2001 From: smribet Date: Wed, 6 May 2026 10:52:38 -0700 Subject: [PATCH 127/136] updating eels edges pt 2 --- src/quantem/spectroscopy/dataset3dspectroscopy.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index c5c5bcd7..5f2e6d20 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -161,6 +161,15 @@ def _line_matches_selectors(line_name, selectors): line_norm = str(line_name).strip().lower() return any(line_norm == sel or line_norm.startswith(sel) for sel in selectors) + @staticmethod + def _line_info_matches_selectors(line_info, selectors): + if not selectors or not isinstance(line_info, dict): + return False + edge_label = str(line_info.get("edge_label", "")).strip().lower() + return bool(edge_label) and any( + edge_label == sel or edge_label.startswith(sel) for sel in selectors + ) + @classmethod def _select_lines(cls, line_dict, selectors): if not isinstance(line_dict, dict): @@ -173,6 +182,7 @@ def _select_lines(cls, line_dict, selectors): line_name: line_info for line_name, line_info in line_dict.items() if cls._line_matches_selectors(line_name, selector_norm) + or cls._line_info_matches_selectors(line_info, selector_norm) } def add_elements_to_model(self, elements): @@ -281,7 +291,10 @@ def remove_elements_from_model(self, elements): self.model_elements[element_key] = { line_name: line_info for line_name, line_info in lines_info.items() - if not type(self)._line_matches_selectors(line_name, selectors) + if not ( + type(self)._line_matches_selectors(line_name, selectors) + or type(self)._line_info_matches_selectors(line_info, selectors) + ) } if not self.model_elements[element_key]: self.model_elements.pop(element_key, None) From 4553f401cc49b52416c9b8c79801fe23038c05be Mon Sep 17 00:00:00 2001 From: nikovlahakis Date: Thu, 7 May 2026 15:21:46 -0700 Subject: [PATCH 128/136] fixes to plotting/cleanup for zlp alignment --- src/quantem/spectroscopy/dataset3deels.py | 102 ++++++++++++++-------- 1 file changed, 67 insertions(+), 35 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index 4a873c82..ab00f635 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -238,7 +238,7 @@ def smooth_eels_rollingaverage( return smoothed_data3d def measure_zlp_offset( - self, zlp_guess_x=None, fit_window=0.8, use_gaussian_fit=True, fit_to_plane=True + self, zlp_guess_x=None, fit_window=0.8, use_gaussian_fit=True, fit_to_plane=False ): """ Measure ZLP offset at each pixel position by using a guess of ZLP posfitting each spectrum to a Gaussian @@ -305,26 +305,30 @@ def _plane_fit_2d(M, a, b, c): zlp_measured[iy - 1, ix - 1] = float(popt[1]) - # Fit a 2D plane to the array of measured ZLPs - xdata, ydata = np.meshgrid(np.arange(n_x), np.arange(n_y)) - - xdata_unpacked = np.vstack((xdata.ravel(), ydata.ravel())) - ydata_unpacked = zlp_measured.ravel() + if fit_to_plane: + # Fit a 2D plane to the array of measured ZLPs + xdata, ydata = np.meshgrid(np.arange(n_x), np.arange(n_y)) - popt, _ = curve_fit(_plane_fit_2d, xdata_unpacked, ydata_unpacked) + xdata_unpacked = np.vstack((xdata.ravel(), ydata.ravel())) + ydata_unpacked = zlp_measured.ravel() - zlp_plane_1d = _plane_fit_2d(xdata_unpacked, popt[0], popt[1], popt[2]) - zlp_plane_2d = zlp_plane_1d.reshape(n_y, n_x) + popt, _ = curve_fit(_plane_fit_2d, xdata_unpacked, ydata_unpacked) - show_2d( - [zlp_measured, zlp_plane_2d], - cmap="magma", - title=["Measured ZLP (mean of Gaussian fit)", "ZLP plane fit"], - ) + zlp_plane_1d = _plane_fit_2d(xdata_unpacked, popt[0], popt[1], popt[2]) + zlp_plane_2d = zlp_plane_1d.reshape(n_y, n_x) - if fit_to_plane: + show_2d( + [zlp_measured, zlp_plane_2d], + cmap="magma", + title=["Measured ZLP (mean of Gaussian fit)", "ZLP plane fit"], + ) return zlp_plane_2d else: + show_2d( + [zlp_measured], + cmap="magma", + title=["Measured ZLP (mean of Gaussian fit)"], + ) return zlp_measured def apply_zlp_correction( @@ -355,6 +359,7 @@ def apply_zlp_correction( "measure_offset was set to False and no input argument for ZLP shifts was provided." ) + # Initialize 3D array to populate with spectra aligned along the energy axis corrected_array = np.zeros_like(self.array) n_energy, n_y, n_x = self.array.shape @@ -363,37 +368,64 @@ def apply_zlp_correction( E0 = float(self.origin[0]) energy_axis = E0 + np.arange(n_energy) * dE + # Record the maximum positive shift to update the origin for the output aligned 3D dataset + max_shift = 0 + + # Apply the ZLP shift at each probe position, rounding to the nearest integer multiple of the sampling of the data for iy in range(n_y): for ix in range(n_x): - E_shift = energy_axis - zlp_array[iy, ix] - interpolator = interp1d( - E_shift, - self.array[:, iy, ix], - kind="linear", - bounds_error=False, - fill_value=0.0, - ) - corrected_array[:, iy, ix] = interpolator(energy_axis) + spec = self.array[:, iy, ix] + shift_binsampled = np.floor_divide(zlp_array[iy, ix], dE) * (-1) + shift_int = shift_binsampled.astype(np.int64) + if shift_binsampled < 0: + spec_shifted = np.pad( + spec[-shift_binsampled:], + (0, -shift_int), + mode="constant", + constant_values=np.nan, + ) + else: + spec_shifted = np.pad( + spec, (shift_int, 0), mode="constant", constant_values=np.nan + )[: len(spec)] + # Update maximum shift for origin correction + if shift_binsampled > max_shift: + max_shift = shift_binsampled + corrected_array[:, iy, ix] = spec_shifted - mean_spectrum_raw = self.array.mean(axis=(1, 2)) - mean_spectrum_corrected = corrected_array.mean(axis=(1, 2)) + # Update origin + new_origin = E0 + max_shift * dE - fig, ax = plt.subplots() - ax.plot(energy_axis, mean_spectrum_corrected, label="ZLP-corrected spectrum", color="b") - ax.plot(energy_axis, mean_spectrum_raw, label="Raw mean spectrum", color="r") - ax.set_xlabel("Energy (eV)") - ax.set_ylabel("Intensity") - ax.grid(True, alpha=0.1) - ax.legend() + # Remove all planes along energy axis containing NaN, to equalize spectra lengths across all scan positions + mask = np.isnan(corrected_array).any(axis=(1, 2)) + aligned_data_3d = corrected_array[~mask] + + new_Eaxis = new_origin + np.arange(aligned_data_3d.shape[0]) * dE + + # Calculate mean spectra before and after correction for plotting + mean_spectrum_raw = self.array.mean(axis=(1, 2)) + mean_spectrum_corrected = aligned_data_3d.mean(axis=(1, 2)) + + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4)) + ax1.plot(energy_axis, mean_spectrum_raw, label="Raw mean spectrum", color="r") + ax2.plot(new_Eaxis, mean_spectrum_corrected, label="ZLP-corrected spectrum", color="b") + ax1.set_xlabel("Energy (eV)") + ax1.set_ylabel("Intensity") + ax1.grid(True, alpha=0.1) + ax1.legend() + ax2.set_xlabel("Energy (eV)") + ax2.set_ylabel("Intensity") + ax2.grid(True, alpha=0.1) + ax2.legend() fig.tight_layout() if return_3d_dataset: return Dataset3deels.from_array( - array=corrected_array, + array=aligned_data_3d, name=self.name, sampling=self.sampling, - origin=self.origin, + origin=new_origin, units=self.units, ) From 393d93001d0c9d947250b08c590e8334fa7b3481 Mon Sep 17 00:00:00 2001 From: smribet Date: Fri, 8 May 2026 15:16:49 -0700 Subject: [PATCH 129/136] sanya's eds changes --- src/quantem/spectroscopy/dataset3deds.py | 1270 ++++++++++++++++++---- 1 file changed, 1088 insertions(+), 182 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index a9c076e3..25fc1845 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -7,7 +7,8 @@ import torch.nn as nn from matplotlib.lines import Line2D from numpy.typing import NDArray -from scipy.signal import find_peaks +from scipy.optimize import curve_fit +from scipy.signal import find_peaks, peak_prominences, peak_widths from quantem.core.visualization import show_2d from quantem.spectroscopy import Dataset3dspectroscopy @@ -35,7 +36,6 @@ class Dataset3deds(Dataset3dspectroscopy): element_info = None element_info_path = "x_ray_lines.csv" - dataset_type = "eds" def __init__( self, @@ -356,41 +356,43 @@ def _merge_edge_filters(requested, saved): return requested or saved @staticmethod - def _estimate_snr_thresholds(snr_values, peaks, snr_min=None, snr_threshold=None): - """Auto-estimate snr_min and snr_threshold from peak SNR distribution.""" + def _estimate_snr_thresholds(snr_values, peaks, floor=None, snr_threshold=None): + """Auto-estimate SNR floors/thresholds from the peak SNR distribution.""" snr_values = np.asarray(snr_values, dtype=float) snr_values = snr_values[np.isfinite(snr_values)] - if snr_min is None: + if floor is None: if snr_values.size: - sorted_snrs = np.sort(snr_values) - target_rank = min(sorted_snrs.size, int(np.clip(2 * int(peaks), 12, 64))) - rank_cutoff = float(sorted_snrs[-target_rank]) - q30, q40, q50 = np.percentile(sorted_snrs, [30, 40, 50]) - snr_min = float( - np.clip(min(q50, max(q30, 0.35 * rank_cutoff, 0.9 * q40)), 7.0, 14.0) - ) + # Robust quantile floor: center near the middle/high-middle SNR + # band so the floor tracks "visible" peaks without being pulled + # down by noise tails or up by a few extreme peaks. + q30, q40, q50, q60 = np.percentile(snr_values, [30, 40, 50, 60]) + floor = 0.5 * float(q40 + q50) + floor = float(np.clip(floor, q30, q60)) + floor = max(0.0, floor) else: - snr_min = 8.0 + floor = 8.0 else: - snr_min = float(snr_min) + floor = float(floor) if snr_threshold is None: if snr_values.size: - high = snr_values[snr_values >= snr_min] + high = snr_values[snr_values >= floor] high = high if high.size else snr_values - high = np.sort(high)[::-1] - anchor = high[: min(high.size, int(np.clip(int(peaks), 10, 40)))] + # Keep auto-threshold independent from the requested display + # count (peaks). `peaks` should control only how many detected + # peaks are shown, not which peaks are detected. + anchor = np.sort(high)[::-1][: min(high.size, 40)] med, q75, q90 = np.percentile(anchor, [50, 75, 90]) snr_threshold = float( - np.clip(max(med, 0.7 * q75, 2.5 * snr_min), max(2.5 * snr_min, snr_min), q90) + np.clip(max(med, 0.7 * q75, 2.5 * floor), max(2.5 * floor, floor), q90) ) else: - snr_threshold = max(4.0 * snr_min, 30.0) + snr_threshold = max(4.0 * floor, 30.0) else: snr_threshold = float(snr_threshold) - return snr_min, snr_threshold + return floor, snr_threshold def x_ray_lookup( self, spec: str | list[str] | tuple[str, ...] | set[str] @@ -462,7 +464,7 @@ def x_ray_lookup( [lbl for lbl, _, _ in unique], ) - def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False): + def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False, show=True): """Generate spectrum images by integrating around X-ray line energies. For each matched X-ray line, sums the spectral intensity within an @@ -599,7 +601,7 @@ def integrate(self, spec, width=0.15, return_maps=False, show=True, **kwargs): return self.Integrate(spec=spec, width=width, return_maps=return_maps, show=show, **kwargs) def show_spectrum_images( - self, x_ray_lines=None, method="integration", return_fig=False, return_maps=False, **kwargs + self, x_ray_lines=None, return_fig=False, return_maps=False, method="integration", **kwargs ): """Display cached spectrum images. @@ -608,13 +610,11 @@ def show_spectrum_images( x_ray_lines : str | sequence[str] | None, optional Selectors to filter which images are shown. If ``None``, one panel per element is displayed. + return_fig : bool, optional + If ``True``, return ``(fig, ax)``. method : {"integration", "fit"}, optional Which cache to read from: integration-based maps or PyTorch fit-based maps. - return_fig : bool, optional - If ``True``, return ``(fig, ax)``. - return_maps: bool, optional - If ``True``, return plotted images **kwargs Forwarded to :func:`show_2d` (e.g. ``cmap``). @@ -882,12 +882,15 @@ def peak_autoid( min_line_weight=0.0, mask=None, show_text=True, + floor=None, + snr_quantile_floor=None, snr_min=None, snr_threshold=None, distance_threshold_for_sample=0.05, grid_peaks=None, peaks=15, mode=None, + line=None, return_details=False, ): """Automatically identify elements from EDS peaks in the mean spectrum. @@ -921,7 +924,7 @@ def peak_autoid( ``[0, 0.25]`` keV to skip the noise floor. threshold : float, optional Legacy parameter (currently unused). SNR filtering is controlled - by *snr_min* and *snr_threshold*. + by *floor* and *snr_threshold*. tolerance : float, optional Maximum energy difference in keV between a detected peak and a tabulated X-ray line for them to be considered a match. @@ -933,9 +936,14 @@ def peak_autoid( contribute to the mean spectrum. show_text : bool, optional If ``True``, annotate matched peaks on the plot. - snr_min : float | None, optional + floor : float | None, optional Minimum signal-to-noise ratio for a peak to be displayed. If - ``None``, estimated automatically from the SNR distribution. + ``None``, estimated from robust middle quantiles (roughly between + the 30th and 60th percentile of peak SNRs). + snr_quantile_floor : float | None, optional + Deprecated alias for *floor*. + snr_min : float | None, optional + Deprecated alias for *floor*. snr_threshold : float | None, optional SNR above which a peak match counts as "strong" evidence for an element. If ``None``, estimated automatically. @@ -952,6 +960,10 @@ def peak_autoid( *elements*; ``"elements_preferred"`` boosts them but allows others; ``"autofill"`` (default when *elements* is ``None``) searches all elements. + line : float | sequence[float] | None, optional + Energy value(s) in keV for reference lines to draw on the spectrum + plot, e.g. ``3.692`` or ``[3.692, 4.510]``. Lines are drawn as + dashed black vertical lines. return_details : bool, optional If ``True``, return a dict with detection details instead of the figure. @@ -962,7 +974,7 @@ def peak_autoid( By default returns ``(fig, (ax_img, ax_spec))``. When *return_details* is ``True``, returns a dict containing ``detected_elements``, ``element_confidence``, ``display_peaks``, - ``peak_matches``, ``snr_min``, ``snr_threshold``, and the figure. + ``peak_matches``, ``floor``, ``snr_threshold``, and the figure. """ type(self)._ensure_element_info() all_info = type(self).element_info or {} @@ -984,7 +996,7 @@ def peak_autoid( requested_elements = set(edge_filters) if edge_filters else None mode = (str(mode).strip().lower() if mode is not None else None) or ( - "elements_preferred" if requested_elements else "autofill" + "elements_only" if requested_elements else "autofill" ) search_elements = requested_elements if mode == "elements_only" else None preferred_elements = ( @@ -1006,56 +1018,382 @@ def peak_autoid( energy_range=energy_range, ignore_range=ignore_range, mask=mask, - attach_mean_spectrum=False, ) - spec = np.asarray(spec, dtype=float) - E = np.asarray(self.energy_axis, dtype=float) + E = float(self.origin[0]) + float(self.sampling[0]) * np.arange(self.shape[0]) + + # Keep the energy axis aligned with calculate_mean_spectrum filtering. if mask is not None: - E = E[np.asarray(mask, dtype=bool)] + mask_arr = np.asarray(mask, dtype=bool) + if mask_arr.shape != E.shape: + raise ValueError( + f"Mask shape {mask_arr.shape} does not match energy axis shape {E.shape}." + ) + E = E[mask_arr] + if energy_range is not None: keep = (energy_range[0] <= E) & (E <= energy_range[1]) E = E[keep] - if E.shape[0] != spec.shape[0]: - raise RuntimeError( - "Energy axis and mean spectrum lengths do not match after applying " - "mask and energy_range." + + if len(spec) != len(E): + raise ValueError( + "Energy axis length does not match mean spectrum length after filtering. " + f"Got len(E)={len(E)} and len(spec)={len(spec)}." ) - spec_for_peaks = np.nan_to_num(spec, nan=0.0, posinf=0.0, neginf=0.0) - if spec_for_peaks.size: - spec_for_peaks = spec_for_peaks - float(np.nanmin(spec_for_peaks)) - peak_scale = float(np.nanmax(np.abs(spec_for_peaks))) - if np.isfinite(peak_scale) and peak_scale > 0: - spec_for_peaks = spec_for_peaks / peak_scale def in_ignore(energy): return len(ignore_range) == 2 and ignore_range[0] <= float(energy) <= ignore_range[1] - peak_indices, props = find_peaks(spec_for_peaks, height=0, distance=5) - peak_signal_heights = props["peak_heights"] - peak_heights = spec[peak_indices] - background_std = np.nanstd( - spec_for_peaks[spec_for_peaks <= np.nanpercentile(spec_for_peaks, 50)] + peak_indices, props = find_peaks(spec, height=0, distance=5) + peak_heights = props["peak_heights"] + peak_proms = ( + peak_prominences(spec, peak_indices)[0] + if len(peak_indices) + else np.asarray([], dtype=float) + ) + peak_width_samples = ( + peak_widths(spec, peak_indices, rel_height=0.5)[0] + if len(peak_indices) + else np.asarray([], dtype=float) ) + background_std = np.nanstd(spec[spec <= np.nanpercentile(spec, 50)]) if not np.isfinite(background_std) or background_std <= 0: - background_std = np.nanstd(spec_for_peaks) + background_std = np.nanstd(spec) if not np.isfinite(background_std) or background_std <= 0: background_std = 1.0 - snr_values = np.asarray( - [height / background_std for height in peak_signal_heights], dtype=float + if floor is None and snr_quantile_floor is not None: + floor = snr_quantile_floor + if floor is None and snr_min is not None: + floor = snr_min + + # Collapse shoulder peaks before SNR filtering. + # Two adjacent peaks are treated as one if they are very close in energy + # and the valley between them is shallow relative to the smaller peak. + # This removes split-peak artifacts that tend to over-label broad peaks. + def collapse_shoulder_peaks(indices, heights, prominences, widths): + if len(indices) <= 1: + return ( + np.asarray(indices, dtype=int), + np.asarray(heights, dtype=float), + np.asarray(prominences, dtype=float), + np.asarray(widths, dtype=float), + ) + + energy_gap_limit = max(6.0 * float(self.sampling[0]), 0.14) + min_valley_relief = 0.35 + min_height_ratio = 0.45 + + keep = [] + i = 0 + while i < len(indices): + best_idx = int(indices[i]) + best_h = float(heights[i]) + best_p = float(prominences[i]) + best_w = float(widths[i]) + j = i + 1 + + while j < len(indices): + cand_idx = int(indices[j]) + cand_h = float(heights[j]) + cand_p = float(prominences[j]) + cand_w = float(widths[j]) + if float(E[cand_idx] - E[best_idx]) > energy_gap_limit: + break + + lo, hi = sorted((best_idx, cand_idx)) + if hi - lo <= 1: + valley = float(min(spec[lo], spec[hi])) + else: + valley = float(np.min(spec[lo : hi + 1])) + + smaller = max(min(best_h, cand_h), 1e-12) + valley_relief = (smaller - valley) / smaller + height_ratio = min(best_h, cand_h) / max(best_h, cand_h) + + # Not a clearly separated doublet -> merge shoulders. + if valley_relief < min_valley_relief or height_ratio < min_height_ratio: + if (cand_p > best_p) or (cand_p == best_p and cand_h > best_h): + best_idx, best_h, best_p, best_w = cand_idx, cand_h, cand_p, cand_w + j += 1 + continue + + break + + keep.append((best_idx, best_h, best_p, best_w)) + i = j + + out_idx = np.asarray([pk for pk, _, _, _ in keep], dtype=int) + out_h = np.asarray([h for _, h, _, _ in keep], dtype=float) + out_p = np.asarray([p for _, _, p, _ in keep], dtype=float) + out_w = np.asarray([w for _, _, _, w in keep], dtype=float) + order = np.argsort(out_idx) + return out_idx[order], out_h[order], out_p[order], out_w[order] + + peak_indices, peak_heights, peak_proms, peak_width_samples = collapse_shoulder_peaks( + peak_indices, + peak_heights, + peak_proms, + peak_width_samples, + ) + + snr_values = np.asarray([height / background_std for height in peak_heights], dtype=float) + floor, snr_threshold = type(self)._estimate_snr_thresholds( + snr_values, + peaks, + floor, + snr_threshold, ) - snr_min, snr_threshold = type(self)._estimate_snr_thresholds( - snr_values, peaks, snr_min, snr_threshold + + # Prominence filter in SNR units: suppress shoulder/noise artifacts that + # may have acceptable height but do not form a distinct peak. + prominence_snr = np.asarray( + [float(p) / max(float(background_std), 1e-12) for p in peak_proms], dtype=float ) - display_peaks = [ - (int(i), float(raw_h), float(E[i]), float(signal_h / background_std)) - for i, raw_h, signal_h in zip(peak_indices, peak_heights, peak_signal_heights) - if not in_ignore(E[i]) and signal_h / background_std >= snr_min + def _local_noise_std(pk_idx): + # Use local baseline variability so narrow doublets are not lost + # when a wide energy range inflates global noise estimates. + local_window = max(0.24, 12.0 * float(self.sampling[0])) + mask_local = np.abs(E - float(E[int(pk_idx)])) <= local_window + if int(np.count_nonzero(mask_local)) < 9: + return float(background_std) + + y_local = np.asarray(spec[mask_local], dtype=float) + if y_local.size < 9 or not np.all(np.isfinite(y_local)): + return float(background_std) + + local_cut = float(np.nanpercentile(y_local, 70)) + base_local = y_local[y_local <= local_cut] + if base_local.size < 5: + base_local = y_local + + local_std = float(np.nanstd(base_local)) + if not np.isfinite(local_std) or local_std <= 0: + local_std = float(background_std) + return max(local_std, 1e-12) + + local_noise = np.asarray([_local_noise_std(int(i)) for i in peak_indices], dtype=float) + local_snr_values = np.asarray( + [float(h) / max(float(n), 1e-12) for h, n in zip(peak_heights, local_noise)], + dtype=float, + ) + local_prominence_snr = np.asarray( + [float(p) / max(float(n), 1e-12) for p, n in zip(peak_proms, local_noise)], dtype=float + ) + + prominence_floor = max(2.2, 0.85 * float(floor)) + salience_snr = prominence_snr * np.sqrt(np.maximum(peak_width_samples, 1e-12)) + salience_floor = max(4.2, 2.0 * float(floor)) + local_salience_snr = local_prominence_snr * np.sqrt(np.maximum(peak_width_samples, 1e-12)) + + adaptive_floor = max(2.0, 0.62 * float(floor)) + adaptive_prominence_floor = max(1.6, 0.62 * float(prominence_floor)) + adaptive_salience_floor = max(2.6, 0.62 * float(salience_floor)) + + display_peaks_with_prom = [ + ( + int(i), + float(h), + float(E[i]), + float(max(float(h / background_std), float(local_snr))), + float(max(float(p_snr), float(local_p_snr))), + float(max(float(sal), float(local_sal))), + ) + for i, h, p_snr, sal, local_snr, local_p_snr, local_sal in zip( + peak_indices, + peak_heights, + prominence_snr, + salience_snr, + local_snr_values, + local_prominence_snr, + local_salience_snr, + ) + if ( + not in_ignore(E[i]) + and ( + ( + h / background_std >= floor + and p_snr >= prominence_floor + and sal >= salience_floor + ) + or ( + local_snr >= adaptive_floor + and local_p_snr >= adaptive_prominence_floor + and local_sal >= adaptive_salience_floor + ) + ) + ) ] + + # Validate peaks as local Gaussian components (center/sigma/amplitude) + # rather than raw single-bin maxima, then merge overlapping components. + def _gauss_with_offset(x, amp, mu, sigma, offset): + sigma = max(float(sigma), 1e-12) + return float(offset) + float(amp) * np.exp(-0.5 * ((x - float(mu)) / sigma) ** 2) + + def _fit_local_gaussian(pk_idx): + window = max(0.18, 10.0 * float(self.sampling[0])) + x0 = float(E[pk_idx]) + mask_local = np.abs(E - x0) <= window + if int(np.count_nonzero(mask_local)) < 7: + return None + + x_local = np.asarray(E[mask_local], dtype=float) + y_local = np.asarray(spec[mask_local], dtype=float) + if not np.all(np.isfinite(y_local)): + return None + + baseline = float(np.percentile(y_local, 20)) + peak_val = float(spec[pk_idx]) + amp0 = max(peak_val - baseline, 1e-9) + sigma0 = max(0.04, 2.0 * float(self.sampling[0])) + + lo_sigma = max(1.5 * float(self.sampling[0]), 0.010) + hi_sigma = 0.18 + bounds = ( + [0.0, x0 - 0.06, lo_sigma, baseline - abs(amp0)], + [max(amp0 * 5.0, 1e-6), x0 + 0.06, hi_sigma, baseline + abs(amp0)], + ) + + try: + popt, _ = curve_fit( + _gauss_with_offset, + x_local, + y_local, + p0=[amp0, x0, sigma0, baseline], + bounds=bounds, + maxfev=4000, + ) + except Exception: + return None + + amp, mu, sigma, offset = map(float, popt) + if amp <= 0 or not np.isfinite(mu) or not np.isfinite(sigma): + return None + + y_hat = _gauss_with_offset(x_local, amp, mu, sigma, offset) + ss_res = float(np.sum((y_local - y_hat) ** 2)) + ss_tot = float(np.sum((y_local - float(np.mean(y_local))) ** 2)) + r2 = 1.0 - ss_res / max(ss_tot, 1e-12) + amp_snr = amp / max(float(background_std), 1e-12) + + return { + "idx": int(pk_idx), + "mu": float(mu), + "sigma": float(sigma), + "amp": float(amp), + "amp_snr": float(amp_snr), + "r2": float(r2), + "area": float(amp * sigma), + } + + gaussian_validation_gate = max(2.2 * float(floor), 0.25 * float(snr_threshold)) + strong_keep_idx = { + int(pk_idx) + for pk_idx, _, _, snr, _, _ in display_peaks_with_prom + if float(snr) >= gaussian_validation_gate + } + + gauss_components = [] + for pk_idx, _, _, snr, _, _ in display_peaks_with_prom: + if float(snr) >= gaussian_validation_gate: + continue + fit = _fit_local_gaussian(int(pk_idx)) + if fit is None: + continue + # Keep only physically plausible and sufficiently Gaussian components. + if fit["r2"] < 0.58: + continue + if fit["amp_snr"] < max(2.0, 0.75 * float(floor)): + continue + if fit["sigma"] < max(1.5 * float(self.sampling[0]), 0.010) or fit["sigma"] > 0.18: + continue + gauss_components.append(fit) + + gaussian_validated = bool(gauss_components) + if gauss_components: + # Merge overlapping Gaussian components and keep the stronger one. + gauss_components.sort(key=lambda comp: comp["mu"]) + merged = [] + for comp in gauss_components: + if not merged: + merged.append(comp) + continue + prev = merged[-1] + # Keep neighbouring components separate unless they are truly + # unresolved by both center spacing and valley separation. + center_gap = abs(float(comp["mu"]) - float(prev["mu"])) + overlap_thresh = 1.15 * min(float(prev["sigma"]), float(comp["sigma"])) + + prev_idx = int(prev["idx"]) + comp_idx = int(comp["idx"]) + lo, hi = sorted((prev_idx, comp_idx)) + if hi - lo <= 1: + valley = float(min(spec[lo], spec[hi])) + else: + valley = float(np.min(spec[lo : hi + 1])) + smaller_amp = max(min(float(prev["amp"]), float(comp["amp"])), 1e-12) + valley_relief = (smaller_amp - valley) / smaller_amp + + unresolved_pair = center_gap <= overlap_thresh and valley_relief < 0.22 + if unresolved_pair: + if (comp["area"] > prev["area"]) or ( + comp["area"] == prev["area"] and comp["amp_snr"] > prev["amp_snr"] + ): + merged[-1] = comp + else: + merged.append(comp) + + keep_idx = {int(comp["idx"]) for comp in merged} + keep_idx.update(strong_keep_idx) + display_peaks_with_prom = [ + item for item in display_peaks_with_prom if int(item[0]) in keep_idx + ] + else: + # If weak-peak Gaussian fitting did not validate any component, + # still keep strong visual peaks. + if strong_keep_idx: + display_peaks_with_prom = [ + item for item in display_peaks_with_prom if int(item[0]) in strong_keep_idx + ] + + # Prune weak shoulder-like bumps near a much stronger neighbouring peak. + # This prevents over-detecting pseudo-peaks on the flanks of broad peaks. + if len(display_peaks_with_prom) > 1 and not gaussian_validated: + by_energy = sorted(display_peaks_with_prom, key=lambda item: item[2]) + shoulder_window = max(8.0 * float(self.sampling[0]), 0.22) + weak_snr_ratio = 0.45 + weak_prom_ratio = 0.65 + local_prom_floor = max(3.5, 1.10 * float(floor)) + + pruned = [] + for idx, h, en, snr, p_snr, sal in by_energy: + strongest_neighbor = None + for o_idx, o_h, o_en, o_snr, o_p_snr, o_sal in by_energy: + if o_idx == idx: + continue + if abs(float(o_en) - float(en)) > shoulder_window: + continue + if strongest_neighbor is None or o_snr > strongest_neighbor[0]: + strongest_neighbor = (float(o_snr), float(o_p_snr), float(o_en)) + + if strongest_neighbor is None: + pruned.append((idx, h, en, snr, p_snr, sal)) + continue + + nbr_snr, nbr_prom, _ = strongest_neighbor + is_weak_shoulder = ( + float(snr) < weak_snr_ratio * max(nbr_snr, 1e-12) + and float(p_snr) < weak_prom_ratio * max(nbr_prom, 1e-12) + and float(p_snr) < local_prom_floor + ) + if not is_weak_shoulder: + pruned.append((idx, h, en, snr, p_snr, sal)) + + display_peaks_with_prom = pruned + + display_peaks = [(idx, h, en, snr) for idx, h, en, snr, _, _ in display_peaks_with_prom] display_peaks.sort(key=lambda item: item[3], reverse=True) - significant_peaks = list(display_peaks) - display_peaks = display_peaks[:peaks] def candidate_matches(peak_energy, snr, allowed_elements=None): matches = [] @@ -1115,6 +1453,144 @@ def candidate_matches(peak_energy, snr, allowed_elements=None): ) ) + energy_min = float(np.min(E)) if len(E) else float(self.origin[0]) + energy_max = float(np.max(E)) if len(E) else energy_min + + def observable_shells_for_element(element): + shells = set() + for line_name, line_info in (all_info.get(str(element), {}) or {}).items(): + if not type(self)._line_allowed_for_element(str(element), line_name, edge_filters): + continue + shell = type(self)._line_shell(line_name) + if shell not in {"K", "L", "M"}: + continue + try: + line_energy = float(line_info.get("energy (keV)", line_info.get("energy"))) + except (TypeError, ValueError): + continue + if energy_min <= line_energy <= energy_max: + shells.add(shell) + return shells + + def strongest_observable_line(element, shell_name): + candidates = [] + for line_name, line_info in (all_info.get(str(element), {}) or {}).items(): + if not type(self)._line_allowed_for_element(str(element), line_name, edge_filters): + continue + if type(self)._line_shell(line_name) != shell_name: + continue + try: + line_energy = float(line_info.get("energy (keV)", line_info.get("energy"))) + line_weight = float(line_info.get("weight", 0.0)) + except (TypeError, ValueError): + continue + if energy_min <= line_energy <= energy_max: + candidates.append((line_weight, line_energy, str(line_name))) + return max(candidates, default=None) + + def shell_has_observable_support(element, shell_name): + strongest = strongest_observable_line(element, shell_name) + if strongest is None: + return True + + _, target_energy, _ = strongest + support_window = max(float(tolerance), 3.0 * float(self.sampling[0]), 0.04) + + for _, _, peak_energy, _ in display_peaks: + dist_to_target = abs(float(peak_energy) - float(target_energy)) + if dist_to_target > support_window: + continue + # Nearby spectral support exists for this shell line. + return True + + local_idx = np.where(np.abs(E - float(target_energy)) <= support_window)[0] + if local_idx.size == 0: + return False + + local_snr = float(np.nanmax(spec[local_idx]) / max(float(background_std), 1e-9)) + weak_bump_threshold = max(2.5, 0.35 * float(snr_threshold)) + if local_snr < weak_bump_threshold: + return False + + return True + + def strong_secondary_lines_have_support( + element, shell_name, matched_line_energy, weight_threshold=None + ): + """Return True if all strong secondary lines within shell_name (besides the matched one) have support.""" + if weight_threshold is None: + # Keep L-shell checks strict (e.g. Xe La1 requires visible Lg1), + # but avoid over-eliminating K-shell IDs (e.g. Fe Ka without + # clearly visible Kb in low-count/trace conditions). + if shell_name == "L": + weight_threshold = 0.03 + elif shell_name == "K": + weight_threshold = 0.12 + else: + weight_threshold = 0.10 + support_window = max(float(tolerance), 3.0 * float(self.sampling[0]), 0.04) + weak_bump_threshold = max(2.5, 0.35 * float(snr_threshold)) + for line_name, line_info in (all_info.get(str(element), {}) or {}).items(): + if not type(self)._line_allowed_for_element(str(element), line_name, edge_filters): + continue + if type(self)._line_shell(line_name) != shell_name: + continue + try: + line_energy = float(line_info.get("energy (keV)", line_info.get("energy"))) + line_weight = float(line_info.get("weight", 0.0)) + except (TypeError, ValueError): + continue + if not (energy_min <= line_energy <= energy_max): + continue + if line_weight < weight_threshold: + continue + # Skip the line that was actually matched — it trivially has support + if abs(line_energy - float(matched_line_energy)) <= support_window: + continue + # This secondary line needs nearby spectral support. + found_support = False + for _, _, pe, _ in display_peaks: + if abs(float(pe) - line_energy) > support_window: + continue + found_support = True + break + if found_support: + continue + # Fallback: raw spectral SNR near the secondary line. + local_idx = np.where(np.abs(E - line_energy) <= support_window)[0] + if local_idx.size == 0: + return False + local_y = np.asarray(spec[local_idx], dtype=float) + local_rel = int(np.argmax(local_y)) + local_max_pos = int(local_idx[local_rel]) + local_snr = float(spec[local_max_pos] / max(float(background_std), 1e-9)) + + # Secondary-line support can be accepted from a clear local + # maximum, even if it does not satisfy the stricter SNR gate. + has_local_max = True + if local_y.size >= 3: + if local_rel <= 0 or local_rel >= int(local_y.size) - 1: + has_local_max = False + else: + has_local_max = bool( + local_y[local_rel] >= local_y[local_rel - 1] + and local_y[local_rel] >= local_y[local_rel + 1] + ) + + edge_baseline = float( + np.median([local_y[0], local_y[-1], float(np.percentile(local_y, 30))]) + ) + relief_snr = float( + (local_y[local_rel] - edge_baseline) / max(float(background_std), 1e-9) + ) + local_bump_threshold = max(1.2, 0.45 * float(floor)) + + if local_snr < weak_bump_threshold and not ( + has_local_max and relief_snr >= local_bump_threshold + ): + return False + return True + element_stats, line_evidence = {}, {} for ( _, @@ -1187,6 +1663,46 @@ def candidate_matches(peak_energy, snr, allowed_elements=None): evidence["best_conf"] = float(conf) evidence["best_snr"] = float(snr) + # Collect all candidate elements across every display peak (not just best-match winners) + all_candidate_shells: dict[str, set] = {} + for peak_idx, height, peak_energy, snr in display_peaks: + for m in candidate_matches(peak_energy, snr, search_elements): + shell = m["shell"] + if shell in {"K", "L", "M"}: + all_candidate_shells.setdefault(m["element"], set()).add(shell) + + shell_hierarchy = ["K", "L", "M"] # descending energy order + + demoted_elements = set() + for element, shells in all_candidate_shells.items(): + # Prefer shells that actually won first-pass matches for this element. + # Using all candidate shells can falsely trigger higher-shell checks + # (e.g. Cu candidate L-lines) even when the element is only evidenced by K-lines. + observed_shells = set((element_stats.get(element, {}) or {}).get("shells", set())) & { + "K", + "L", + "M", + } + matched_shells = observed_shells if observed_shells else (shells & {"K", "L", "M"}) + observable = observable_shells_for_element(element) + eliminate = False + for matched_shell in matched_shells: + shell_idx = shell_hierarchy.index(matched_shell) + # Every higher-energy shell that is observable must have spectral support. + # Also verify that the supporting shell is genuine by checking its own + # strong secondary lines — prevents a coincidental neighbouring peak + # (e.g. Cu Kb1,3 near Os La1) from falsely satisfying the L-shell check. + for higher_shell in shell_hierarchy[:shell_idx]: + if higher_shell not in observable: + continue + if not shell_has_observable_support(element, higher_shell): + eliminate = True + break + if eliminate: + break + if eliminate: + demoted_elements.add(str(element)) + element_confidence = {} # --- Intensity ratio check and multi-peak pattern boost --- for element, stats in element_stats.items(): @@ -1324,28 +1840,49 @@ def prior_boost(element): return prior, factor def consistency_boost(element, line_name, peak_energy): - if element not in dominant_elements: - return 1.0 + is_detected = element in detected_elements + is_dominant = element in dominant_elements + if is_dominant: + scale = 1.0 + elif is_detected: + scale = 0.80 + else: + scale = 0.65 + # First, check evidence for this exact line evidence = line_evidence.get(f"{element} {line_name}") - if not evidence or not any( - abs(float(peak_energy) - float(prev)) >= 0.04 + if evidence and any( + abs(float(peak_energy) - float(prev)) <= 0.04 for prev in evidence.get("energies", []) ): - return 1.0 - best_conf = float(evidence.get("best_conf", 0.0)) - best_snr = float(evidence.get("best_snr", 0.0)) - strong = int(evidence.get("strong_matches", 0)) + best_conf = float(evidence.get("best_conf", 0.0)) + best_snr = float(evidence.get("best_snr", 0.0)) + strong = int(evidence.get("strong_matches", 0)) + line_weight = float( + (all_info.get(element, {}).get(line_name, {}) or {}).get("weight", 0.5) + ) + tier = 1.0 + 0.7 * max(0.0, line_weight - 0.35) + if strong >= 1 and best_conf >= 1.4: + return min(3.2, scale * 2.4 * tier) + if best_conf >= 1.1 and best_snr >= max(floor, 0.75 * snr_threshold): + return min(2.6, scale * 1.9 * tier) + if best_conf >= 0.8: + return min(2.0, scale * 1.5 * tier) + return min(1.5, scale * 1.2 * tier) + # Element was matched via a different line — boost secondary lines of this element + stats = element_stats.get(element, {}) + elem_conf = float(element_confidence.get(element, 0.0)) + elem_strong = int(stats.get("strong_matches", 0)) line_weight = float( (all_info.get(element, {}).get(line_name, {}) or {}).get("weight", 0.5) ) - tier = 1.0 + 0.7 * max(0.0, line_weight - 0.35) - if strong >= 1 and best_conf >= 1.4: - return min(3.2, 2.4 * tier) - if best_conf >= 1.1 and best_snr >= max(snr_min, 0.75 * snr_threshold): - return min(2.6, 1.9 * tier) - if best_conf >= 0.8: - return min(2.0, 1.5 * tier) - return min(1.5, 1.2 * tier) + tier = 1.0 + 0.5 * max(0.0, line_weight - 0.35) + if elem_strong >= 1 and elem_conf >= 1.4: + return min(2.4, scale * 1.8 * tier) + if elem_conf >= 1.1: + return min(2.0, scale * 1.5 * tier) + if elem_conf >= 0.8: + return min(1.6, scale * 1.2 * tier) + return min(1.3, scale * 1.1 * tier) def dominant_boost(element): if element not in dominant_elements: @@ -1365,42 +1902,333 @@ def reranked_matches(peak_energy, snr, allowed_elements=None, top_k=None): element_to_lines = {} for _, _, _, _, el, _, _, ln, _, _ in peak_matches: element_to_lines.setdefault(el, set()).add(ln) + + def _has_main_line(lines, target): + # Accept compact aliases from x_ray_lines.csv such as La1,2 or Kb1,3. + for ln in lines: + name = str(ln) + if name == target: + return True + if target in {"Ka1", "Kb1", "La1", "Lb1", "Ma1", "Mb1"} and name.startswith( + target + "," + ): + return True + return False + + def _canonical_aliases(target): + if target == "La1": + return ("La1", "La1,2") + if target == "Lb1": + return ("Lb1",) + if target == "Ka1": + return ("Ka1",) + if target == "Kb1": + return ("Kb1", "Kb1,3") + return (target,) + + def _line_evidence_strength(element, target): + best = 0.0 + for alias in _canonical_aliases(target): + ev = line_evidence.get(f"{element} {alias}") + if not ev: + continue + best_conf = float(ev.get("best_conf", 0.0)) + strong = float(ev.get("strong_matches", 0)) + count = float(ev.get("match_count", 0)) + score = best_conf + 0.45 * strong + 0.15 * count + if score > best: + best = score + return best + + def _l_support_strength(element): + return _line_evidence_strength(element, "La1") + _line_evidence_strength( + element, "Lb1" + ) + + candidates = candidate_matches(peak_energy, snr, allowed_elements) + + # For weak peaks, also consider a relaxed-distance pass for already + # detected/dominant elements. This keeps context-consistent lines in + # play (e.g. Te continuation) even when local calibration/noise shifts + # push them slightly beyond the strict tolerance window. + weak_peak = float(snr) < max(2.5 * float(floor), 0.30 * float(snr_threshold)) + if weak_peak: + relaxed_tol = max(float(tolerance), 0.30) + context_elements = set(map(str, detected_elements | dominant_elements)) + if context_elements: + for element_name, lines in all_info.items(): + element_name = str(element_name) + if element_name not in context_elements: + continue + if allowed_elements is not None and element_name not in allowed_elements: + continue + for line_name, line_info in lines.items(): + if not type(self)._line_allowed_for_element( + element_name, line_name, edge_filters + ): + continue + line_weight = float(line_info.get("weight", 0.5)) + line_energy = float(line_info["energy (keV)"]) + shell = type(self)._line_shell(line_name) + tol = ( + relaxed_tol * 0.5 + if shell == "M" + and ("Ma" not in line_name and "Mb" not in line_name) + else relaxed_tol + ) + distance = abs(float(peak_energy) - line_energy) + if line_weight < min_line_weight or distance > tol: + continue + score = type(self)._peak_confidence( + snr, line_weight, distance, relaxed_tol + ) * type(self)._shell_preference_factor(shell) + candidates.append( + { + "element": element_name, + "line": str(line_name), + "weight": line_weight, + "distance": distance, + "score": float(score), + "shell": shell, + } + ) + + # De-duplicate exact element/line candidates, keeping highest score. + if candidates: + uniq = {} + for c in candidates: + key = (str(c["element"]), str(c["line"])) + prev = uniq.get(key) + if prev is None or float(c["score"]) > float(prev["score"]): + uniq[key] = c + candidates = list(uniq.values()) + candidates.sort(key=lambda m: m["score"], reverse=True) + + # Performance guard: the precedence logic below is O(n^2) over + # candidates. Keep the strongest candidates, but always retain + # context-important elements (confirmed/dominant/preferred). + max_candidates = 48 + if len(candidates) > max_candidates: + context_elements = set( + map(str, detected_elements | dominant_elements | preferred_elements) + ) + trimmed = list(candidates[:max_candidates]) + if context_elements: + kept_keys = {(str(c["element"]), str(c["line"])) for c in trimmed} + for c in candidates[max_candidates:]: + key = (str(c["element"]), str(c["line"])) + if key in kept_keys: + continue + if str(c["element"]) in context_elements: + trimmed.append(c) + kept_keys.add(key) + candidates = trimmed + + element_has_l_support = {} + element_has_l_pair = {} + for el, lines in element_to_lines.items(): + has_la = _has_main_line(lines, "La1") + has_lb = _has_main_line(lines, "Lb1") + element_has_l_support[str(el)] = has_la or has_lb + element_has_l_pair[str(el)] = has_la and has_lb + + # Guard against boosted confirmed elements stealing a peak from a + # much-closer strong K/L candidate (e.g. Cu Ka around 8 keV). + distance_anchor = None + for candidate in candidates: + if candidate["shell"] not in {"K", "L"}: + continue + if float(candidate["weight"]) < 0.30: + continue + if float(candidate["score"]) < 0.45: + continue + distance_anchor = max(float(candidate["distance"]), 1e-9) + break + scored = [] - for match in candidate_matches(peak_energy, snr, allowed_elements): + for match in candidates: element, line_name, shell = match["element"], match["line"], match["shell"] + is_demoted = str(element) in demoted_elements + + minor_l_penalty = 1.0 + + # Logical guard for L-series continuation: do not let an orphan + # minor L-line assignment (Ll/Lg/Lb2) outrank a closer candidate + # from an element that already shows L-series support (La/Lb). + if ( + shell == "L" + and line_name in {"Ll", "Lg1", "Lb2,15"} + and not element_has_l_support.get(str(element), False) + ): + supported_closer_exists = False + for other in candidates: + other_el = str(other["element"]) + if other_el == str(element): + continue + if other["shell"] != "L": + continue + if not element_has_l_support.get(other_el, False): + continue + if float(other["distance"]) < float(match["distance"]): + supported_closer_exists = True + break + if supported_closer_exists: + minor_l_penalty *= 0.55 + + # Stricter logical precedence: an orphan minor L-line must not + # outrank a closer L-line from an element with an established + # La/Lb pair in this spectrum. + if ( + shell == "L" + and line_name in {"Ll", "Lg1", "Lb2,15"} + and not element_has_l_pair.get(str(element), False) + ): + paired_closer_exists = False + for other in candidates: + other_el = str(other["element"]) + if other_el == str(element): + continue + if other["shell"] != "L": + continue + if not element_has_l_pair.get(other_el, False): + continue + if float(other["distance"]) <= float(match["distance"]): + paired_closer_exists = True + break + if paired_closer_exists: + minor_l_penalty *= 0.70 + + # Evidence-strength precedence for minor L-lines: + # if another element has materially stronger La/Lb evidence and + # a comparable-or-better distance match, do not keep the weaker + # minor-L candidate as a possible winner. + if shell == "L" and line_name in {"Ll", "Lg1", "Lb2,15"}: + this_el = str(element) + this_dist = float(match["distance"]) + this_support = _l_support_strength(this_el) + beaten_by_stronger = False + for other in candidates: + other_el = str(other["element"]) + if other_el == this_el or other["shell"] != "L": + continue + other_support = _l_support_strength(other_el) + if other_support <= max(0.4, this_support + 0.30): + continue + # Accept up to 30 eV slack so support can break near ties. + if float(other["distance"]) <= this_dist + 0.03: + beaten_by_stronger = True + break + if beaten_by_stronger: + minor_l_penalty *= 0.60 + prior, prior_factor = prior_boost(element) pref = 1.35 if element in preferred_elements else 1.0 anchor = 1.15 if element in anchor_elements and shell in {"K", "L"} else 1.0 - consistency = consistency_boost(element, line_name, peak_energy) dom = dominant_boost(element) # Pattern boost: if both main lines for K, L, or M are matched by detected peaks, boost candidate score lines_matched = element_to_lines.get(element, set()) - k_lines = {"Ka1", "Kb1"} - l_lines = {"La1", "Lb1"} - m_lines = {"Ma1", "Mb1"} + has_k_pair = _has_main_line(lines_matched, "Ka1") and _has_main_line( + lines_matched, "Kb1" + ) + has_l_pair = _has_main_line(lines_matched, "La1") and _has_main_line( + lines_matched, "Lb1" + ) + has_m_pair = _has_main_line(lines_matched, "Ma1") and _has_main_line( + lines_matched, "Mb1" + ) pattern_factor = 1.0 - if k_lines.issubset(lines_matched): + if has_k_pair: pattern_factor = 3.0 - elif l_lines.issubset(lines_matched): + elif has_l_pair: pattern_factor = 2.5 - elif m_lines.issubset(lines_matched): + elif has_m_pair: pattern_factor = 2.0 + if shell == "M": prior_factor = 1.0 + 0.3 * prior - consistency = 1.0 dom = min(dom, 1.30) + + # Guard against introducing new singleton elements on weak peaks. + # If an element is not already detected/dominant and only appears + # as an isolated line, require a very tight distance match. + if ( + weak_peak + and element not in detected_elements + and element not in dominant_elements + ): + matched_lines_for_el = element_to_lines.get(element, set()) + # Consider an element supported if it already has any matched + # line in the current spectrum, or strong line evidence from + # first-pass matching. This avoids dropping context-consistent + # secondary lines (e.g. Cu Kb1,3 after Cu Ka1 is matched). + element_line_strength = 0.0 + for ln in matched_lines_for_el: + ev = line_evidence.get(f"{element} {ln}") + if not ev: + continue + best_conf = float(ev.get("best_conf", 0.0)) + strong = float(ev.get("strong_matches", 0)) + count = float(ev.get("match_count", 0)) + element_line_strength = max( + element_line_strength, best_conf + 0.4 * strong + 0.1 * count + ) + + has_support = len(matched_lines_for_el) >= 1 or element_line_strength >= 0.8 + if not has_support: + if float(match["distance"]) > 0.035: + continue + prior_factor *= 0.65 + # For confirmed elements (detected or dominant), the line_weight prior is irrelevant — + # we already know the element is present. Use weight=1.0 and score purely on distance + # so that e.g. Cu Kb1 (weight=0.17) beats Os La1 (weight=1.0) when Cu is confirmed + # and Cu Kb1 is closer to the measured peak. + confirmed = element in detected_elements or element in dominant_elements + if confirmed: + sigma = max(float(tolerance) / 3.0, 1e-9) + distance_factor = np.exp(-0.5 * (float(match["distance"]) / sigma) ** 2) + base_score = ( + np.log1p(max(float(snr), 0.0)) + * 1.0 + * distance_factor + * type(self)._shell_preference_factor(shell) + ) + # Once an element is clearly present, prefer physically + # consistent continuation lines over introducing new + # elements for nearby ambiguous peaks. + continuation = consistency_boost(element, line_name, peak_energy) + base_score = base_score * max(1.0, min(float(continuation), 1.8)) + else: + base_score = match["score"] + consistency = consistency_boost(element, line_name, peak_energy) + # Non-confirmed elements should not gain an aggressive + # boost that steals peaks from already-confirmed elements. + base_score = base_score * min(1.0, float(consistency)) score = ( - match["score"] + base_score * prior_factor * pref * anchor - * consistency * dom * pattern_factor + * minor_l_penalty ) - scored.append({**match, "score": float(score)}) - scored.sort(key=lambda m: m["score"], reverse=True) + # If there is a strong nearby K/L anchor, damp long-distance + # takeovers that are caused mainly by cross-peak boosts. + if ( + confirmed + and distance_anchor is not None + and float(match["distance"]) > distance_anchor + ): + ratio = float(match["distance"]) / distance_anchor + if ratio >= 2.0: + score *= ratio**-1.6 + + scored.append({**match, "score": float(score), "demoted": bool(is_demoted)}) + + # Ranking-only policy: keep shell-inconsistent elements as options, + # but place them behind more plausible (non-demoted) candidates. + scored.sort(key=lambda m: (bool(m.get("demoted", False)), -float(m["score"]))) if mode == "elements_preferred" and preferred_elements: preferred = [m for m in scored if m["element"] in preferred_elements] scored = ( @@ -1443,7 +2271,7 @@ def reranked_matches(peak_energy, snr, allowed_elements=None, top_k=None): refined_peak_matches = [] for peak_idx, height, peak_energy, snr in display_peaks: - best = reranked_matches(peak_energy, snr, rematch_allowed or None, top_k=1) + best = reranked_matches(peak_energy, snr, None, top_k=1) best = best[0] if best else None if best is None: continue @@ -1463,6 +2291,34 @@ def reranked_matches(peak_energy, snr, allowed_elements=None, top_k=None): ) peak_matches = refined_peak_matches + # Backfill element_confidence for elements that only appear after the + # unrestricted re-rank (e.g. not in search_elements so never entered + # element_stats in the first pass). Use the same base formula as + # _peak_confidence so the displayed value is meaningful. + for ( + _, + height, + peak_energy, + snr, + element, + _, + distance, + line_name, + line_weight, + _, + ) in peak_matches: + if element in element_confidence: + continue + sigma = max(float(tolerance) / 3.0, 1e-9) + dist_factor = float(np.exp(-0.5 * (float(distance) / sigma) ** 2)) + raw = float( + np.log1p(max(float(snr), 0.0)) * max(float(line_weight), 0.0) * dist_factor + ) + shell = type(self)._line_shell(str(line_name)) + valid_shells = {shell} & {"K", "L", "M"} + major_bonus = 1.20 if {"K", "L"} & valid_shells else 1.0 + element_confidence[element] = raw * major_bonus + matched_elements = {str(match[4]) for match in peak_matches} detected_elements = { str(el) @@ -1474,12 +2330,20 @@ def reranked_matches(peak_energy, snr, allowed_elements=None, top_k=None): str(el) for el in preferred_elements if str(el) in matched_elements ) refined_match_by_idx = {int(match[0]): match for match in peak_matches} + plot_peaks = display_peaks[:peaks] + plot_peak_indices = {int(pk_idx) for pk_idx, _, _, _ in plot_peaks} final_matches_by_element: dict[str, set[str]] = {} for _, _, _, _, element, _, _, line_name, _, _ in peak_matches: if element not in ignored_elements: final_matches_by_element.setdefault(element, set()).add(str(line_name)) + # For display purposes (table + plot), restrict to elements/lines seen in plot_peaks + plot_matches_by_element: dict[str, set[str]] = {} + for pk_idx, _, _, _, element, _, _, line_name, _, _ in peak_matches: + if int(pk_idx) in plot_peak_indices and element not in ignored_elements: + plot_matches_by_element.setdefault(element, set()).add(str(line_name)) + candidate_elements = sorted( str(el) for el in final_matches_by_element if str(el) not in detected_elements ) @@ -1506,21 +2370,33 @@ def format_saved(edge_filters): ) return "\n".join(out) if out else "None" - all_identified = set(detected_elements) | set(candidate_elements) - print( - f"\nDetected: {format_elements_with_lines(all_identified) if all_identified else 'None'}" + plot_all_identified = set( + el + for el in (set(detected_elements) | set(candidate_elements)) + if el in plot_matches_by_element ) - visible_dominant = dominant_elements & all_identified - if visible_dominant: - dominant_str = ", ".join( - f"{el} (conf={element_confidence.get(str(el), 0.0):.2f})" - for el in sorted( - visible_dominant, - key=lambda el: element_confidence.get(str(el), 0.0), - reverse=True, + if plot_all_identified: + det_rows = [] + for element in sorted(map(str, plot_all_identified)): + conf = element_confidence.get(element, 0.0) + lines_matched = sorted(map(str, plot_matches_by_element.get(element, set()))) + if element in detected_elements: + status = "Dominant" if element in dominant_elements else "Detected" + else: + status = "Possible" + det_rows.append( + (element, status, conf, ", ".join(lines_matched) if lines_matched else "-") ) + det_rows.sort( + key=lambda r: (0 if r[1] == "Dominant" else 1 if r[1] == "Detected" else 2, -r[2]) ) - print(f"High confidence: {dominant_str}") + print(f"\n{'Element':<10} {'Confidence':<12} {'Matched Lines'}") + print("-" * 50) + for el, status, conf, lines_str in det_rows: + print(f"{el:<10} {conf:<12.3f} {lines_str}") + print("-" * 50) + else: + print("\nDetected: None") elements_for_color = set(detected_elements) | {str(match[4]) for match in peak_matches} if search_elements is not None: @@ -1545,10 +2421,9 @@ def format_saved(edge_filters): element_color_map = { el: palette[i % len(palette)] for i, el in enumerate(sorted(elements_for_color)) } - y_min = float(np.nanmin(spec)) if len(spec) else 0.0 y_max = float(np.nanmax(spec)) if len(spec) else 1.0 - y_scale = max(max(1e-9, y_max - y_min), abs(y_max), 1.0) + y_scale = max(max(1e-9, y_max - y_min), abs(y_max), abs(y_min), 1e-6) y_dot = -0.04 * y_scale def infer_requested_color(peak_energy): @@ -1570,7 +2445,7 @@ def infer_requested_color(peak_energy): return best_element table_rows = [] - for peak_idx, height, peak_energy, snr in display_peaks: + for peak_idx, height, peak_energy, snr in plot_peaks: match = refined_match_by_idx.get(int(peak_idx)) color = ( element_color_map.get(match[4], "red") @@ -1582,7 +2457,7 @@ def infer_requested_color(peak_energy): # Only plot solid lines for matched peaks (autodetected or requested elements) if match is not None: ax_spec.axvline( - peak_energy, color=color, linestyle="-", alpha=0.7, linewidth=1.5 + peak_energy, color=color, linestyle="-", alpha=0.5, linewidth=1.5 ) else: ax_spec.plot( @@ -1647,17 +2522,17 @@ def label_with_energy_and_ratio(label, detected_peak_intensity=None): ) continue - allowed_for_table = ( - set(map(str, search_elements)) - if search_elements is not None - else ({str(el) for el in all_info if str(el) not in ignored_elements} or None) - ) - ranked = reranked_matches(peak_energy, snr, allowed_for_table, top_k=3) + # Best match for the table MUST be the same element/line shown on the spectrum + # (from refined_match_by_idx). Re-rank with all elements for Alt 2/3 alternatives. + best_label = f"{match[4]} {match[7]}" + ranked = reranked_matches(peak_energy, snr, None, top_k=3) labels = [ (f"{m['element']} {m['line']}", float(m["score"]), m["element"], m["line"]) for m in ranked ] - best_label = f"{match[4]} {match[7]}" + # If the spectrum winner appears in ranked, use that ordering; otherwise prepend it. + if not any(lbl.lower() == best_label.lower() for lbl, _, _, _ in labels): + labels = [(best_label, 0.0, match[4], match[7])] + labels def fmt(label, score=None): label = ( @@ -1669,18 +2544,18 @@ def fmt(label, score=None): # Gather all intensities for this element for ratio calculation all_element_intensities = {} - for intensity in all_info.get(match[4], {}): + for ll in all_info.get(match[4], {}): # Find the highest observed intensity for each line obs = 0.0 for _, h, _, _, el, _, _, ln, _, _ in peak_matches: - if el == match[4] and ln == intensity: + if el == match[4] and ln == ll: obs = max(obs, float(h)) - weight = all_info.get(match[4], {}).get(intensity, {}).get("weight", None) + weight = all_info.get(match[4], {}).get(ll, {}).get("weight", None) try: weight = float(weight) if weight is not None else 0.0 except Exception: weight = 0.0 - all_element_intensities[intensity] = (obs, weight) + all_element_intensities[ll] = (obs, weight) remaining = [ (label, score, elem, line) @@ -1716,9 +2591,8 @@ def get_peak_intensity(elem, line): ) current_bottom, current_top = ax_spec.get_ylim() - ax_spec.set_ylim( - bottom=min(current_bottom, y_dot - 0.02 * y_scale, -0.02 * y_scale), top=current_top - ) + padded_bottom = min(current_bottom, y_min - 0.10 * y_scale) + ax_spec.set_ylim(bottom=padded_bottom, top=current_top) label_candidates = [] top_label_y = 0.92 @@ -1757,11 +2631,11 @@ def get_peak_intensity(elem, line): for matched_energy in matched_by_element.get(str(element), []) ): continue - color = element_color_map.get(str(element), "black") + color = element_color_map.get(str(element), "gray") style = "--" - alpha = 0.45 + alpha = 0.5 ax_spec.axvline( - line_energy, color=color, linestyle=style, alpha=alpha, linewidth=1.2 + line_energy, color="gray", linestyle=style, alpha=alpha, linewidth=1.2 ) label_candidates.append( ( @@ -1778,11 +2652,15 @@ def get_peak_intensity(elem, line): ) if show_text and peak_matches: - label_offset = max(0.03 * y_scale, 0.08) + # Keep label offset proportional to local y-scale so low-intensity + # windows (e.g., 5.5-7 keV) do not place text outside the axes. + label_offset = 0.08 * y_scale label_allowed = set(detected_elements) | possible_elements if requested_elements: label_allowed.update(str(el) for el in requested_elements) - for _, height, peak_energy, _, element, match_str, _, _, _, _ in peak_matches: + for pk_idx, height, peak_energy, _, element, match_str, _, _, _, _ in peak_matches: + if int(pk_idx) not in plot_peak_indices: + continue is_requested = requested_elements is not None and element in requested_elements if element not in label_allowed or in_ignore(peak_energy): continue @@ -1804,66 +2682,76 @@ def get_peak_intensity(elem, line): legend_handles, legend_labels = [], set() if show_text and label_candidates: label_candidates.sort(key=lambda item: item[0]) - groups, current = [], [] - overlap_threshold = max(0.16, 1.1 * float(tolerance)) - for label in label_candidates: - if not current or abs(label[0] - current[-1][0]) <= overlap_threshold: - current.append(label) - else: - groups.append(current) - current = [label] - if current: - groups.append(current) - - for group in groups: - if len(group) == 1: - ( + drawn_texts = [] + for ( + peak_energy, + label_text, + color, + linestyle, + y_value, + y_mode, + font_size, + font_weight, + alpha_value, + ) in label_candidates: + common = dict( + ha="center", + fontsize=font_size, + color=color, + weight=font_weight, + rotation=90, + alpha=alpha_value, + ) + if y_mode == "axes_top": + txt = ax_spec.text( peak_energy, - label_text, - color, - _, y_value, - y_mode, - font_size, - font_weight, - alpha_value, - ) = group[0] - common = dict( - ha="center", - fontsize=font_size, - color=color, - weight=font_weight, - rotation=90, - alpha=alpha_value, + label_text, + va="top", + transform=ax_spec.get_xaxis_transform(), + clip_on=True, + **common, ) - if y_mode == "axes_top": - ax_spec.text( - peak_energy, - y_value, - label_text, - va="top", - transform=ax_spec.get_xaxis_transform(), - clip_on=True, - **common, - ) - else: - ax_spec.text(peak_energy, y_value, label_text, va="bottom", **common) else: - for _, label_text, color, linestyle, *_ in group: + txt = ax_spec.text(peak_energy, y_value, label_text, va="bottom", **common) + # Prioritize data-peak labels over top reference labels if collisions occur. + priority = 1 if y_mode == "data" else 0 + drawn_texts.append((txt, label_text, color, linestyle, priority)) + + if drawn_texts: + fig.canvas.draw() + ax_bbox = ax_spec.get_window_extent() + renderer = fig.canvas.get_renderer() + kept_bboxes = [] + # Keep higher-priority labels first, then by x-position for stable layout. + drawn_texts.sort(key=lambda item: (-item[4], item[0].get_position()[0])) + for txt, label_text, color, linestyle, _ in drawn_texts: + txt_bbox = txt.get_window_extent(renderer=renderer) + out_of_bounds = ( + txt_bbox.x0 < ax_bbox.x0 + or txt_bbox.x1 > ax_bbox.x1 + or txt_bbox.y0 < ax_bbox.y0 + or txt_bbox.y1 > ax_bbox.y1 + ) + overlaps_kept = any(txt_bbox.overlaps(prev_bbox) for prev_bbox in kept_bboxes) + + if out_of_bounds or overlaps_kept: + txt.remove() key = (label_text, str(color), linestyle) - if key in legend_labels: - continue - legend_labels.add(key) - legend_handles.append( - Line2D( - [0], - [0], - color=color, - linestyle=linestyle, - linewidth=1.5, - label=label_text, + if key not in legend_labels: + legend_labels.add(key) + legend_handles.append( + Line2D( + [0], + [0], + color=color, + linestyle=linestyle, + linewidth=1.5, + label=label_text, + ) ) - ) + else: + kept_bboxes.append(txt_bbox) if legend_handles: overlap_legend = ax_spec.legend( @@ -1871,6 +2759,21 @@ def get_peak_intensity(elem, line): ) ax_spec.add_artist(overlap_legend) + if line is not None: + x_min, x_max = ax_spec.get_xlim() + _ref_energies = [line] if isinstance(line, (int, float)) else list(line) + for ref_energy in _ref_energies: + try: + ref_energy = float(ref_energy) + except (TypeError, ValueError): + continue + # Do not let out-of-window reference lines change autoscaled limits. + if x_min <= ref_energy <= x_max: + ax_spec.axvline( + ref_energy, color="black", linestyle="--", linewidth=1.2, zorder=3 + ) + ax_spec.set_xlim(x_min, x_max) + fig.tight_layout() plt.show() @@ -1885,7 +2788,8 @@ def get_peak_intensity(elem, line): ) print("-" * 105) print( - f"{len(display_peaks)} of {len(significant_peaks)} peaks above snr_min={snr_min:.1f}, snr_threshold={snr_threshold:.1f} displayed.\n" + f"{len(plot_peaks)} of {len(display_peaks)} peaks above " + f"floor={floor:.1f}, snr_threshold={snr_threshold:.1f} displayed.\n" ) if return_details: @@ -1896,7 +2800,9 @@ def get_peak_intensity(elem, line): "element_confidence": element_confidence, "display_peaks": display_peaks, "peak_matches": peak_matches, - "snr_min": snr_min, + "floor": floor, + "snr_quantile_floor": floor, + "snr_min": floor, "snr_threshold": snr_threshold, } return fig, (ax_img, ax_spec) From 9d07ae1208780e8bfc96319b3d0858d216b22712 Mon Sep 17 00:00:00 2001 From: smribet Date: Fri, 8 May 2026 15:20:42 -0700 Subject: [PATCH 130/136] sanya's spectroscopy changes --- src/quantem/spectroscopy/dataset3dspectroscopy.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 5f2e6d20..1a5fa5be 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -291,10 +291,7 @@ def remove_elements_from_model(self, elements): self.model_elements[element_key] = { line_name: line_info for line_name, line_info in lines_info.items() - if not ( - type(self)._line_matches_selectors(line_name, selectors) - or type(self)._line_info_matches_selectors(line_info, selectors) - ) + if not type(self)._line_matches_selectors(line_name, selectors) } if not self.model_elements[element_key]: self.model_elements.pop(element_key, None) From af8530915e5682834131278f5d3b4920484d044f Mon Sep 17 00:00:00 2001 From: smribet Date: Fri, 8 May 2026 15:32:18 -0700 Subject: [PATCH 131/136] fixing labels --- src/quantem/spectroscopy/dataset3deds.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 25fc1845..d26c53a8 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -2592,10 +2592,12 @@ def get_peak_intensity(elem, line): current_bottom, current_top = ax_spec.get_ylim() padded_bottom = min(current_bottom, y_min - 0.10 * y_scale) - ax_spec.set_ylim(bottom=padded_bottom, top=current_top) + padded_top = max(current_top, y_max + 0.18 * y_scale) + ax_spec.set_ylim(bottom=padded_bottom, top=padded_top) label_candidates = [] - top_label_y = 0.92 + top_label_y = 0.99 + peak_label_y = 0.92 # Plot reference lines (dotted) ONLY for explicitly requested elements, not for autodetected/possible if requested_elements: energy_min, energy_max = float(np.min(E)), float(np.max(E)) @@ -2652,13 +2654,10 @@ def get_peak_intensity(elem, line): ) if show_text and peak_matches: - # Keep label offset proportional to local y-scale so low-intensity - # windows (e.g., 5.5-7 keV) do not place text outside the axes. - label_offset = 0.08 * y_scale label_allowed = set(detected_elements) | possible_elements if requested_elements: label_allowed.update(str(el) for el in requested_elements) - for pk_idx, height, peak_energy, _, element, match_str, _, _, _, _ in peak_matches: + for pk_idx, _height, peak_energy, _, element, match_str, _, _, _, _ in peak_matches: if int(pk_idx) not in plot_peak_indices: continue is_requested = requested_elements is not None and element in requested_elements @@ -2671,8 +2670,8 @@ def get_peak_intensity(elem, line): label, element_color_map.get(element, "black"), "-", - float(height + label_offset), - "data", + float(peak_label_y), + "axes_peak", 10, "bold", 1.0, @@ -2702,7 +2701,7 @@ def get_peak_intensity(elem, line): rotation=90, alpha=alpha_value, ) - if y_mode == "axes_top": + if y_mode in {"axes_top", "axes_peak"}: txt = ax_spec.text( peak_energy, y_value, @@ -2715,7 +2714,7 @@ def get_peak_intensity(elem, line): else: txt = ax_spec.text(peak_energy, y_value, label_text, va="bottom", **common) # Prioritize data-peak labels over top reference labels if collisions occur. - priority = 1 if y_mode == "data" else 0 + priority = 1 if y_mode in {"data", "axes_peak"} else 0 drawn_texts.append((txt, label_text, color, linestyle, priority)) if drawn_texts: From 7f136bf178ec738c783b7c12a0c144fe172122c0 Mon Sep 17 00:00:00 2001 From: smribet Date: Wed, 13 May 2026 13:52:58 -0700 Subject: [PATCH 132/136] updated background subtraction EDS --- src/quantem/spectroscopy/dataset3deds.py | 140 +++++++++++++++--- .../spectroscopy/dataset3dspectroscopy.py | 50 +++++-- 2 files changed, 157 insertions(+), 33 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index d26c53a8..44afa9d0 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -3666,31 +3666,125 @@ def _local_closure(): "spectrum_images_pytorch": self._spectrum_images_pytorch, } - def calculate_background_powerlaw(self, spectrum): - """Estimate a power-law Bremsstrahlung background from the spectrum.""" - import numpy as np - from scipy.ndimage import gaussian_filter - - # Use a larger window for more conservative background estimation - window_size = 15 # Larger window = smoother, less aggressive - background = np.zeros_like(spectrum) - half_window = window_size // 2 + def calculate_background_polynomial( + self, + spectrum, + energy_axis=None, + degree=3, + percentile=10, + window_size=50, + ): + """ + Fit an EDS continuum background with a polynomial power series in energy. - # Estimate background from sliding minimum - for i in range(len(spectrum)): - start = max(0, i - half_window) - end = min(len(spectrum), i + half_window + 1) - # Use percentile instead of minimum for more robustness - background[i] = np.percentile(spectrum[start:end], 10) + A rolling low-percentile envelope is used as the fit target so sharp + characteristic X-ray peaks do not dominate the continuum fit. + """ - # Apply heavy smoothing to avoid creating artificial features - background = gaussian_filter(background, sigma=5.0) + spectrum = np.asarray(spectrum, dtype=float) + if spectrum.ndim != 1: + raise ValueError("spectrum must be a 1D array") + if spectrum.size == 0: + raise ValueError("spectrum must contain at least one channel") + + if energy_axis is None: + energy_axis = np.asarray(self.energy_axis, dtype=float) + if energy_axis.shape != spectrum.shape: + energy_axis = float(self.origin[0]) + float(self.sampling[0]) * np.arange( + spectrum.size, dtype=float + ) + else: + energy_axis = np.asarray(energy_axis, dtype=float) + if energy_axis.shape != spectrum.shape: + raise ValueError("energy_axis must have the same shape as spectrum") - # Be very conservative - only subtract 80% of estimated background - # This prevents over-subtraction that creates artificial peaks - background = background * 0.8 + if isinstance(degree, bool): + raise TypeError("degree must be a non-negative integer") + try: + degree = int(degree) + except (TypeError, ValueError) as exc: + raise TypeError("degree must be a non-negative integer") from exc + if degree < 0: + raise ValueError("degree must be >= 0") - # Ensure background doesn't exceed spectrum - background = np.minimum(background, spectrum * 0.9) + try: + percentile = float(percentile) + except (TypeError, ValueError) as exc: + raise TypeError("percentile must be a number between 0 and 100") from exc + if percentile < 0 or percentile > 100: + raise ValueError("percentile must be between 0 and 100") + + if isinstance(window_size, bool): + raise TypeError("window_size must be a positive integer") + try: + window_size = int(window_size) + except (TypeError, ValueError) as exc: + raise TypeError("window_size must be a positive integer") from exc + if window_size < 1: + raise ValueError("window_size must be >= 1") + window_size = min(window_size, spectrum.size) + + finite = np.isfinite(spectrum) & np.isfinite(energy_axis) + if np.count_nonzero(finite) < degree + 1: + raise ValueError("not enough finite spectrum points for the requested degree") - return background + half_window = window_size // 2 + envelope = np.full_like(spectrum, np.nan, dtype=float) + for channel in range(spectrum.size): + start = max(0, channel - half_window) + end = min(spectrum.size, channel + half_window + 1) + values = spectrum[start:end] + values = values[np.isfinite(values)] + if values.size: + envelope[channel] = np.percentile(values, percentile) + + fit_mask = finite & np.isfinite(envelope) + if np.count_nonzero(fit_mask) < degree + 1: + raise ValueError("not enough background fit points for the requested degree") + + fit_energy = energy_axis[fit_mask] + fit_counts = envelope[fit_mask] + energy_min = float(np.min(fit_energy)) + energy_span = float(np.max(fit_energy) - energy_min) + if energy_span <= 0: + if degree != 0: + raise ValueError("energy_axis must span more than one value for degree > 0") + return np.full_like(spectrum, max(float(np.median(fit_counts)), 0.0), dtype=float) + + # Scaling improves conditioning; this remains a polynomial in energy. + def scaled_energy(energy): + return 2.0 * (np.asarray(energy, dtype=float) - energy_min) / energy_span - 1.0 + + def polynomial_background(energy, *coefficients): + energy_scaled = scaled_energy(energy) + background = np.zeros_like(energy_scaled, dtype=float) + for power, coefficient in enumerate(coefficients): + background += coefficient * (energy_scaled**power) + return background + + scaled_fit_energy = scaled_energy(fit_energy) + initial_coefficients = np.polynomial.polynomial.polyfit( + scaled_fit_energy, + fit_counts, + deg=degree, + ) + try: + coefficients, _ = curve_fit( + polynomial_background, + fit_energy, + fit_counts, + p0=initial_coefficients, + maxfev=10000, + ) + except (RuntimeError, ValueError, FloatingPointError): + coefficients = initial_coefficients + + background = polynomial_background(energy_axis, *coefficients) + finite_counts = spectrum[finite] + max_count = max(float(np.max(finite_counts)), float(np.max(fit_counts)), 0.0) + background = np.nan_to_num(background, nan=0.0, posinf=max_count, neginf=0.0) + return np.maximum(background, 0.0) + + def calculate_background_powerlaw(self, spectrum, *args, **kwargs): + """Compatibility wrapper for the EDS polynomial background fit.""" + return self.calculate_background_polynomial(spectrum, *args, **kwargs) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 1a5fa5be..2089f787 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -1154,13 +1154,15 @@ def subtract_background( ignore_range=None, mask=None, target_edge=None, - window_size=10, + window_size=None, method="powerlaw", + polynomial_degree=3, return_dataset=True, attach_spectrum=True, fit_mode="global", kernel_width=1, show=True, + show_subtracted=True, return_background=False, ): """ @@ -1177,9 +1179,17 @@ def subtract_background( Number of nearest spatial neighbors to average for each local background fit. The current pixel is included. Used only when ``fit_mode="local"``. + window_size : int, optional + For EDS, number of spectral channels in the rolling low-percentile + envelope used before polynomial fitting. For EELS power-law fitting, + percent of ``target_edge`` used for the pre-edge fit window. Defaults + to 50 channels for EDS and 10 percent for EELS. show : bool, optional If True, plot the mean raw spectrum, fitted background, and background-subtracted spectrum. + polynomial_degree : int, optional + Degree of the polynomial power-series background used for EDS data. + Ignored for EELS data. return_background : bool, optional If True, return ``(dataset, background_cube)`` when ``return_dataset`` is True, otherwise return the background cube. @@ -1210,6 +1220,7 @@ def subtract_background( method=method, target_edge=target_edge, window_size=window_size, + polynomial_degree=polynomial_degree, ) background_cube = np.broadcast_to(background[:, None, None], array3d.shape) else: @@ -1219,6 +1230,7 @@ def subtract_background( method=method, target_edge=target_edge, window_size=window_size, + polynomial_degree=polynomial_degree, kernel_width=kernel_width, ) @@ -1237,6 +1249,7 @@ def subtract_background( background_mean_spectrum, subtracted_mean_spectrum, fit_mode=fit_mode, + show_subtracted=show_subtracted, ) dataset_type = str(self.dataset_type).lower() @@ -1303,12 +1316,25 @@ def _background_energy_axis_and_indices(self, energy_range, mask): indices = np.where(selected)[0] return E[indices], indices - def _fit_background_spectrum(self, spectrum, energy_axis, method, target_edge, window_size): + def _fit_background_spectrum( + self, + spectrum, + energy_axis, + method, + target_edge, + window_size, + polynomial_degree=3, + ): dataset_type = str(self.dataset_type).lower() spectrum = np.asarray(spectrum, dtype=float) if dataset_type == "eds": - return self.calculate_background_powerlaw(spectrum) + return self.calculate_background_polynomial( + spectrum, + energy_axis=np.asarray(energy_axis, dtype=float), + degree=polynomial_degree, + window_size=50 if window_size is None else window_size, + ) if dataset_type != "eels": raise ValueError(f"Unsupported spectroscopy dataset_type {self.dataset_type!r}") @@ -1325,7 +1351,7 @@ def _fit_background_spectrum(self, spectrum, energy_axis, method, target_edge, w spectrum, np.asarray(energy_axis, dtype=float), target_edge=target_edge, - window_size=window_size, + window_size=10 if window_size is None else window_size, ) def _fit_eels_powerlaw_background(self, spectrum, energy_axis, target_edge, window_size): @@ -1366,6 +1392,7 @@ def _fit_local_background_cube( method, target_edge, window_size, + polynomial_degree, kernel_width, ): from scipy.spatial import cKDTree @@ -1398,6 +1425,7 @@ def _fit_local_background_cube( method=method, target_edge=target_edge, window_size=window_size, + polynomial_degree=polynomial_degree, ) except Exception as exc: y, x = divmod(pixel_index, nx) @@ -1412,17 +1440,19 @@ def _plot_background_subtraction( background_spectrum, subtracted_spectrum, fit_mode, + show_subtracted, ): fig, (ax_specbacksub) = plt.subplots(1, 1, figsize=(12, 4)) ax_specbacksub.plot(energy_axis, input_spectrum, linewidth=1.2, label="Input") ax_specbacksub.plot(energy_axis, background_spectrum, linewidth=1.2, label="Background") - ax_specbacksub.plot( - energy_axis, - subtracted_spectrum, - linewidth=1.5, - label="Background-subtracted", - ) + if show_subtracted: + ax_specbacksub.plot( + energy_axis, + subtracted_spectrum, + linewidth=1.5, + label="Background-subtracted", + ) if self.dataset_type == "eds": ax_specbacksub.set_xlabel("Energy (keV)") else: From 9cf731b8b1e6bdcfbb05ce230fda0939b3c2af6d Mon Sep 17 00:00:00 2001 From: smribet Date: Wed, 13 May 2026 14:44:47 -0700 Subject: [PATCH 133/136] bug fix --- src/quantem/spectroscopy/dataset3deds.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 44afa9d0..8e83f07a 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -992,7 +992,7 @@ def peak_autoid( str(k): (set(map(str, v.keys())) if isinstance(v, dict) and v else None) for k, v in (getattr(self, "model_elements", {}) or {}).items() } or None - edge_filters = type(self)._merge_edge_filters(requested, saved) + edge_filters = requested if requested is not None else saved requested_elements = set(edge_filters) if edge_filters else None mode = (str(mode).strip().lower() if mode is not None else None) or ( @@ -2271,7 +2271,7 @@ def _l_support_strength(element): refined_peak_matches = [] for peak_idx, height, peak_energy, snr in display_peaks: - best = reranked_matches(peak_energy, snr, None, top_k=1) + best = reranked_matches(peak_energy, snr, search_elements, top_k=1) best = best[0] if best else None if best is None: continue @@ -2523,9 +2523,9 @@ def label_with_energy_and_ratio(label, detected_peak_intensity=None): continue # Best match for the table MUST be the same element/line shown on the spectrum - # (from refined_match_by_idx). Re-rank with all elements for Alt 2/3 alternatives. + # (from refined_match_by_idx). Preserve elements_only filtering for alternatives. best_label = f"{match[4]} {match[7]}" - ranked = reranked_matches(peak_energy, snr, None, top_k=3) + ranked = reranked_matches(peak_energy, snr, search_elements, top_k=3) labels = [ (f"{m['element']} {m['line']}", float(m["score"]), m["element"], m["line"]) for m in ranked From e4ae0048f28a0d548b5c138b2623f0126d23379b Mon Sep 17 00:00:00 2001 From: smribet Date: Wed, 13 May 2026 16:07:49 -0700 Subject: [PATCH 134/136] normalization bug --- .../spectroscopy/dataset3dspectroscopy.py | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 2089f787..6aff9d96 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -695,15 +695,15 @@ def calculate_mean_spectrum( mask=None, attach_mean_spectrum=True, roi_cal=None, - normalize=True, + normalize=False, ): """Calculate a spectrum from a spatial ROI. Parameters ---------- normalize : bool, optional - If ``True``, average over the ROI pixels. If ``False``, sum counts - over the ROI pixels. + If ``True``, scale the mean spectrum to the range [0, 1]. If + ``False``, return the mean spectrum in original intensity units. """ y, x, dy, dx = self._resolve_roi(roi=roi, roi_cal=roi_cal) @@ -744,7 +744,7 @@ def calculate_mean_spectrum( arr = arr[mask, y : y + dy, x : x + dx] # select masked energies and ROI if arr.shape[0] > 0: - spec = arr.mean(axis=(1, 2)) if normalize else arr.sum(axis=(1, 2)) + spec = arr.mean(axis=(1, 2)) else: spec = np.zeros(0) E = E[mask] # Mask the energy axis as well @@ -755,7 +755,7 @@ def calculate_mean_spectrum( roi_data = img[y : y + dy, x : x + dx] if roi_data.size == 0: raise ValueError("ROI is empty; check y/x/dy/dx.") - spec[k] = roi_data.mean() if normalize else roi_data.sum() + spec[k] = roi_data.mean() # APPLY ENERGY RANGE --------------------------------------------------------------- @@ -776,6 +776,16 @@ def calculate_mean_spectrum( spec = spec[indices] E = E[indices] + if normalize and spec.size > 0: + finite = np.isfinite(spec) + if np.any(finite): + spec_min = np.min(spec[finite]) + spec_max = np.max(spec[finite]) + if spec_max > spec_min: + spec = (spec - spec_min) / (spec_max - spec_min) + else: + spec = np.zeros_like(spec, dtype=float) + if attach_mean_spectrum: self.add_spectrum_to_data(spec, E) @@ -788,6 +798,7 @@ def show_mean_spectrum( energy_range=None, mask=None, intensity_range=None, + normalize=False, **kwargs, ): """ @@ -810,6 +821,9 @@ def show_mean_spectrum( Boolean mask for pixel selection. intensity_range : 2-tuple, None If not None, sets intensity range on spectrum plot + normalize : bool, optional + If ``True``, scale the mean spectrum to the range [0, 1]. If + ``False``, plot the mean spectrum in original intensity units. Returns ------- (fig, ax) : tuple @@ -847,7 +861,7 @@ def show_mean_spectrum( roi_cal=roi_cal, energy_range=energy_range, mask=mask, - normalize=False, + normalize=normalize, ) dE = float(self.sampling[0]) @@ -901,7 +915,7 @@ def show_mean_spectrum( ax_spec.set_xlabel("Energy (keV)") else: ax_spec.set_xlabel("Energy (eV)") - ax_spec.set_ylabel("Intensity") + ax_spec.set_ylabel("Normalized intensity" if normalize else "Intensity") ax_spec.set_title(f"Spectrum from ROI [{y}:{y + dy}, {x}:{x + dx}]") ax_spec.grid(True, alpha=0.1) if intensity_range is not None: From aca05b9290c50f56ac23b3b4281a4406702c8fe5 Mon Sep 17 00:00:00 2001 From: smribet Date: Wed, 20 May 2026 14:46:02 -0700 Subject: [PATCH 135/136] removing unused code --- src/quantem/spectroscopy/dataset3deds.py | 154 +----------------- src/quantem/spectroscopy/dataset3deels.py | 26 +-- .../spectroscopy/dataset3dspectroscopy.py | 116 ------------- 3 files changed, 23 insertions(+), 273 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deds.py b/src/quantem/spectroscopy/dataset3deds.py index 8e83f07a..18b5c774 100644 --- a/src/quantem/spectroscopy/dataset3deds.py +++ b/src/quantem/spectroscopy/dataset3deds.py @@ -105,10 +105,6 @@ def _normalize_element_info(cls, combine_close_peaks=True, energy_threshold_ev=1 if not isinstance(cls.element_info, dict): return cls.element_info - if combine_close_peaks is None: - combine_close_peaks = cls.combine_close_xray_lines - if energy_threshold_ev is None: - energy_threshold_ev = cls.close_xray_line_threshold_ev threshold_kev = float(energy_threshold_ev) / 1000.0 def line_family(line_name): @@ -356,7 +352,7 @@ def _merge_edge_filters(requested, saved): return requested or saved @staticmethod - def _estimate_snr_thresholds(snr_values, peaks, floor=None, snr_threshold=None): + def _estimate_snr_thresholds(snr_values, floor=None, snr_threshold=None): """Auto-estimate SNR floors/thresholds from the peak SNR distribution.""" snr_values = np.asarray(snr_values, dtype=float) snr_values = snr_values[np.isfinite(snr_values)] @@ -464,7 +460,7 @@ def x_ray_lookup( [lbl for lbl, _, _ in unique], ) - def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False, show=True): + def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False): """Generate spectrum images by integrating around X-ray line energies. For each matched X-ray line, sums the spectral intensity within an @@ -481,8 +477,6 @@ def generage_spectrum_images(self, elements=None, width=0.15, return_maps=False, Half-width of the integration window in keV. return_maps : bool, optional If ``True``, return ``(maps, labels)``. - show : bool, optional - If ``True``, display the generated maps. Returns ------- @@ -874,10 +868,8 @@ def peak_autoid( roi_cal=None, energy_range=None, elements=None, - refline=None, ignore_elements=None, ignore_range=None, - threshold=5.0, tolerance=0.15, min_line_weight=0.0, mask=None, @@ -915,16 +907,11 @@ def peak_autoid( Element or element-line specifiers to search for, e.g. ``'Fe'``, ``'Fe Ka'``, or ``['Cu', 'Zn K']``. When provided, behaviour depends on *mode*. - refline : str | None, optional - Reserved for future use. ignore_elements : str | sequence[str] | None, optional Elements to exclude from autodetection. ignore_range : sequence[float] | None, optional Energy range ``[emin, emax]`` whose peaks are ignored. Defaults to ``[0, 0.25]`` keV to skip the noise floor. - threshold : float, optional - Legacy parameter (currently unused). SNR filtering is controlled - by *floor* and *snr_threshold*. tolerance : float, optional Maximum energy difference in keV between a detected peak and a tabulated X-ray line for them to be considered a match. @@ -1016,7 +1003,6 @@ def peak_autoid( roi=roi, roi_cal=roi_cal, energy_range=energy_range, - ignore_range=ignore_range, mask=mask, ) E = float(self.origin[0]) + float(self.sampling[0]) * np.arange(self.shape[0]) @@ -1139,7 +1125,6 @@ def collapse_shoulder_peaks(indices, heights, prominences, widths): snr_values = np.asarray([height / background_std for height in peak_heights], dtype=float) floor, snr_threshold = type(self)._estimate_snr_thresholds( snr_values, - peaks, floor, snr_threshold, ) @@ -1514,83 +1499,6 @@ def shell_has_observable_support(element, shell_name): return True - def strong_secondary_lines_have_support( - element, shell_name, matched_line_energy, weight_threshold=None - ): - """Return True if all strong secondary lines within shell_name (besides the matched one) have support.""" - if weight_threshold is None: - # Keep L-shell checks strict (e.g. Xe La1 requires visible Lg1), - # but avoid over-eliminating K-shell IDs (e.g. Fe Ka without - # clearly visible Kb in low-count/trace conditions). - if shell_name == "L": - weight_threshold = 0.03 - elif shell_name == "K": - weight_threshold = 0.12 - else: - weight_threshold = 0.10 - support_window = max(float(tolerance), 3.0 * float(self.sampling[0]), 0.04) - weak_bump_threshold = max(2.5, 0.35 * float(snr_threshold)) - for line_name, line_info in (all_info.get(str(element), {}) or {}).items(): - if not type(self)._line_allowed_for_element(str(element), line_name, edge_filters): - continue - if type(self)._line_shell(line_name) != shell_name: - continue - try: - line_energy = float(line_info.get("energy (keV)", line_info.get("energy"))) - line_weight = float(line_info.get("weight", 0.0)) - except (TypeError, ValueError): - continue - if not (energy_min <= line_energy <= energy_max): - continue - if line_weight < weight_threshold: - continue - # Skip the line that was actually matched — it trivially has support - if abs(line_energy - float(matched_line_energy)) <= support_window: - continue - # This secondary line needs nearby spectral support. - found_support = False - for _, _, pe, _ in display_peaks: - if abs(float(pe) - line_energy) > support_window: - continue - found_support = True - break - if found_support: - continue - # Fallback: raw spectral SNR near the secondary line. - local_idx = np.where(np.abs(E - line_energy) <= support_window)[0] - if local_idx.size == 0: - return False - local_y = np.asarray(spec[local_idx], dtype=float) - local_rel = int(np.argmax(local_y)) - local_max_pos = int(local_idx[local_rel]) - local_snr = float(spec[local_max_pos] / max(float(background_std), 1e-9)) - - # Secondary-line support can be accepted from a clear local - # maximum, even if it does not satisfy the stricter SNR gate. - has_local_max = True - if local_y.size >= 3: - if local_rel <= 0 or local_rel >= int(local_y.size) - 1: - has_local_max = False - else: - has_local_max = bool( - local_y[local_rel] >= local_y[local_rel - 1] - and local_y[local_rel] >= local_y[local_rel + 1] - ) - - edge_baseline = float( - np.median([local_y[0], local_y[-1], float(np.percentile(local_y, 30))]) - ) - relief_snr = float( - (local_y[local_rel] - edge_baseline) / max(float(background_std), 1e-9) - ) - local_bump_threshold = max(1.2, 0.45 * float(floor)) - - if local_snr < weak_bump_threshold and not ( - has_local_max and relief_snr >= local_bump_threshold - ): - return False - return True - element_stats, line_evidence = {}, {} for ( _, @@ -2349,27 +2257,6 @@ def _l_support_strength(element): ) possible_elements = set(candidate_elements) - def format_elements_with_lines(names): - items = [] - for element in sorted(map(str, names)): - lines = sorted(map(str, final_matches_by_element.get(element, set()))) - line_strs = [str(line) for line in lines] - items.append(f"{element} [{', '.join(line_strs)}]" if lines else f"{element}") - return ", ".join(items) - - def format_saved(edge_filters): - if edge_filters is None: - return "None" - out = [] - for element in sorted(map(str, edge_filters)): - selectors = edge_filters.get(element) - out.append( - f"{element} [all]" - if not selectors - else f"{element} [{', '.join(sorted(map(str, selectors)))}]" - ) - return "\n".join(out) if out else "None" - plot_all_identified = set( el for el in (set(detected_elements) | set(candidate_elements)) @@ -2486,7 +2373,7 @@ def infer_requested_color(peak_energy): print(f"Peak at {peak_energy} keV may come from the grid.") break - def label_with_energy_and_ratio(label, detected_peak_intensity=None): + def label_with_energy_and_ratio(label): # label is like 'Fe Ka', want to append (energy, ratio) from all_info and observed/expected if not label or label == "-" or label == "Unmatched" or label == "Unknown": return label @@ -2534,7 +2421,7 @@ def label_with_energy_and_ratio(label, detected_peak_intensity=None): if not any(lbl.lower() == best_label.lower() for lbl, _, _, _ in labels): labels = [(best_label, 0.0, match[4], match[7])] + labels - def fmt(label, score=None): + def fmt(label): label = ( f"{label}*" if requested_elements and str(label).split()[0] in requested_elements @@ -2542,49 +2429,22 @@ def fmt(label, score=None): ) return label - # Gather all intensities for this element for ratio calculation - all_element_intensities = {} - for ll in all_info.get(match[4], {}): - # Find the highest observed intensity for each line - obs = 0.0 - for _, h, _, _, el, _, _, ln, _, _ in peak_matches: - if el == match[4] and ln == ll: - obs = max(obs, float(h)) - weight = all_info.get(match[4], {}).get(ll, {}).get("weight", None) - try: - weight = float(weight) if weight is not None else 0.0 - except Exception: - weight = 0.0 - all_element_intensities[ll] = (obs, weight) - remaining = [ (label, score, elem, line) for label, score, elem, line in labels if label.lower() != best_label.lower() ] - # For each label, show ratio for that line - def get_peak_intensity(elem, line): - obs = 0.0 - for _, h, _, _, el, _, _, ln, _, _ in peak_matches: - if el == elem and ln == line: - obs = max(obs, float(h)) - return obs - table_rows.append( ( peak_energy, height, snr, - label_with_energy_and_ratio(fmt(best_label), detected_peak_intensity=height), - label_with_energy_and_ratio( - fmt(remaining[0][0]), detected_peak_intensity=height - ) + label_with_energy_and_ratio(fmt(best_label)), + label_with_energy_and_ratio(fmt(remaining[0][0])) if len(remaining) > 0 else "-", - label_with_energy_and_ratio( - fmt(remaining[1][0]), detected_peak_intensity=height - ) + label_with_energy_and_ratio(fmt(remaining[1][0])) if len(remaining) > 1 else "-", ) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index ab00f635..d5a9bb9d 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -184,9 +184,7 @@ def powerlaw_function(E, A, r): return background_fit - def smooth_eels_rollingaverage( - self, roi=None, energy_range=None, ignore_range=None, mask=None, kernel_size=10 - ): + def smooth_eels_rollingaverage(self, roi=None, energy_range=None, mask=None, kernel_size=10): dE = float(self.sampling[0]) E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 energy_axis = E0 + dE * np.arange(self.shape[0]) @@ -225,9 +223,15 @@ def smooth_eels_rollingaverage( # Plot raw and smoothed mean spectra on the same set of axes - mean_spectrum_raw = self.calculate_mean_spectrum(roi, energy_range, ignore_range, mask) + mean_spectrum_raw = self.calculate_mean_spectrum( + roi=roi, + energy_range=energy_range, + mask=mask, + ) mean_spectrum_smoothed = smoothed_data3d.calculate_mean_spectrum( - roi, energy_range, ignore_range, mask + roi=roi, + energy_range=energy_range, + mask=mask, ) fig, ax = plt.subplots() @@ -237,9 +241,7 @@ def smooth_eels_rollingaverage( return smoothed_data3d - def measure_zlp_offset( - self, zlp_guess_x=None, fit_window=0.8, use_gaussian_fit=True, fit_to_plane=False - ): + def measure_zlp_offset(self, zlp_guess_x=None, fit_window=0.8, fit_to_plane=False): """ Measure ZLP offset at each pixel position by using a guess of ZLP posfitting each spectrum to a Gaussian """ @@ -344,7 +346,11 @@ def apply_zlp_correction( # Alternatively, a 2D array matching the x and y dimensions of the 3D dataset can be supplied as the value of zlp_shifts_array to skip this step. # If measure_offset is False and no 2D ZLP shifts array is provided, a scalar input for zlp_guess_x can be used to shift the energy axis at every scan position by that amount. if measure_offset: - zlp_array = self.measure_zlp_offset(zlp_guess_x, fit_window, fit_to_plane) + zlp_array = self.measure_zlp_offset( + zlp_guess_x=zlp_guess_x, + fit_window=fit_window, + fit_to_plane=fit_to_plane, + ) elif zlp_shifts_array is not None: if zlp_shifts_array.shape == self.array.shape[1:3]: zlp_array = zlp_shifts_array @@ -531,7 +537,7 @@ def calibrate_zero_loss_peak(self, center_guess=None, search_window=10): units=self.units, ) - def correct_zlp_shift(ll, hl, approach="smooth", sigma=1.2): + def correct_zlp_shift(ll, hl): """ Aligns ZLP jitter across the spatial map and synchronizes Dual-EELS pairs. """ diff --git a/src/quantem/spectroscopy/dataset3dspectroscopy.py b/src/quantem/spectroscopy/dataset3dspectroscopy.py index 6aff9d96..8cd5ec5f 100644 --- a/src/quantem/spectroscopy/dataset3dspectroscopy.py +++ b/src/quantem/spectroscopy/dataset3dspectroscopy.py @@ -350,7 +350,6 @@ def perform_pca( standardize: bool = True, mask: Optional[NDArray] = None, plot_results: bool = True, - random_state: Optional[int] = 42, return_results=False, ) -> dict: """ @@ -367,8 +366,6 @@ def perform_pca( (scan_y, scan_x) or a flattened spatial mask. plot_results : bool If True, plot the explained variance and first few components - random_state : Optional[int] - Accepted for API compatibility. PCA uses deterministic SVD. Returns ------- @@ -691,7 +688,6 @@ def calculate_mean_spectrum( self, roi=None, energy_range=None, - ignore_range=None, mask=None, attach_mean_spectrum=True, roi_cal=None, @@ -924,116 +920,6 @@ def show_mean_spectrum( fig.tight_layout() return fig, (ax_img, ax_spec) - def refline( - self, - elements, - ax=None, - energy=None, - energy_range=None, - linestyle=":", - linewidth=1.2, - alpha=0.35, - show_text=True, - ): - """Overlay reference lines for selected element specifiers on a spectrum axis. - - This is plotting-only and does not modify auto-identification behavior. - """ - if elements is None: - raise ValueError("elements must be specified") - if energy is not None and energy_range is not None: - raise ValueError("Specify either energy or energy_range, not both") - - all_info = type(self)._ensure_element_info() - - specs = type(self)._normalize_element_specs(elements) - if len(specs) == 0: - raise ValueError("elements must contain at least one selector") - - if ax is None: - ax = plt.gca() - - if energy_range is not None: - if len(energy_range) != 2: - raise ValueError("energy_range must be [min_energy, max_energy]") - e_min = float(min(energy_range[0], energy_range[1])) - e_max = float(max(energy_range[0], energy_range[1])) - elif energy is not None: - tol = max(2.0 * abs(float(self.sampling[0])), 1e-9) - center = float(energy) - e_min = center - tol - e_max = center + tol - else: - xlim = ax.get_xlim() - e_min = float(min(xlim)) - e_max = float(max(xlim)) - - artists = [] - labels = [] - energies = [] - - y_top = ax.get_ylim()[1] - y_text = y_top - 0.08 * max(abs(y_top), 1e-12) - - for spec in specs: - tokens = str(spec).split() - if len(tokens) == 0: - continue - - element_key = type(self)._resolve_element_key(all_info, tokens[0]) - if element_key is None: - continue - - selectors = tokens[1:] - selected_lines = type(self)._select_lines(all_info.get(element_key, {}), selectors) - - for line_name, line_info in selected_lines.items(): - if not isinstance(line_info, dict): - continue - - energy_value = line_info.get("energy (keV)") - if energy_value is None: - energy_value = line_info.get("onset_energy (eV)") - if energy_value is None: - energy_value = line_info.get("energy") - if energy_value is None: - continue - - try: - line_energy = float(energy_value) - except (TypeError, ValueError): - continue - - if not (e_min <= line_energy <= e_max): - continue - - label = f"{element_key} {line_name}" - line_artist = ax.axvline( - line_energy, - linestyle=linestyle, - linewidth=linewidth, - alpha=alpha, - label=label, - ) - artists.append(line_artist) - labels.append(label) - energies.append(line_energy) - - if show_text: - text_artist = ax.text( - line_energy, - y_text, - label, - rotation=90, - ha="center", - va="top", - alpha=min(1.0, alpha + 0.2), - clip_on=True, - ) - artists.append(text_artist) - - return {"ax": ax, "artists": artists, "labels": labels, "energies": np.asarray(energies)} - def show_energy_window_map( self, energy_window=None, @@ -1165,7 +1051,6 @@ def subtract_background( self, roi=None, energy_range=None, - ignore_range=None, mask=None, target_edge=None, window_size=None, @@ -1215,7 +1100,6 @@ def subtract_background( True, also returns the fitted background cube. """ - del ignore_range from quantem.spectroscopy import Dataset3deds, Dataset3deels fit_mode = str(fit_mode).lower() From 248fbb38cce0bd84648e15435ba1cf4237a90b09 Mon Sep 17 00:00:00 2001 From: smribet Date: Wed, 27 May 2026 17:20:14 -0700 Subject: [PATCH 136/136] changes to fit for zlp --- src/quantem/spectroscopy/dataset3deels.py | 156 +++++++++++----------- 1 file changed, 81 insertions(+), 75 deletions(-) diff --git a/src/quantem/spectroscopy/dataset3deels.py b/src/quantem/spectroscopy/dataset3deels.py index d5a9bb9d..f0f61aba 100644 --- a/src/quantem/spectroscopy/dataset3deels.py +++ b/src/quantem/spectroscopy/dataset3deels.py @@ -119,9 +119,7 @@ def powerlaw_backgroundfit_eels(self, spectrum, energy_range, target_edge, windo The input window size should be 10-30% of the target edge energy. """ - dE = float(self.sampling[0]) - E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 - energy_axis = E0 + dE * np.arange(self.shape[0]) + energy_axis = self.energy_axis if energy_range is not None: energy_range[0] = np.maximum(energy_range[0], energy_axis[0]) @@ -185,9 +183,7 @@ def powerlaw_function(E, A, r): return background_fit def smooth_eels_rollingaverage(self, roi=None, energy_range=None, mask=None, kernel_size=10): - dE = float(self.sampling[0]) - E0 = float(self.origin[0]) if hasattr(self, "origin") else 0.0 - energy_axis = E0 + dE * np.arange(self.shape[0]) + energy_axis = self.energy_axis if energy_range is not None: energy_range[0] = np.maximum(energy_range[0], energy_axis[0]) @@ -241,7 +237,14 @@ def smooth_eels_rollingaverage(self, roi=None, energy_range=None, mask=None, ker return smoothed_data3d - def measure_zlp_offset(self, zlp_guess_x=None, fit_window=0.8, fit_to_plane=False): + def measure_zlp_offset( + self, + zlp_guess_x=None, + fit_window=0.8, + fit_to_plane=False, + median_filter_pixels=3, + fit_zlp=True, + ): """ Measure ZLP offset at each pixel position by using a guess of ZLP posfitting each spectrum to a Gaussian """ @@ -254,11 +257,8 @@ def _plane_fit_2d(M, a, b, c): x, y = M return (a * x) + (b * y) + c - n_energy, n_y, n_x = self.array.shape - - dE = float(self.sampling[0]) - E0 = float(self.origin[0]) - energy_axis = E0 + np.arange(n_energy) * dE + _n_energy, n_y, n_x = self.array.shape + energy_axis = self.energy_axis # For each pixel, measure the zlp position by fitting a Gaussian to the measured zero-loss signal and taking its center as the zlp position. @@ -267,45 +267,52 @@ def _plane_fit_2d(M, a, b, c): for iy in range(n_y): for ix in range(n_x): # Apply median filter to discount hot pixels that might spuriously produce the maximum intensity of the spectrum - spec_filt = median_filter(self.array[:, iy, ix], 3) - - # Use initial guess for ZLP to define window for Gaussian fitting. If zlp_guess_x=None (default) use the maximum value of the spectrum - if zlp_guess_x is not None: - zlp_crude_idx = int(np.argmin(np.abs(energy_axis - zlp_guess_x))) + if median_filter_pixels > 0: + spec_filt = median_filter(self.array[:, iy, ix], median_filter_pixels) else: - zlp_crude_idx = int(np.argmax(spec_filt)) + spec_filt = self.array[:, iy, ix] - mu0 = float(energy_axis[zlp_crude_idx]) + if fit_zlp: + # Use initial guess for ZLP to define window for Gaussian fitting. If zlp_guess_x=None (default) use the maximum value of the spectrum + if zlp_guess_x is not None: + zlp_crude_idx = int(np.argmin(np.abs(energy_axis - zlp_guess_x))) + else: + zlp_crude_idx = int(np.argmax(spec_filt)) - lo = mu0 - fit_window - hi = mu0 + fit_window + mu0 = float(energy_axis[zlp_crude_idx]) - x_mask = (energy_axis >= lo) & (energy_axis <= hi) + lo = mu0 - fit_window + hi = mu0 + fit_window - xw = energy_axis[x_mask] - yw = spec_filt[x_mask] + x_mask = (energy_axis >= lo) & (energy_axis <= hi) - A0 = float(spec_filt[zlp_crude_idx]) - sigma0 = fit_window / 2 + xw = energy_axis[x_mask] + yw = spec_filt[x_mask] - p0 = (A0, mu0, sigma0) + A0 = float(spec_filt[zlp_crude_idx]) + sigma0 = fit_window / 2 - bounds = ( - ( - 0.0, - lo, - 1e-12, - ), - ( - np.inf, - hi, - np.inf, - ), - ) + p0 = (A0, mu0, sigma0) - popt, _ = curve_fit(_gaussian_fit, xw, yw, p0=p0, bounds=bounds) + bounds = ( + ( + 0.0, + lo, + 1e-12, + ), + ( + np.inf, + hi, + np.inf, + ), + ) - zlp_measured[iy - 1, ix - 1] = float(popt[1]) + popt, _ = curve_fit(_gaussian_fit, xw, yw, p0=p0, bounds=bounds) + + zlp_measured[iy, ix] = float(popt[1]) + else: + zlp_crude_idx = int(np.argmax(spec_filt)) + zlp_measured[iy, ix] = float(energy_axis[zlp_crude_idx]) if fit_to_plane: # Fit a 2D plane to the array of measured ZLPs @@ -340,6 +347,7 @@ def apply_zlp_correction( fit_window=0.8, measure_offset=True, fit_to_plane=True, + fit_zlp=True, return_3d_dataset=True, ): # Default behavior is to automatically call measure_zlp_offset to generate an array of ZLP shifts for each scan position. @@ -350,63 +358,60 @@ def apply_zlp_correction( zlp_guess_x=zlp_guess_x, fit_window=fit_window, fit_to_plane=fit_to_plane, + fit_zlp=fit_zlp, ) elif zlp_shifts_array is not None: - if zlp_shifts_array.shape == self.array.shape[1:3]: - zlp_array = zlp_shifts_array - else: + zlp_array = np.asarray(zlp_shifts_array, dtype=float) + if zlp_array.shape != self.array.shape[1:3]: raise ValueError( "Dimensions of input array for ZLP shifts do not match X and Y dimensions of 3D spectroscopy dataset." ) elif zlp_guess_x is not None: - zlp_array = np.ones(self.array.shape[1:3]) * zlp_guess_x + zlp_array = np.ones(self.array.shape[1:3], dtype=float) * zlp_guess_x else: raise ValueError( "measure_offset was set to False and no input argument for ZLP shifts was provided." ) + zlp_array = np.asarray(zlp_array, dtype=float) + if not np.all(np.isfinite(zlp_array)): + raise ValueError("ZLP shifts must contain only finite values.") + # Initialize 3D array to populate with spectra aligned along the energy axis - corrected_array = np.zeros_like(self.array) + corrected_array = np.empty(self.array.shape, dtype=np.result_type(self.array.dtype, float)) n_energy, n_y, n_x = self.array.shape - dE = float(self.sampling[0]) - E0 = float(self.origin[0]) - energy_axis = E0 + np.arange(n_energy) * dE - - # Record the maximum positive shift to update the origin for the output aligned 3D dataset - max_shift = 0 + energy_axis = self.energy_axis + if np.all((zlp_array >= 0) & (zlp_array <= n_energy - 1)) and ( + np.min(zlp_array) < energy_axis[0] or np.max(zlp_array) > energy_axis[-1] + ): + zlp_array = np.interp(zlp_array, np.arange(n_energy), energy_axis) - # Apply the ZLP shift at each probe position, rounding to the nearest integer multiple of the sampling of the data + # Apply sub-channel ZLP shifts using 1D linear interpolation along the energy axis. for iy in range(n_y): for ix in range(n_x): spec = self.array[:, iy, ix] - shift_binsampled = np.floor_divide(zlp_array[iy, ix], dE) * (-1) - shift_int = shift_binsampled.astype(np.int64) - if shift_binsampled < 0: - spec_shifted = np.pad( - spec[-shift_binsampled:], - (0, -shift_int), - mode="constant", - constant_values=np.nan, - ) - else: - spec_shifted = np.pad( - spec, (shift_int, 0), mode="constant", constant_values=np.nan - )[: len(spec)] - # Update maximum shift for origin correction - if shift_binsampled > max_shift: - max_shift = shift_binsampled - corrected_array[:, iy, ix] = spec_shifted - - # Update origin - new_origin = E0 + max_shift * dE + corrected_array[:, iy, ix] = np.interp( + energy_axis + zlp_array[iy, ix], + energy_axis, + spec, + left=np.nan, + right=np.nan, + ) # Remove all planes along energy axis containing NaN, to equalize spectra lengths across all scan positions mask = np.isnan(corrected_array).any(axis=(1, 2)) aligned_data_3d = corrected_array[~mask] + new_Eaxis = energy_axis[~mask] + + if aligned_data_3d.shape[0] == 0: + raise ValueError( + "ZLP shifts leave no shared energy range after alignment. " + "Check that zlp_shifts_array is in energy units, not channel indices." + ) - new_Eaxis = new_origin + np.arange(aligned_data_3d.shape[0]) * dE + new_origin = float(new_Eaxis[0]) # Calculate mean spectra before and after correction for plotting mean_spectrum_raw = self.array.mean(axis=(1, 2)) @@ -434,6 +439,7 @@ def apply_zlp_correction( origin=new_origin, units=self.units, ) + return aligned_data_3d def calibrate_zero_loss_peak(self, center_guess=None, search_window=10): """ @@ -463,7 +469,7 @@ def calibrate_zero_loss_peak(self, center_guess=None, search_window=10): dE = float(self.sampling[0]) E0 = float(self.origin[0]) - energy_axis = E0 + np.arange(n_energy) * dE + energy_axis = self.energy_axis # --- Build ZLP position map --- # For every pixel, find the energy where the ZLP sits.