Coverage for dynasor/post_processing/neutron_scattering_lengths.py: 100%
64 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-10 08:27 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-10 08:27 +0000
1import json
2from importlib.resources import files
3from typing import Optional
5import numpy as np
6from pandas import DataFrame
7from .weights import Weights
10class NeutronScatteringLengths(Weights):
11 """This class provides sample weights corresponding to neutron scattering lengths.
12 By default, the coherent and incoherent scattering lengths are weighted by the natural
13 abundance of each isotope of the considered atomic species.
14 This weighting can be overwritten using the :attr:`abundances` argument.
16 The scattering lengths have been extracted from `this NIST
17 database <https://www.ncnr.nist.gov/resources/n-lengths/list.html>`__,
18 which in turn have been taken from Table 1 of Neutron News **3**, 26 (1992);
19 `doi: 10.1080/10448639208218770 <https://doi.org/10.1080/10448639208218770>`_.
21 Parameters
22 ----------
23 atom_types
24 List of atomic species for which to retrieve scattering lengths.
25 abundances
26 Dict of the desired fractional abundance of each isotope for
27 each species in the sample. For example, to use an equal
28 weighting of all isotopes of oxygen, one can write
29 ``abundances['O'] = {16: 1/3, 17: 1/3, 18: 1/3}``. Note that
30 the abundance for any isotopes that are *not* included in this
31 dict is automatically set to zero. In other words, you need to
32 ensure that the abundances provided sum up to 1. By default
33 the neutron scattering lengths are weighted proportionally to
34 the natural abundance of each isotope.
35 """
37 def __init__(
38 self,
39 atom_types: list[str],
40 abundances: Optional[dict[str, dict[int, float]]] = None,
41 ):
42 scat_lengths = _read_scattering_lengths()
44 # Sub select only the relevant species
45 scat_lengths = scat_lengths[scat_lengths.species.isin(atom_types)].reset_index()
47 for species in atom_types:
48 if not np.any(scat_lengths.species == species):
49 raise ValueError('Missing tabulated values '
50 f'for requested species {species}.')
52 # Update the abundances if another weighting is desired
53 if abundances is not None:
54 for species in abundances:
55 scat_lengths.loc[scat_lengths.species == species, 'abundance'] = 0
56 for Z, frac in abundances[species].items():
57 if not np.isfinite(frac) or not (0.0 <= frac <= 1.0):
58 raise ValueError(f'Abundance fraction for {species} isotope {Z} '
59 f'must be finite and between 0 and 1 (got {frac}).')
60 match = (scat_lengths.species == species) & (scat_lengths.isotope == Z)
61 if not np.any(match): # Check if any row+column matches
62 raise ValueError(f'No match in database for {species} and isotope {Z}')
63 scat_lengths.loc[match, 'abundance'] = frac
65 self._scattering_lengths = scat_lengths
67 # Check if any of the fetched scattering lengths is None,
68 # indicating that it is missing in the experimental database.
69 # Only raise an error if the desired abundance is greater than 0.
70 nan_rows = scat_lengths[scat_lengths.isnull().any(axis=1) & (scat_lengths.abundance > 0.0)]
71 if not nan_rows.empty:
72 # Grab first offending entry
73 row = nan_rows.iloc[0]
74 raise ValueError(f'{row.isotope}{row.species} is missing tabulated values for either'
75 ' the coherent or incoherent scattering length.'
76 ' Adjust the abundance parameter to set the fraction of'
77 f' {row.isotope}{row.species} to zero.')
79 # Make sure abundances add up to 100%
80 by_species = self._scattering_lengths.groupby('species')
81 for species, species_df in by_species:
82 if not np.isclose(species_df.abundance.sum(), 1):
83 raise ValueError(f'Abundance values for {species} do not sum up to 1.0')
85 # Compute scattering lengths weighted by abundance
86 weights_coh = by_species.apply(
87 lambda s: (s.b_coh * s.abundance).sum(),
88 include_groups=False
89 ).to_dict()
90 # First compute the average scattering length, then take the square
91 # since the incoherent scattering lengths enter as b_incoh**2, but
92 # dynasor only applies a single weighting factor w_incoh.
93 weights_inc = by_species.apply(
94 lambda s: (s.b_inc * s.abundance).sum(),
95 include_groups=False
96 ).to_dict()
98 supports_currents = False
99 super().__init__(weights_coh, weights_inc, supports_currents)
101 @property
102 def abundances(self) -> dict[str, dict[int, float]]:
103 """Abundances used for calculating scattering lengths."""
104 abundance_dict = {}
105 for (species), species_df in self._scattering_lengths.groupby('species'):
106 abundance_dict[species] = {}
107 for (isotope, abundance), _ in species_df.groupby(['isotope', 'abundance']):
108 abundance_dict[species][isotope] = abundance
109 return abundance_dict
111 @property
112 def parameters(self) -> DataFrame:
113 """Scattering lengths used to compute the coherent and
114 incoherent weights for the selected isotopes.
115 """
116 return self._scattering_lengths
119def _read_scattering_lengths() -> DataFrame:
120 """
121 Extracts the scattering lengths from the file `neutron-scattering-lengths-nist-1992.json`
122 for each of the supplied species. Scattering lengths are in units of fm.
124 The scattering lengths have been extracted from the following NIST
125 database: https://www.ncnr.nist.gov/resources/n-lengths/list.html,
126 which in turn have been extracted from
127 Neutron News **3**, No. 3***, 26 (1992).
128 """
129 data_file = files(__package__) / 'form-factors/neutron-scattering-lengths-nist-1992.json'
130 with open(data_file) as fp:
131 scattering_lengths = json.load(fp)
133 data = []
134 for species in scattering_lengths:
135 for isotope in scattering_lengths[species]:
136 for fld in 'b_coh b_inc'.split():
137 val = scattering_lengths[species][isotope][fld]
138 if 'None' in val:
139 scattering_lengths[species][isotope][fld] = np.nan
140 elif 'j' in val:
141 scattering_lengths[species][isotope][fld] = complex(val)
142 else:
143 scattering_lengths[species][isotope][fld] = float(val)
144 data.append(
145 dict(
146 species=species,
147 isotope=int(isotope),
148 abundance=float(scattering_lengths[species][isotope]['abundance'])
149 / 100, # % -> fraction
150 b_coh=complex(scattering_lengths[species][isotope]['b_coh']),
151 b_inc=complex(scattering_lengths[species][isotope]['b_inc']),
152 )
153 )
154 scattering_lengths = DataFrame.from_dict(data)
155 return scattering_lengths