scipas 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. scipas/__init__.py +23 -0
  2. scipas/analysis/__init__.py +3 -0
  3. scipas/analysis/vedb/__init__.py +5 -0
  4. scipas/analysis/vedb/annihilation_fractions.py +105 -0
  5. scipas/analysis/vedb/diffusion_length.py +268 -0
  6. scipas/analysis/vedb/lineshape.py +167 -0
  7. scipas/analysis/vedb/ve_implanation.py +86 -0
  8. scipas/core/__init__.py +4 -0
  9. scipas/core/cdb.py +219 -0
  10. scipas/core/const.py +3 -0
  11. scipas/core/db.py +349 -0
  12. scipas/filter/__init__.py +8 -0
  13. scipas/filter/pals_coincidence.py +1 -0
  14. scipas/filter/pas_coincidence.py +215 -0
  15. scipas/libs/__init__.py +0 -0
  16. scipas/libs/positron_profile/__init__.py +0 -0
  17. scipas/libs/positron_profile/gosh_profile_parameters.txt +35 -0
  18. scipas/libs/positron_profile/makhov_profile_parameters.txt +35 -0
  19. scipas/model/__init__.py +5 -0
  20. scipas/model/layer.py +43 -0
  21. scipas/model/lifetime.py +53 -0
  22. scipas/model/material.py +86 -0
  23. scipas/model/sample.py +90 -0
  24. scipas/transport/__init__.py +4 -0
  25. scipas/transport/diffusion/__init__.py +3 -0
  26. scipas/transport/diffusion/positron_profile_solver.py +229 -0
  27. scipas/transport/implantation/__init__.py +4 -0
  28. scipas/transport/implantation/material_parameters.py +59 -0
  29. scipas/transport/implantation/multilayer.py +143 -0
  30. scipas/transport/implantation/profiles.py +102 -0
  31. scipas/transport/implantation/utils.py +39 -0
  32. scipas-0.3.0.dist-info/METADATA +231 -0
  33. scipas-0.3.0.dist-info/RECORD +36 -0
  34. scipas-0.3.0.dist-info/WHEEL +5 -0
  35. scipas-0.3.0.dist-info/licenses/LICENSE +21 -0
  36. scipas-0.3.0.dist-info/top_level.txt +1 -0
