Skip to content

Commit cf03e1e

Browse files
authored
Merge pull request #613 from scipp/atomic-weights
Add atomic weights and masses
2 parents d4bf359 + ab4409b commit cf03e1e

File tree

5 files changed

+4103
-39
lines changed

5 files changed

+4103
-39
lines changed

src/scippneutron/atoms/__init__.py

Lines changed: 149 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,98 @@
11
# SPDX-License-Identifier: BSD-3-Clause
2-
# Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
3-
"""Parameters for neutron interactions with atoms."""
2+
# Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
3+
"""Parameters of atoms and neutron interactions with atoms."""
44

55
from __future__ import annotations
66

77
import dataclasses
88
import importlib.resources
9+
import re
910
from functools import lru_cache
1011
from typing import TextIO
1112

1213
import scipp as sc
1314

1415

16+
@dataclasses.dataclass(frozen=True, eq=False)
17+
class Atom:
18+
"""Atomic parameters of a specific element / isotope.
19+
20+
Values have been retrieved at 2025-03-20T15:11 from the lists at
21+
https://www.ciaaw.org/atomic-weights.htm
22+
23+
Atomic weights are properties of an *element* while atomic masses
24+
are properties of a specific *isotope* (nuclide).
25+
The reported atomic weights are the abridged standard
26+
atomic weight for each element.
27+
The reported atomic masses are the most recent value for each isotope
28+
(at the above date).
29+
"""
30+
31+
isotope: str
32+
z: int | None
33+
_atomic_weight: sc.Variable | None
34+
_atomic_mass: sc.Variable | None
35+
36+
@property
37+
def atomic_weight(self) -> sc.Variable:
38+
"""Return the atomic weight is available."""
39+
if self._atomic_weight is None:
40+
raise ValueError(
41+
f"Atomic weight for '{self.isotope}' is not defined ."
42+
"This likely means that there is no standard atomic weight "
43+
"for this element."
44+
)
45+
return self._atomic_weight.copy()
46+
47+
@property
48+
def atomic_mass(self) -> sc.Variable:
49+
"""Return the atomic mass is available."""
50+
if self._atomic_mass is None:
51+
raise ValueError(
52+
f"Atomic mass for '{self.isotope}' is not defined ."
53+
"This likely means that you specified an element name, not a specific "
54+
"isotope; atomic masses are only defined for isotopes / nuclides."
55+
)
56+
return self._atomic_mass.copy()
57+
58+
def __eq__(self, other: object) -> bool | type(NotImplemented):
59+
if not isinstance(other, Atom):
60+
return NotImplemented
61+
return all(
62+
_eq_or_identical(getattr(self, field.name), getattr(other, field.name))
63+
for field in dataclasses.fields(self)
64+
)
65+
66+
@staticmethod
67+
@lru_cache
68+
def for_isotope(isotope: str) -> Atom:
69+
"""Return the atom parameters for the given element / isotope.
70+
71+
Parameters
72+
----------
73+
isotope:
74+
Name of the element or isotope.
75+
For example, 'H', '3He', 'V', '50V'.
76+
77+
Returns
78+
-------
79+
:
80+
Atom parameters.
81+
"""
82+
element = _parse_isotope_name(isotope)
83+
z, weight = _load_atomic_weight(element)
84+
if element == isotope:
85+
mass = None # masses are only defined for specific isotopes
86+
else:
87+
mass = _load_atomic_mass(isotope)
88+
return Atom(
89+
isotope=isotope,
90+
z=z,
91+
_atomic_weight=weight,
92+
_atomic_mass=mass,
93+
)
94+
95+
1596
def reference_wavelength() -> sc.Variable:
1697
"""Return the reference wavelength for absorption cross-sections.
1798
@@ -62,9 +143,7 @@ def __eq__(self, other: object) -> bool | type(NotImplemented):
62143
if not isinstance(other, ScatteringParams):
63144
return NotImplemented
64145
return all(
65-
self.isotope == other.isotope
66-
if field.name == 'isotope'
67-
else _eq_or_identical(getattr(self, field.name), getattr(other, field.name))
146+
_eq_or_identical(getattr(self, field.name), getattr(other, field.name))
68147
for field in dataclasses.fields(self)
69148
)
70149

@@ -84,37 +163,65 @@ def for_isotope(isotope: str) -> ScatteringParams:
84163
:
85164
Neutron scattering parameters.
86165
"""
87-
with _open_scattering_parameters_file() as f:
88-
while line := f.readline():
89-
name, rest = line.split(',', 1)
90-
if name == isotope:
91-
return _parse_line(isotope, rest)
166+
with _open_bundled_parameters_file('scattering_parameters.csv') as f:
167+
if line_remainder := _find_line_with_isotope(isotope, f):
168+
return ScatteringParams._parse_line(isotope, line_remainder)
92169
raise ValueError(f"No entry for element / isotope '{isotope}'")
93170

