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
55from __future__ import annotations
66
77import dataclasses
88import importlib .resources
9+ import re
910from functools import lru_cache
1011from typing import TextIO
1112
1213import 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+
1596def 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
120227def _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