scipas/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ # Core analysis classes
2
+ from scipas.core import DB, CDB
3
+ from scipas.core.const import ELECTRON_REST_MASS_KEV
4
+
5
+ # Model / geometry
6
+ from scipas.model import Material, Defect, Layer, Sample
7
+
8
+ # Coincidence filter
9
+ from scipas.filter import PasCoincidenceFilter
10
+
11
+ # Transport
12
+ from scipas.transport import (
13
+ ghosh_profile,
14
+ makhov_profile,
15
+ ghosh_material_parameters,
16
+ makhov_material_parameters,
17
+ multilayer_implantation_profile,
18
+ profile_solver,
19
+ )
20
+
21
+ # VEDB analysis
22
+ from scipas.analysis import (DiffusionLengthOptimization, compute_s_lineshape, compute_w_lineshape,
23
+ variable_energy_implantation_profiles)
@@ -0,0 +1,3 @@
1
+ from .vedb import (compute_annihilation_fractions, DiffusionLengthOptimization,
2
+ compute_s_lineshape, compute_w_lineshape,
3
+ variable_energy_implantation_profiles)
@@ -0,0 +1,5 @@
1
+ """Package for Variable Energy Doppler Broadening (VEDB) analysis"""
2
+ from .annihilation_fractions import compute_annihilation_fractions
3
+ from .diffusion_length import DiffusionLengthOptimization
4
+ from .lineshape import compute_s_lineshape, compute_w_lineshape
5
+ from .ve_implanation import variable_energy_implantation_profiles
@@ -0,0 +1,105 @@
1
+ import numpy as np
2
+ import xarray as xr
3
+ from warnings import warn
4
+ from scipas.model.sample import Sample
5
+
6
+
7
+ def compute_annihilation_fractions(positron_profile: xr.DataArray, sample: Sample) -> xr.DataArray:
8
+ """
9
+ Compute the annihilation fraction of positrons in each layer and at the surface.
10
+
11
+ The total annihilation rate is decomposed into contributions from:
12
+ - Surface (layer = -1): positrons annihilating at the sample surface,
13
+ governed by the surface absorption length.
14
+ - Bulk layers (layer = 0, 1, ...): positrons annihilating in each
15
+ material layer, weighted by the effective annihilation rate.
16
+
17
+ The fractions are normalized so that they sum to 1.
18
+
19
+ Parameters
20
+ ----------
21
+ positron_profile : xr.DataArray
22
+ 1D positron density profile after diffusion [positrons/nm],
23
+ with a single depth coordinate (any name is accepted).
24
+ Must be normalized such that its integral equals 1.
25
+ sample : Sample
26
+ Multilayer sample defining geometry, material annihilation rates,
27
+ and surface absorption length.
28
+
29
+ Returns
30
+ -------
31
+ xr.DataArray
32
+ Normalized annihilation fractions with coordinate 'layer':
33
+ - layer = -1 : surface annihilation fraction
34
+ - layer = 0, 1, 2, ... : bulk annihilation fraction per layer
35
+
36
+ Raises
37
+ ------
38
+ ValueError
39
+ If positron_profile is not 1D.
40
+
41
+ Warns
42
+ -----
43
+ UserWarning
44
+ If the positron profile extends beyond the sample length.
45
+ Annihilation beyond the sample boundary is ignored and
46
+ fractions will not sum to 1 correctly.
47
+
48
+ Examples
49
+ --------
50
+ >>> from scipas.model import Sample, Layer, Material
51
+ >>> from scipas.analysis import compute_annihilation_fractions
52
+ >>> import xarray as xr
53
+ >>> import numpy as np
54
+ >>> silicon = Material(name="Silicon",
55
+ ... diffusion=1,
56
+ ... mobility=1,
57
+ ... bulk_annihilation_rate=1)
58
+ >>> layer = Layer(start=0.0, width=10000.0, material=silicon)
59
+ >>> sample = Sample(layers=[layer], absorption_length=1)
60
+ >>> depth = np.arange(0, layer.width+1, 1)
61
+ >>> positron_annihilation_profile = xr.DataArray(np.ones_like(depth), coords={'x':depth})
62
+ >>> positron_annihilation_profile /= positron_annihilation_profile.integrate('x')
63
+ >>> res = compute_annihilation_fractions(positron_annihilation_profile, sample)
64
+ >>> round(float(res.sum())) == 1.0
65
+ True
66
+ >>> round(float(res.sel(layer=-1)), 5) == 1e-4
67
+ True
68
+ """
69
+ # check input
70
+
71
+ if positron_profile.ndim != 1:
72
+ raise ValueError(f"positron_profile must be 1D, got {positron_profile.ndim}D")
73
+ depth_dim = positron_profile.dims[0] # infer axis name
74
+
75
+ profile_max_depth = positron_profile.coords[depth_dim].max().item()
76
+ sample_length = sample.sample_length()
77
+ if profile_max_depth > sample_length:
78
+ warn(
79
+ f"Positron profile extends to {profile_max_depth:.1f} nm but sample ends at "
80
+ f"{sample_length:.1f} nm. Annihilation beyond the sample boundary is ignored, "
81
+ f"fractions will not sum to 1 correctly. Consider extending the last layer."
82
+ )
83
+ # defs
84
+ layers = sample.layers
85
+ num_of_layers = len(layers)
86
+ layers_names = range(-1, num_of_layers)
87
+ annihilation_rate = np.zeros(num_of_layers+1) # layers and surface
88
+
89
+ # surface positrons annihilation rate
90
+ annihilation_rate[0] = (positron_profile.sel({depth_dim: 0.0}, method='nearest').item() *
91
+ sample.layers[0].material.diffusion / sample.absorption_length)
92
+
93
+ # layers positrons annihilation rates
94
+ for i, layer in enumerate(layers):
95
+ layer_positron_profile = positron_profile.sel({depth_dim: slice(layer.start, layer.start + layer.width)})
96
+ positron_fraction_in_layer = layer_positron_profile.integrate(depth_dim)
97
+ annihilation_rate[i+1] = layer.material.effective_annihilation_rate() * positron_fraction_in_layer.item()
98
+
99
+ # norm
100
+ annihilation_fractions = xr.DataArray(annihilation_rate, coords={'layer':layers_names})/annihilation_rate.sum()
101
+
102
+ return annihilation_fractions
103
+
104
+
105
+
@@ -0,0 +1,268 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ from uncertainties import ufloat
4
+ from uncertainties.unumpy import nominal_values, std_devs
5
+ from scipy.optimize import least_squares
6
+ from scipas.model import Sample, Material, Layer
7
+ from scipas.transport.diffusion import profile_solver
8
+ from scipas.analysis.vedb.annihilation_fractions import compute_annihilation_fractions
9
+
10
+
11
+ class DiffusionLengthOptimization:
12
+ """
13
+ Optimize positron diffusion lengths in a multilayer sample to best fit
14
+ experimentally measured lineshpae-parameter from variable-energy Doppler broadening (VEDB).
15
+ The parametrer can be S or W.
16
+
17
+ The optimization solves the simplified positron transport equation in layered samples:
18
+
19
+ d²c(z)/dz² − (1 / L_eff²) * c(z) = -I(z)
20
+
21
+ where:
22
+ - c(z) : positron concentration as a function of depth
23
+ - L_eff : effective diffusion length, L_eff = sqrt(D / λ)
24
+ - I(z) : positron implantation source term
25
+
26
+ For each trial set of diffusion lengths, the class:
27
+ 1. Constructs a normalized trial sample (D=1, λ = 1/L²).
28
+ 2. Solves the positron transport equation for each implantation energy.
29
+ 3. Computes annihilation fractions per layer and surface.
30
+ 4. Estimates S-parameters via linear least squares.
31
+ 5. Minimizes weighted residuals via nonlinear least squares.
32
+
33
+ The number of layers is inferred automatically from the initial guess sample,
34
+ making this class applicable to single-layer, two-layer, and three-layer models
35
+ without modification.
36
+
37
+ Parameters
38
+ ----------
39
+ positron_implantation_profiles : list of xr.DataArray
40
+ Depth profiles of implanted positrons for each beam energy [positrons/nm].
41
+ Each profile must be normalized and have a single depth coordinate.
42
+ s_measurement : pd.Series of uncertainties.ufloat or uarray
43
+ Measured S-parameters with uncertainties for each beam energy.
44
+ Index must correspond to the implantation profiles.
45
+ initial_guess : Sample
46
+ Initial sample defining layer geometry and material properties.
47
+ Used to extract layer widths and initial diffusion length estimates.
48
+ num_of_mesh_cells : int, optional
49
+ Number of mesh points for the transport equation solver. Default is 10000.
50
+ Higher values improve accuracy at the cost of computation time.
51
+
52
+ Attributes
53
+ ----------
54
+ n_layers : int
55
+ Number of layers, inferred from initial_guess.
56
+ s_measurement : np.ndarray
57
+ Nominal S-parameter values extracted from the ufloat series.
58
+ s_measurement_dev : np.ndarray
59
+ Standard deviations of the S-parameter measurements.
60
+
61
+ Notes
62
+ -----
63
+ The trial sample uses normalized units (D=1, absorption_length=1) since
64
+ only the diffusion length L = sqrt(D/λ) affects the annihilation profile shape.
65
+ The absolute values of D and absorption_length do not affect the optimization result.
66
+ """
67
+
68
+ def __init__(self,
69
+ positron_implantation_profiles: list,
70
+ s_measurement: pd.Series,
71
+ initial_guess: Sample,
72
+ num_of_mesh_cells: int = 10000):
73
+
74
+ self.positron_implantation_profiles = positron_implantation_profiles
75
+ self.initial_sample = initial_guess
76
+ self.s_measurement = nominal_values(s_measurement)
77
+ self.s_measurement_dev = std_devs(s_measurement)
78
+ self.num_of_mesh_cells = num_of_mesh_cells
79
+ self.n_layers = len(initial_guess.layers)
80
+
81
+ def make_sample(self, diffusion_lengths: list) -> Sample:
82
+ """
83
+ Construct a normalized trial sample for a given set of diffusion lengths.
84
+
85
+ Each layer is assigned D=1 and λ = 1/L² so that the effective diffusion
86
+ length matches the trial value. Layer geometry (start, width) is preserved
87
+ from the initial guess. The absorption length is set to 1 (normalized)
88
+ since it does not affect the diffusion length optimization.
89
+
90
+ Parameters
91
+ ----------
92
+ diffusion_lengths : list of float
93
+ Trial diffusion lengths [nm], one per layer.
94
+ Must have the same length as the number of layers in the initial sample.
95
+
96
+ Returns
97
+ -------
98
+ Sample
99
+ Normalized single- or multilayer sample with the given diffusion lengths.
100
+ """
101
+ layers = [
102
+ Layer(
103
+ start=self.initial_sample.layers[i].start,
104
+ width=self.initial_sample.layers[i].width,
105
+ material=Material(diffusion=1, mobility=0, bulk_annihilation_rate=1 / dl ** 2)
106
+ )
107
+ for i, dl in enumerate(diffusion_lengths)
108
+ ]
109
+ return Sample(layers=layers, absorption_length=1)
110
+
111
+ def layers_transport_solver(self, sample: Sample, implantation_profiles: list) -> np.ndarray:
112
+ """
113
+ Solve the positron transport equation for each implantation profile
114
+ and return the annihilation fraction matrix.
115
+
116
+ Parameters
117
+ ----------
118
+ sample : Sample
119
+ Trial sample for which the transport equation is solved.
120
+ implantation_profiles : list of xr.DataArray
121
+ Positron implantation profiles, one per beam energy.
122
+
123
+ Returns
124
+ -------
125
+ np.ndarray
126
+ Annihilation fraction matrix of shape (n_profiles, n_layers + 1).
127
+ Each row corresponds to one beam energy; columns are
128
+ [surface, layer_0, layer_1, ...].
129
+ """
130
+ frac_matrix = np.zeros((len(implantation_profiles), self.n_layers + 1))
131
+ for i, p in enumerate(implantation_profiles):
132
+ frac_matrix[i] = compute_annihilation_fractions(
133
+ positron_profile=profile_solver(p, sample, mesh_size=self.num_of_mesh_cells),
134
+ sample=sample).values
135
+ return frac_matrix
136
+
137
+ def layer_s_value(self, frac_matrix: np.ndarray) -> np.ndarray:
138
+ """
139
+ Estimate the S-parameter characteristic of each annihilation channel
140
+ via linear least squares.
141
+
142
+ Solves: frac_matrix @ s_layers ≈ s_measurement
143
+
144
+ Parameters
145
+ ----------
146
+ frac_matrix : np.ndarray
147
+ Annihilation fraction matrix of shape (n_profiles, n_channels).
148
+
149
+ Returns
150
+ -------
151
+ np.ndarray
152
+ Estimated S-parameter per annihilation channel [surface, layer_0, ...].
153
+ Length is n_layers + 1.
154
+ """
155
+ return np.linalg.lstsq(frac_matrix, self.s_measurement, rcond=None)[0]
156
+
157
+ def residuals(self, diffusion_lengths: np.ndarray) -> np.ndarray:
158
+ """
159
+ Compute weighted residuals between measured and modeled S-parameters.
160
+
161
+ For a given trial set of diffusion lengths, solves the transport equation,
162
+ estimates S-parameters per layer, and returns the normalized difference:
163
+
164
+ residuals = (S_calc - S_measured) / sigma_measured
165
+
166
+ If any estimated S-parameter per layer is unphysical (negative or >= 1),
167
+ returns a large residual vector to penalize the optimizer.
168
+
169
+ Parameters
170
+ ----------
171
+ diffusion_lengths : np.ndarray
172
+ Trial diffusion lengths [nm], one per layer.
173
+
174
+ Returns
175
+ -------
176
+ np.ndarray
177
+ Weighted residual vector of length n_profiles.
178
+ """
179
+ sample = self.make_sample(diffusion_lengths)
180
+ frac_matrix = self.layers_transport_solver(sample, self.positron_implantation_profiles)
181
+ s_vec = self.layer_s_value(frac_matrix)
182
+ s_calc = frac_matrix @ s_vec
183
+ if np.any(s_vec[1:] >= 1) or np.any(s_vec <= 0):
184
+ return np.full_like(s_calc, 1e6)
185
+ return (s_calc - self.s_measurement) / self.s_measurement_dev
186
+
187
+ def extract_fit_results(self, ls_results) -> list:
188
+ """
189
+ Extract best-fit parameters and full covariance matrix from least-squares result.
190
+
191
+ The covariance matrix is estimated from the Jacobian via the Gauss-Newton
192
+ approximation using SVD:
193
+
194
+ J ≈ U S Vᵀ → cov ≈ Vᵀ diag(1/s²) V
195
+
196
+ Singular values below numerical precision are discarded to regularize
197
+ the inversion.
198
+ Parameters
199
+ ----------
200
+ ls_results : OptimizeResult
201
+ Result from `scipy.optimize.least_squares`. Must contain
202
+ `jac` (Jacobian at solution) and `x` (best-fit parameters).
203
+
204
+ Returns
205
+ -------
206
+ best_fit : np.ndarray
207
+ Best-fit diffusion lengths [nm], shape (n_layers,).
208
+ cov : np.ndarray
209
+ Full covariance matrix, shape (n_layers, n_layers).
210
+ Diagonal entries are variances; off-diagonal entries capture
211
+ parameter correlations and should not be neglected for
212
+ correlated layers.
213
+ """
214
+
215
+ J = ls_results.jac
216
+ _, s, VT = np.linalg.svd(J, full_matrices=False)
217
+ threshold = np.finfo(float).eps * max(J.shape) * s[0]
218
+ s = s[s > threshold]
219
+ VT = VT[:s.size]
220
+ cov = np.dot(VT.T / s ** 2, VT)
221
+ return ls_results.x, cov
222
+
223
+ def optimize_diffusion_length(self, bounds=None) -> tuple:
224
+ """
225
+ Optimize diffusion lengths for all layers simultaneously.
226
+
227
+ Performs nonlinear least-squares minimization of the weighted residuals
228
+ between measured and modeled S-parameters. Initial guesses are derived
229
+ from the material properties of the initial sample.
230
+
231
+ Parameters
232
+ ----------
233
+ bounds : tuple of (float, float), optional
234
+ (lower, upper) bounds applied equally to all diffusion lengths [nm].
235
+ Must satisfy 0 <= lower <= upper. Default is (0, 1000).
236
+
237
+ Returns
238
+ -------
239
+ best_fit : np.ndarray
240
+ Best-fit diffusion lengths [nm], shape (n_layers,).
241
+ Ordered as [layer_0, layer_1, ...].
242
+ cov : np.ndarray
243
+ Full covariance matrix of shape (n_layers, n_layers).
244
+ Diagonal entries are variances; off-diagonal entries capture
245
+ parameter correlations. Marginal uncertainties can be obtained
246
+ via np.sqrt(np.diag(cov)).
247
+
248
+ Raises
249
+ ------
250
+ ValueError
251
+ If lower bound exceeds upper bound.
252
+ """
253
+ initial_guess = [
254
+ (m.diffusion / m.effective_annihilation_rate()) ** 0.5
255
+ for m in (layer.material for layer in self.initial_sample.layers)
256
+ ]
257
+
258
+ if bounds is None:
259
+ bounds = (0, 1000)
260
+ elif not 0 <= bounds[0] <= bounds[1]:
261
+ raise ValueError("lower bound must be less than upper bound")
262
+
263
+ lb = [bounds[0]] * self.n_layers
264
+ ub = [bounds[1]] * self.n_layers
265
+
266
+ ls_result = least_squares(fun=self.residuals, x0=initial_guess, bounds=(lb, ub))
267
+
268
+ return self.extract_fit_results(ls_result)
@@ -0,0 +1,167 @@
1
+ import pandas as pd
2
+ from scipas.core.const import ELECTRON_REST_MASS_KEV
3
+
4
+
5
+ def compute_s_lineshape(
6
+ db_spectra: list,
7
+ energies,
8
+ energy_domain_total,
9
+ energy_domain_s,
10
+ centralize: bool = True,
11
+ center_value: float = ELECTRON_REST_MASS_KEV,
12
+ ) -> pd.Series:
13
+ """
14
+ Compute S-parameter vs. positron beam energy from a series of DB spectra.
15
+
16
+ Parameters
17
+ ----------
18
+ db_spectra : list of DB
19
+ DB spectra, one per beam energy, in the same order as ``energies``.
20
+ energies : array-like
21
+ Positron beam energies [keV], one per spectrum.
22
+ energy_domain_total : iterable of float
23
+ (e_low, e_high) — full integration window for normalization [keV].
24
+ energy_domain_s : iterable of float
25
+ (e_low, e_high) — central window for the S parameter [keV].
26
+ centralize : bool, optional
27
+ If True (default), recenters each spectrum's axis calibration so the
28
+ annihilation peak aligns with ``center_value`` before computing.
29
+ Modifies each DB's axis calibration in place.
30
+ center_value : float, optional
31
+ Target energy for peak centralization [keV].
32
+ Defaults to the electron rest mass energy (``ELECTRON_REST_MASS_KEV``).
33
+ Use 0.0 for CDB or momentum spectra expressed as energy shifts.
34
+
35
+ Returns
36
+ -------
37
+ pd.Series of uncertainties.ufloat
38
+ S parameter vs. beam energy, indexed by ``energies``, named ``'S'``.
39
+
40
+ Raises
41
+ ------
42
+ ValueError
43
+ If ``db_spectra`` and ``energies`` have different lengths.
44
+
45
+ Examples
46
+ --------
47
+ >>> import numpy as np
48
+ >>> from scispectrum import Spectrum, AxisCalibration, ResolutionCalibration
49
+ >>> from scipas.core.db import DB
50
+ >>> from scipas.analysis.vedb.lineshape import compute_s_lineshape
51
+ >>> def make_db(center):
52
+ ... bins = np.linspace(center - 10, center + 10, 200)
53
+ ... ax = (bins[:-1] + bins[1:]) / 2
54
+ ... counts = np.round(1e4 * np.exp(-0.5 * ((ax - center) / 1.5) ** 2) + 50).astype(int)
55
+ ... spec = Spectrum(counts=counts, counts_err=np.sqrt(counts),
56
+ ... axis_calib=AxisCalibration.from_array(ax),
57
+ ... resolution_calib=ResolutionCalibration(lambda e: 3.0))
58
+ ... return DB.from_spectrum(spec)
59
+ >>> db_list = [make_db(511.0), make_db(510.8)]
60
+ >>> s = compute_s_lineshape(db_list, [5.0, 10.0],
61
+ ... energy_domain_total=[507.0, 515.0],
62
+ ... energy_domain_s=[510.2, 511.8])
63
+ >>> list(s.index) == [5.0, 10.0]
64
+ True
65
+ >>> all(0 < float(v.nominal_value) < 1 for v in s)
66
+ True
67
+ """
68
+ if len(db_spectra) != len(energies):
69
+ raise ValueError(
70
+ f"db_spectra and energies must have the same length "
71
+ f"(got {len(db_spectra)} spectra and {len(energies)} energies)."
72
+ )
73
+
74
+ s_values = []
75
+ for db in db_spectra:
76
+ if centralize:
77
+ db.recenter(center_value)
78
+ s_values.append(db.s_parameter_calculation(energy_domain_total, energy_domain_s))
79
+
80
+ s = pd.Series(s_values, index=list(energies), name='S')
81
+ s.index.name = 'energy'
82
+ return s
83
+
84
+
85
+ def compute_w_lineshape(
86
+ db_spectra: list,
87
+ energies,
88
+ energy_domain_total,
89
+ energy_domain_w_left,
90
+ energy_domain_w_right,
91
+ centralize: bool = True,
92
+ center_value: float = ELECTRON_REST_MASS_KEV,
93
+ ) -> pd.Series:
94
+ """
95
+ Compute W-parameter vs. positron beam energy from a series of DB spectra.
96
+
97
+ Parameters
98
+ ----------
99
+ db_spectra : list of DB
100
+ DB spectra, one per beam energy, in the same order as ``energies``.
101
+ energies : array-like
102
+ Positron beam energies [keV], one per spectrum.
103
+ energy_domain_total : iterable of float
104
+ (e_low, e_high) — full integration window for normalization [keV].
105
+ energy_domain_w_left : iterable of float
106
+ (e_low, e_high) — left wing window for the W parameter [keV].
107
+ energy_domain_w_right : iterable of float
108
+ (e_low, e_high) — right wing window for the W parameter [keV].
109
+ centralize : bool, optional
110
+ If True (default), recenters each spectrum's axis calibration so the
111
+ annihilation peak aligns with ``center_value`` before computing.
112
+ Modifies each DB's axis calibration in place.
113
+ center_value : float, optional
114
+ Target energy for peak centralization [keV].
115
+ Defaults to the electron rest mass energy (``ELECTRON_REST_MASS_KEV``).
116
+ Use 0.0 for CDB or momentum spectra expressed as energy shifts.
117
+
118
+ Returns
119
+ -------
120
+ pd.Series of uncertainties.ufloat
121
+ W parameter vs. beam energy, indexed by ``energies``, named ``'W'``.
122
+
123
+ Raises
124
+ ------
125
+ ValueError
126
+ If ``db_spectra`` and ``energies`` have different lengths.
127
+
128
+ Examples
129
+ --------
130
+ >>> import numpy as np
131
+ >>> from scispectrum import Spectrum, AxisCalibration, ResolutionCalibration
132
+ >>> from scipas.core.db import DB
133
+ >>> from scipas.analysis.vedb.lineshape import compute_w_lineshape
134
+ >>> def make_db(center):
135
+ ... bins = np.linspace(center - 10, center + 10, 200)
136
+ ... ax = (bins[:-1] + bins[1:]) / 2
137
+ ... counts = np.round(1e4 * np.exp(-0.5 * ((ax - center) / 1.5) ** 2) + 50).astype(int)
138
+ ... spec = Spectrum(counts=counts, counts_err=np.sqrt(counts),
139
+ ... axis_calib=AxisCalibration.from_array(ax),
140
+ ... resolution_calib=ResolutionCalibration(lambda e: 3.0))
141
+ ... return DB.from_spectrum(spec)
142
+ >>> db_list = [make_db(511.0), make_db(510.8)]
143
+ >>> w = compute_w_lineshape(db_list, [5.0, 10.0],
144
+ ... energy_domain_total=[507.0, 515.0],
145
+ ... energy_domain_w_left=[507.5, 509.3],
146
+ ... energy_domain_w_right=[512.7, 514.5])
147
+ >>> list(w.index) == [5.0, 10.0]
148
+ True
149
+ >>> all(0 < float(v.nominal_value) < 1 for v in w)
150
+ True
151
+ """
152
+ if len(db_spectra) != len(energies):
153
+ raise ValueError(
154
+ f"db_spectra and energies must have the same length "
155
+ f"(got {len(db_spectra)} spectra and {len(energies)} energies)."
156
+ )
157
+
158
+ w_values = []
159
+ for db in db_spectra:
160
+ if centralize:
161
+ db.recenter(center_value)
162
+ w_values.append(db.w_parameter_calculation(
163
+ energy_domain_total, energy_domain_w_left, energy_domain_w_right))
164
+
165
+ w = pd.Series(w_values, index=list(energies), name='W')
166
+ w.index.name = 'energy'
167
+ return w
@@ -0,0 +1,86 @@
1
+ import numpy as np
2
+ import xarray as xr
3
+ from scipas.transport.implantation import multilayer_implantation_profile, makhov_profile
4
+
5
+
6
+ def variable_energy_implantation_profiles(
7
+ energies,
8
+ depth_vector: np.ndarray,
9
+ materials_parameters: list,
10
+ densities: list,
11
+ widths: list,
12
+ implantation_profile_function=makhov_profile,
13
+ ) -> list:
14
+ """
15
+ Compute positron implantation profiles for a series of beam energies.
16
+
17
+ Wraps ``multilayer_implantation_profile`` in a loop over ``energies``,
18
+ returning one profile per energy. Handles both single-layer (homogeneous
19
+ substrate) and multilayer (thin film on substrate) geometries.
20
+
21
+ Parameters
22
+ ----------
23
+ energies : array-like
24
+ Positron beam energies [keV] for which to compute implantation profiles.
25
+ depth_vector : np.ndarray
26
+ 1D depth grid [nm] spanning the full sample, e.g.
27
+ ``np.linspace(0, sample.sample_length(), 10000)``.
28
+ materials_parameters : list of pd.Series
29
+ Material parameters for each layer, one entry per layer.
30
+ Obtain via ``makhov_material_parameters()`` or ``ghosh_material_parameters()``.
31
+ For a homogeneous substrate pass a single-element list.
32
+ densities : list of float
33
+ Actual material density [g/cm³] for each layer.
34
+ Must be the same length as ``materials_parameters``.
35
+ widths : list of float
36
+ Width of each layer [nm], from surface to substrate.
37
+ If ``depth_vector[-1]`` exceeds ``sum(widths)``, the last layer is
38
+ extended to fill the remaining depth (semi-infinite substrate approximation).
39
+ For a homogeneous substrate pass ``[sample_length]``.
40
+ implantation_profile_function : callable, optional
41
+ Profile function to use for each layer.
42
+ Must have the signature
43
+ ``f(positron_energy, depth_vector, density, material_params) -> xr.DataArray``.
44
+ Defaults to ``makhov_profile``.
45
+
46
+ Returns
47
+ -------
48
+ list of xr.DataArray
49
+ Implantation profiles, one per entry in ``energies``, each with
50
+ coordinate ``'x'`` in nm and units of positrons/nm (normalised so
51
+ the integral over depth equals 1).
52
+
53
+ Examples
54
+ --------
55
+ >>> import numpy as np
56
+ >>> from scipas.transport import makhov_material_parameters, makhov_profile
57
+ >>> from scipas.analysis.vedb.ve_implanation import variable_energy_implantation_profiles
58
+ >>> params = makhov_material_parameters()
59
+ >>> cu = params[params['Material'] == 'Cu'].iloc[0]
60
+ >>> depth = np.linspace(0, 10000, 5000)
61
+ >>> profiles = variable_energy_implantation_profiles(
62
+ ... energies=[5.0, 10.0, 20.0],
63
+ ... depth_vector=depth,
64
+ ... materials_parameters=[cu],
65
+ ... densities=[cu.density],
66
+ ... widths=[10000],
67
+ ... )
68
+ >>> len(profiles)
69
+ 3
70
+ >>> all(isinstance(p, xr.DataArray) for p in profiles)
71
+ True
72
+ >>> all('x' in p.coords for p in profiles)
73
+ True
74
+ """
75
+ profiles = []
76
+ for energy in energies:
77
+ profile = multilayer_implantation_profile(
78
+ positron_energy=float(energy),
79
+ depth_vector=depth_vector,
80
+ widths=list(widths),
81
+ materials_parameters=list(materials_parameters),
82
+ densities=list(densities),
83
+ implantation_profile_function=implantation_profile_function,
84
+ )
85
+ profiles.append(profile)
86
+ return profiles
@@ -0,0 +1,4 @@
1
+ """Doppler Broadening peak analysis"""
2
+ from .db import DB
3
+ from .cdb import CDB
4
+ from .const import ELECTRON_REST_MASS_KEV