171+
@staticmethod
172+
def _parse_line(isotope: str, line: str) -> ScatteringParams:
173+
line = line.rstrip().split(',')
174+
return ScatteringParams(
175+
isotope=isotope,
176+
coherent_scattering_length_re=_assemble_scalar(line[0], line[1], 'fm'),
177+
coherent_scattering_length_im=_assemble_scalar(line[2], line[3], 'fm'),
178+
incoherent_scattering_length_re=_assemble_scalar(line[4], line[5], 'fm'),
179+
incoherent_scattering_length_im=_assemble_scalar(line[6], line[7], 'fm'),
180+
coherent_scattering_cross_section=_assemble_scalar(
181+
line[8], line[9], 'barn'
182+
),
183+
incoherent_scattering_cross_section=_assemble_scalar(
184+
line[10], line[11], 'barn'
185+
),
186+
total_scattering_cross_section=_assemble_scalar(line[12], line[13], 'barn'),
187+
absorption_cross_section=_assemble_scalar(line[14], line[15], 'barn'),
188+
)
189+
190+
191+
def _open_bundled_parameters_file(name: str) -> TextIO:
192+
return importlib.resources.files('scippneutron.atoms').joinpath(name).open('r')
193+
94194

95-
def _open_scattering_parameters_file() -> TextIO:
96-
return (
97-
importlib.resources.files('scippneutron.atoms')
98-
.joinpath('scattering_parameters.csv')
99-
.open('r')
100-
)
195+
def _find_line_with_isotope(isotope: str, io: TextIO) -> str | None:
196+
while line := io.readline():
197+
name, rest = line.split(',', 1)
198+
if name == isotope:
199+
return rest
200+
return None
101201

102202

103-
def _parse_line(isotope: str, line: str) -> ScatteringParams:
104-
line = line.rstrip().split(',')
105-
return ScatteringParams(
106-
isotope=isotope,
107-
coherent_scattering_length_re=_assemble_scalar(line[0], line[1], 'fm'),
108-
coherent_scattering_length_im=_assemble_scalar(line[2], line[3], 'fm'),
109-
incoherent_scattering_length_re=_assemble_scalar(line[4], line[5], 'fm'),
110-
incoherent_scattering_length_im=_assemble_scalar(line[6], line[7], 'fm'),
111-
coherent_scattering_cross_section=_assemble_scalar(line[8], line[9], 'barn'),
112-
incoherent_scattering_cross_section=_assemble_scalar(
113-
line[10], line[11], 'barn'
114-
),
115-
total_scattering_cross_section=_assemble_scalar(line[12], line[13], 'barn'),
116-
absorption_cross_section=_assemble_scalar(line[14], line[15], 'barn'),
117-
)
203+
def _load_atomic_weight(element: str) -> tuple[int, sc.Variable | None]:
204+
# The CSV file was extracted from https://www.ciaaw.org/abridged-atomic-weights.htm
205+
# using the notebook in tools/atomic_weights.ipynb (in the ScippNeutron repo).
206+
with _open_bundled_parameters_file('atomic_weights.csv') as f:
207+
f.readline() # skip copyright
208+
f.readline() # skip header
209+
if line_remainder := _find_line_with_isotope(element, f):
210+
z, weight, error = line_remainder.rstrip().split(',')
211+
return int(z), _assemble_scalar(weight, error, 'Da')
212+
raise ValueError(f"No entry for element '{element}'")
213+
214+
215+
def _load_atomic_mass(isotope: str) -> sc.Variable | None:
216+
# The CSV file was extracted from https://www.ciaaw.org/atomic-masses.htm
217+
# using the notebook in tools/atomic_weights.ipynb (in the ScippNeutron repo).
218+
with _open_bundled_parameters_file('atomic_masses.csv') as f:
219+
f.readline() # skip copyright
220+
f.readline() # skip header
221+
if line_remainder := _find_line_with_isotope(isotope, f):
222+
weight, error = line_remainder.rstrip().split(',')
223+
return _assemble_scalar(weight, error, 'Da')
224+
raise ValueError(f"No entry for element / isotope '{isotope}'")
118225

119226

120227
def _assemble_scalar(value: str, std: str, unit: str) -> sc.Variable | None:
@@ -125,7 +232,14 @@ def _assemble_scalar(value: str, std: str, unit: str) -> sc.Variable | None:
125232
return sc.scalar(value, variance=variance, unit=unit)
126233

127234

128-
def _eq_or_identical(a: sc.Variable | None, b: sc.Variable | None) -> bool:
129-
if a is None:
130-
return b is None
131-
return sc.identical(a, b)
235+
def _eq_or_identical(a: object, b: object) -> bool:
236+
if isinstance(a, sc.Variable) or isinstance(b, sc.Variable):
237+
return sc.identical(a, b)
238+
return a == b
239+
240+
241+
def _parse_isotope_name(name: str) -> str:
242+
# Extract the element name from an isotope name.
243+
# 'H' -> 'H'
244+
# '2H' -> 'H'
245+
return re.match(r'(?:\d+)?([a-zA-Z]+)', name)[1]

0 commit comments

Comments
 (0)