refjax 0.1.1__tar.gz
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.
- refjax-0.1.1/LICENSE +21 -0
- refjax-0.1.1/PKG-INFO +186 -0
- refjax-0.1.1/README.md +155 -0
- refjax-0.1.1/pyproject.toml +45 -0
- refjax-0.1.1/setup.cfg +4 -0
- refjax-0.1.1/src/refjax/__init__.py +9 -0
- refjax-0.1.1/src/refjax/model.py +112 -0
- refjax-0.1.1/src/refjax/smear.py +34 -0
- refjax-0.1.1/src/refjax/tools.py +25 -0
- refjax-0.1.1/src/refjax.egg-info/PKG-INFO +186 -0
- refjax-0.1.1/src/refjax.egg-info/SOURCES.txt +14 -0
- refjax-0.1.1/src/refjax.egg-info/dependency_links.txt +1 -0
- refjax-0.1.1/src/refjax.egg-info/requires.txt +8 -0
- refjax-0.1.1/src/refjax.egg-info/top_level.txt +1 -0
- refjax-0.1.1/test/test_ellips.py +90 -0
- refjax-0.1.1/test/test_refjax.py +70 -0
refjax-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Max Champneys
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
refjax-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: refjax
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Lightweight `jit` and `grad` compatible reflectometry in jax.
|
|
5
|
+
Author: Stephanie L. Burg
|
|
6
|
+
Author-email: "Max D. Champneys" <max.champneys@sheffield.ac.uk>
|
|
7
|
+
Maintainer-email: Max Champneys <max.champneys@sheffield.ac.uk>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Project-URL: Repository, https://github.com/MDCHAMP/refjax
|
|
10
|
+
Project-URL: Issues, https://github.com/MDCHAMP/refjax/issues
|
|
11
|
+
Project-URL: Paper, https://doi.org/10.1038/s43246-026-01341-4
|
|
12
|
+
Keywords: Reflectometry,Neutron reflectometry,jax,autograd
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Development Status :: 4 - Beta
|
|
18
|
+
Classifier: Intended Audience :: Science/Research
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: numpy>=2.0
|
|
24
|
+
Requires-Dist: jax>=0.4.26
|
|
25
|
+
Requires-Dist: jaxlib>=0.4.26
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
28
|
+
Requires-Dist: pandas>=2.2.2; extra == "test"
|
|
29
|
+
Requires-Dist: refnx>=0.1.45; extra == "test"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# Refjax - Lightweight `jit` and `grad` compatible reflectometry in jax
|
|
33
|
+
|
|
34
|
+
This package provides lightweight, low-level access to neutron reflectometry and spectroscopic ellipsometry kernels in jax.
|
|
35
|
+
|
|
36
|
+
The provided kernels are all `jit` and `grad` compatible and easy to integrate with your favourite optimisation or uncertainty quantification library for fast parameterisation of stratified reflectometry models. Several examples for popular libraries are available in the [`examples`](https://github.com/MDCHAMP/refjax/tree/main/examples) folder.
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
From PyPI:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install refjax
|
|
45
|
+
# or
|
|
46
|
+
uv add refjax
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or from GitHub, pinned to the 0.1.1 release by its tag:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install "refjax @ git+https://github.com/MDCHAMP/refjax@v0.1.1"
|
|
53
|
+
# or
|
|
54
|
+
uv add git+https://github.com/MDCHAMP/refjax --tag v0.1.1
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
refjax needs Python >= 3.11, `jax` and `jaxlib` >= 0.4.26 and `numpy` >= 2.0. The test suite runs in CI at these minimum versions and at the latest releases.
|
|
58
|
+
|
|
59
|
+
### 64-bit precision is on by default
|
|
60
|
+
|
|
61
|
+
`import refjax` switches on jax's 64-bit mode (`jax.config.update("jax_enable_x64", True)`), so refjax computes in float64 and complex128. This setting is global: it applies to all jax code in the same Python process, not only to refjax. To work in 32-bit, switch it off after importing refjax, before creating any arrays: `jax.config.update("jax_enable_x64", False)`. refjax then computes in float32 and complex64; its tests run only in 64-bit.
|
|
62
|
+
|
|
63
|
+
## Neutron reflectometry
|
|
64
|
+
|
|
65
|
+
### Design pattern
|
|
66
|
+
|
|
67
|
+
Reflectometry models are created by creating reflectivity kernels with `refjax.kernel`:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
kernel = refjax.kernel(model, smear=refjax.smear.no_smear, smear_params=None, jit=True)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Currently implemented models:
|
|
74
|
+
- `refjax.model.abeles` Abeles matrix formalism for stratified media
|
|
75
|
+
|
|
76
|
+
Currently implemented smearing kernels:
|
|
77
|
+
- `refjax.smear.no_smear` No smearing kernel (default)
|
|
78
|
+
- `refjax.smear.constant_gaussian` Constant gaussian smearing at every Q value (the third argument is the FWHM as a percentage of Q)
|
|
79
|
+
- `refjax.smear.pointwise_gaussian` Pointwise Gaussian approximation to the true smearing kernel (requires FWHM resolution measurements), by 17th-order Gauss-Hermite quadrature
|
|
80
|
+
|
|
81
|
+
Once a kernel has been created, it is called with the model parameters and Q (and FWHM values for smeared kernels).
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
R = kernel(params, Q, FWHM) # fast because jit
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Parameters of `abeles`
|
|
88
|
+
|
|
89
|
+
For a slab model with `n` layers between a fronting and a backing medium, `params` is a dict:
|
|
90
|
+
|
|
91
|
+
| key | shape | meaning |
|
|
92
|
+
|---------|-----------|----------------------------------------------------------------------------|
|
|
93
|
+
| `thick` | `(n,)` | layer thicknesses, in the length unit of 1/Q (Å for Q in Å⁻¹) |
|
|
94
|
+
| `SLD` | `(n + 2,)` | scattering length densities in 10⁻⁶ Å⁻², fronting first, backing last |
|
|
95
|
+
| `rough` | `(n + 1,)` | interfacial roughnesses, one per interface |
|
|
96
|
+
| `scale` | `()` | multiplies the reflectivity |
|
|
97
|
+
| `bkg` | `()` | constant background added to the reflectivity |
|
|
98
|
+
|
|
99
|
+
### Example
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
import jax.numpy as jnp
|
|
103
|
+
import refjax
|
|
104
|
+
|
|
105
|
+
params = {
|
|
106
|
+
"thick": jnp.array([255.0, 17.0]),
|
|
107
|
+
"SLD": jnp.array([0.0, 6.4, 3.47, 2.07]),
|
|
108
|
+
"rough": jnp.array([5.2, 5.0, 2.07]),
|
|
109
|
+
"scale": 1.0,
|
|
110
|
+
"bkg": 2e-7,
|
|
111
|
+
}
|
|
112
|
+
Q = jnp.linspace(0.005, 0.3, 200) # Å⁻¹
|
|
113
|
+
FWHM = 0.05 * Q # resolution FWHM at each Q
|
|
114
|
+
kernel = refjax.kernel(refjax.model.abeles, refjax.smear.pointwise_gaussian)
|
|
115
|
+
R = kernel(params, Q, FWHM)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
[`examples/01_basic_usage.py`](https://github.com/MDCHAMP/refjax/blob/main/examples/01_basic_usage.py) walks through this model against the example data in [`examples/.example_NR_data.csv`](https://github.com/MDCHAMP/refjax/blob/main/examples/.example_NR_data.csv); the other scripts in [`examples`](https://github.com/MDCHAMP/refjax/tree/main/examples) show parameter identification with optax and numpyro.
|
|
119
|
+
|
|
120
|
+
## Spectroscopic ellipsometry
|
|
121
|
+
|
|
122
|
+
`refjax.model.spec_ellips(params, lam)` computes the ellipsometric angles Psi and Delta of a stratified medium (transfer-matrix method) at the wavelengths `lam`. It is `jit` and `grad` compatible and is called directly (not through `refjax.kernel`).
|
|
123
|
+
|
|
124
|
+
For `n` layers between an ambient medium and a substrate, `params` is a dict:
|
|
125
|
+
|
|
126
|
+
| key | shape | meaning |
|
|
127
|
+
|------------|----------------------|---------------------------------------------------------------------------------|
|
|
128
|
+
| `nbar` | `(n + 2, lam.shape)` | complex refractive index `n + ik` (`k >= 0`) of each medium, ambient first, substrate last |
|
|
129
|
+
| `thick` | `(n,)` | layer thicknesses, in the same length unit as `lam` |
|
|
130
|
+
| `aoi_rads` | `()` | angle of incidence in the ambient, in radians |
|
|
131
|
+
|
|
132
|
+
It returns `(psi, delta)` in degrees, each of shape `lam.shape`.
|
|
133
|
+
|
|
134
|
+
Conventions:
|
|
135
|
+
- `tan(psi) exp(i delta) = rp / rs` in Woollam's convention, as used by J.A. Woollam's instruments and CompleteEASE and set out in H. Fujiwara, *Spectroscopic Ellipsometry: Principles and Applications* (Wiley, 2007), [doi:10.1002/9780470060193](https://doi.org/10.1002/9780470060193): the complex index written n - ik, so refjax's n + ik enters conjugated. `delta` is wrapped to `[0, 360)`; compare it to data modulo 360.
|
|
136
|
+
- The complex index is `n + ik` with `k >= 0`. This is the complex conjugate of Woollam's `n - ik`, hence the minus sign on the phase of `rp / rs` in the code. A Woollam-convention index `N = n - ik` is passed as `N.conj()`.
|
|
137
|
+
- `n cos(theta)` in each medium follows from Snell's law with the principal square root, which gives `Im >= 0`: the decaying wave for `n + ik`.
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
import jax.numpy as jnp
|
|
141
|
+
import numpy as np
|
|
142
|
+
import refjax
|
|
143
|
+
|
|
144
|
+
lam = jnp.linspace(370.0, 1000.0, 50) # nm
|
|
145
|
+
# ambient, film, SiO2, Si substrate (n + ik), each constant in wavelength here
|
|
146
|
+
nbar = jnp.array([1.0, 1.58, 1.46, 3.9 + 0.02j])[:, None] * jnp.ones_like(lam)
|
|
147
|
+
params = {"nbar": nbar, "thick": jnp.array([17.0, 2.0]), "aoi_rads": np.radians(75.0)}
|
|
148
|
+
psi, delta = refjax.model.spec_ellips(params, lam)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
[`examples/06_spectroscopic_ellipsometry.py`](https://github.com/MDCHAMP/refjax/blob/main/examples/06_spectroscopic_ellipsometry.py) models and fits a thick polymer film on Si against a measured spectrum in [`examples/.example_ellips_data.csv`](https://github.com/MDCHAMP/refjax/blob/main/examples/.example_ellips_data.csv) (which carries the substrate's indices). [`test/test_ellips.py`](https://github.com/MDCHAMP/refjax/blob/main/test/test_ellips.py) checks `spec_ellips` against an independent numpy implementation and against that spectrum.
|
|
152
|
+
|
|
153
|
+
### Changes in 0.1.1
|
|
154
|
+
|
|
155
|
+
The first release on PyPI. Over the 0.1.0 tag: the smearing quadrature is computed in float64 (it was float32, an error of about 2e-8 relative in smeared reflectivity), importing refjax no longer starts jax's backend, `abeles` and `spec_ellips` have docstrings, and the packaging metadata is updated.
|
|
156
|
+
|
|
157
|
+
### Changes in 0.1.0
|
|
158
|
+
|
|
159
|
+
`spec_ellips` now follows the conventions above. `delta` is wrapped to `[0, 360)`; before 0.1.0 it was unwrapped along the wavelength axis, so it could differ from the 0.1.0 value by multiples of 360. `n cos(theta)` now comes from the principal square root rather than `arcsin` of Snell's law. Code written against the earlier `spec_ellips` should compare `delta` modulo 360.
|
|
160
|
+
|
|
161
|
+
## Development
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
uv run --extra test pytest
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Citing
|
|
168
|
+
|
|
169
|
+
If you use refjax in published work, please cite:
|
|
170
|
+
|
|
171
|
+
> Max D. Champneys, Andrew J. Parnell and Stephanie L. Burg, "Towards real-time surrogate-free Bayesian inversion for neutron reflectometry", *Communications Materials* (2026). [doi:10.1038/s43246-026-01341-4](https://doi.org/10.1038/s43246-026-01341-4)
|
|
172
|
+
|
|
173
|
+
```bibtex
|
|
174
|
+
@article{champneys2026refjax,
|
|
175
|
+
author = {Champneys, Max D. and Parnell, Andrew J. and Burg, Stephanie L.},
|
|
176
|
+
title = {Towards real-time surrogate-free {B}ayesian inversion for neutron reflectometry},
|
|
177
|
+
journal = {Communications Materials},
|
|
178
|
+
year = {2026},
|
|
179
|
+
doi = {10.1038/s43246-026-01341-4},
|
|
180
|
+
url = {https://doi.org/10.1038/s43246-026-01341-4}
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Please also state the refjax version you used (e.g. 0.1.1). Citation metadata is in [`CITATION.cff`](https://github.com/MDCHAMP/refjax/blob/main/CITATION.cff); GitHub's "Cite this repository" button reads it.
|
|
185
|
+
|
|
186
|
+
Having trouble? Feature request? Raise an issue on the [GitHub issues page](https://github.com/MDCHAMP/refjax/issues).
|
refjax-0.1.1/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Refjax - Lightweight `jit` and `grad` compatible reflectometry in jax
|
|
2
|
+
|
|
3
|
+
This package provides lightweight, low-level access to neutron reflectometry and spectroscopic ellipsometry kernels in jax.
|
|
4
|
+
|
|
5
|
+
The provided kernels are all `jit` and `grad` compatible and easy to integrate with your favourite optimisation or uncertainty quantification library for fast parameterisation of stratified reflectometry models. Several examples for popular libraries are available in the [`examples`](https://github.com/MDCHAMP/refjax/tree/main/examples) folder.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
From PyPI:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install refjax
|
|
14
|
+
# or
|
|
15
|
+
uv add refjax
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Or from GitHub, pinned to the 0.1.1 release by its tag:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install "refjax @ git+https://github.com/MDCHAMP/refjax@v0.1.1"
|
|
22
|
+
# or
|
|
23
|
+
uv add git+https://github.com/MDCHAMP/refjax --tag v0.1.1
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
refjax needs Python >= 3.11, `jax` and `jaxlib` >= 0.4.26 and `numpy` >= 2.0. The test suite runs in CI at these minimum versions and at the latest releases.
|
|
27
|
+
|
|
28
|
+
### 64-bit precision is on by default
|
|
29
|
+
|
|
30
|
+
`import refjax` switches on jax's 64-bit mode (`jax.config.update("jax_enable_x64", True)`), so refjax computes in float64 and complex128. This setting is global: it applies to all jax code in the same Python process, not only to refjax. To work in 32-bit, switch it off after importing refjax, before creating any arrays: `jax.config.update("jax_enable_x64", False)`. refjax then computes in float32 and complex64; its tests run only in 64-bit.
|
|
31
|
+
|
|
32
|
+
## Neutron reflectometry
|
|
33
|
+
|
|
34
|
+
### Design pattern
|
|
35
|
+
|
|
36
|
+
Reflectometry models are created by creating reflectivity kernels with `refjax.kernel`:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
kernel = refjax.kernel(model, smear=refjax.smear.no_smear, smear_params=None, jit=True)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Currently implemented models:
|
|
43
|
+
- `refjax.model.abeles` Abeles matrix formalism for stratified media
|
|
44
|
+
|
|
45
|
+
Currently implemented smearing kernels:
|
|
46
|
+
- `refjax.smear.no_smear` No smearing kernel (default)
|
|
47
|
+
- `refjax.smear.constant_gaussian` Constant gaussian smearing at every Q value (the third argument is the FWHM as a percentage of Q)
|
|
48
|
+
- `refjax.smear.pointwise_gaussian` Pointwise Gaussian approximation to the true smearing kernel (requires FWHM resolution measurements), by 17th-order Gauss-Hermite quadrature
|
|
49
|
+
|
|
50
|
+
Once a kernel has been created, it is called with the model parameters and Q (and FWHM values for smeared kernels).
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
R = kernel(params, Q, FWHM) # fast because jit
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Parameters of `abeles`
|
|
57
|
+
|
|
58
|
+
For a slab model with `n` layers between a fronting and a backing medium, `params` is a dict:
|
|
59
|
+
|
|
60
|
+
| key | shape | meaning |
|
|
61
|
+
|---------|-----------|----------------------------------------------------------------------------|
|
|
62
|
+
| `thick` | `(n,)` | layer thicknesses, in the length unit of 1/Q (Å for Q in Å⁻¹) |
|
|
63
|
+
| `SLD` | `(n + 2,)` | scattering length densities in 10⁻⁶ Å⁻², fronting first, backing last |
|
|
64
|
+
| `rough` | `(n + 1,)` | interfacial roughnesses, one per interface |
|
|
65
|
+
| `scale` | `()` | multiplies the reflectivity |
|
|
66
|
+
| `bkg` | `()` | constant background added to the reflectivity |
|
|
67
|
+
|
|
68
|
+
### Example
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import jax.numpy as jnp
|
|
72
|
+
import refjax
|
|
73
|
+
|
|
74
|
+
params = {
|
|
75
|
+
"thick": jnp.array([255.0, 17.0]),
|
|
76
|
+
"SLD": jnp.array([0.0, 6.4, 3.47, 2.07]),
|
|
77
|
+
"rough": jnp.array([5.2, 5.0, 2.07]),
|
|
78
|
+
"scale": 1.0,
|
|
79
|
+
"bkg": 2e-7,
|
|
80
|
+
}
|
|
81
|
+
Q = jnp.linspace(0.005, 0.3, 200) # Å⁻¹
|
|
82
|
+
FWHM = 0.05 * Q # resolution FWHM at each Q
|
|
83
|
+
kernel = refjax.kernel(refjax.model.abeles, refjax.smear.pointwise_gaussian)
|
|
84
|
+
R = kernel(params, Q, FWHM)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
[`examples/01_basic_usage.py`](https://github.com/MDCHAMP/refjax/blob/main/examples/01_basic_usage.py) walks through this model against the example data in [`examples/.example_NR_data.csv`](https://github.com/MDCHAMP/refjax/blob/main/examples/.example_NR_data.csv); the other scripts in [`examples`](https://github.com/MDCHAMP/refjax/tree/main/examples) show parameter identification with optax and numpyro.
|
|
88
|
+
|
|
89
|
+
## Spectroscopic ellipsometry
|
|
90
|
+
|
|
91
|
+
`refjax.model.spec_ellips(params, lam)` computes the ellipsometric angles Psi and Delta of a stratified medium (transfer-matrix method) at the wavelengths `lam`. It is `jit` and `grad` compatible and is called directly (not through `refjax.kernel`).
|
|
92
|
+
|
|
93
|
+
For `n` layers between an ambient medium and a substrate, `params` is a dict:
|
|
94
|
+
|
|
95
|
+
| key | shape | meaning |
|
|
96
|
+
|------------|----------------------|---------------------------------------------------------------------------------|
|
|
97
|
+
| `nbar` | `(n + 2, lam.shape)` | complex refractive index `n + ik` (`k >= 0`) of each medium, ambient first, substrate last |
|
|
98
|
+
| `thick` | `(n,)` | layer thicknesses, in the same length unit as `lam` |
|
|
99
|
+
| `aoi_rads` | `()` | angle of incidence in the ambient, in radians |
|
|
100
|
+
|
|
101
|
+
It returns `(psi, delta)` in degrees, each of shape `lam.shape`.
|
|
102
|
+
|
|
103
|
+
Conventions:
|
|
104
|
+
- `tan(psi) exp(i delta) = rp / rs` in Woollam's convention, as used by J.A. Woollam's instruments and CompleteEASE and set out in H. Fujiwara, *Spectroscopic Ellipsometry: Principles and Applications* (Wiley, 2007), [doi:10.1002/9780470060193](https://doi.org/10.1002/9780470060193): the complex index written n - ik, so refjax's n + ik enters conjugated. `delta` is wrapped to `[0, 360)`; compare it to data modulo 360.
|
|
105
|
+
- The complex index is `n + ik` with `k >= 0`. This is the complex conjugate of Woollam's `n - ik`, hence the minus sign on the phase of `rp / rs` in the code. A Woollam-convention index `N = n - ik` is passed as `N.conj()`.
|
|
106
|
+
- `n cos(theta)` in each medium follows from Snell's law with the principal square root, which gives `Im >= 0`: the decaying wave for `n + ik`.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
import jax.numpy as jnp
|
|
110
|
+
import numpy as np
|
|
111
|
+
import refjax
|
|
112
|
+
|
|
113
|
+
lam = jnp.linspace(370.0, 1000.0, 50) # nm
|
|
114
|
+
# ambient, film, SiO2, Si substrate (n + ik), each constant in wavelength here
|
|
115
|
+
nbar = jnp.array([1.0, 1.58, 1.46, 3.9 + 0.02j])[:, None] * jnp.ones_like(lam)
|
|
116
|
+
params = {"nbar": nbar, "thick": jnp.array([17.0, 2.0]), "aoi_rads": np.radians(75.0)}
|
|
117
|
+
psi, delta = refjax.model.spec_ellips(params, lam)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
[`examples/06_spectroscopic_ellipsometry.py`](https://github.com/MDCHAMP/refjax/blob/main/examples/06_spectroscopic_ellipsometry.py) models and fits a thick polymer film on Si against a measured spectrum in [`examples/.example_ellips_data.csv`](https://github.com/MDCHAMP/refjax/blob/main/examples/.example_ellips_data.csv) (which carries the substrate's indices). [`test/test_ellips.py`](https://github.com/MDCHAMP/refjax/blob/main/test/test_ellips.py) checks `spec_ellips` against an independent numpy implementation and against that spectrum.
|
|
121
|
+
|
|
122
|
+
### Changes in 0.1.1
|
|
123
|
+
|
|
124
|
+
The first release on PyPI. Over the 0.1.0 tag: the smearing quadrature is computed in float64 (it was float32, an error of about 2e-8 relative in smeared reflectivity), importing refjax no longer starts jax's backend, `abeles` and `spec_ellips` have docstrings, and the packaging metadata is updated.
|
|
125
|
+
|
|
126
|
+
### Changes in 0.1.0
|
|
127
|
+
|
|
128
|
+
`spec_ellips` now follows the conventions above. `delta` is wrapped to `[0, 360)`; before 0.1.0 it was unwrapped along the wavelength axis, so it could differ from the 0.1.0 value by multiples of 360. `n cos(theta)` now comes from the principal square root rather than `arcsin` of Snell's law. Code written against the earlier `spec_ellips` should compare `delta` modulo 360.
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
uv run --extra test pytest
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Citing
|
|
137
|
+
|
|
138
|
+
If you use refjax in published work, please cite:
|
|
139
|
+
|
|
140
|
+
> Max D. Champneys, Andrew J. Parnell and Stephanie L. Burg, "Towards real-time surrogate-free Bayesian inversion for neutron reflectometry", *Communications Materials* (2026). [doi:10.1038/s43246-026-01341-4](https://doi.org/10.1038/s43246-026-01341-4)
|
|
141
|
+
|
|
142
|
+
```bibtex
|
|
143
|
+
@article{champneys2026refjax,
|
|
144
|
+
author = {Champneys, Max D. and Parnell, Andrew J. and Burg, Stephanie L.},
|
|
145
|
+
title = {Towards real-time surrogate-free {B}ayesian inversion for neutron reflectometry},
|
|
146
|
+
journal = {Communications Materials},
|
|
147
|
+
year = {2026},
|
|
148
|
+
doi = {10.1038/s43246-026-01341-4},
|
|
149
|
+
url = {https://doi.org/10.1038/s43246-026-01341-4}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Please also state the refjax version you used (e.g. 0.1.1). Citation metadata is in [`CITATION.cff`](https://github.com/MDCHAMP/refjax/blob/main/CITATION.cff); GitHub's "Cite this repository" button reads it.
|
|
154
|
+
|
|
155
|
+
Having trouble? Feature request? Raise an issue on the [GitHub issues page](https://github.com/MDCHAMP/refjax/issues).
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "refjax"
|
|
7
|
+
dependencies = ["numpy>=2.0", "jax>=0.4.26", "jaxlib>=0.4.26"]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "Max D. Champneys", email = "max.champneys@sheffield.ac.uk" },
|
|
11
|
+
{ name = "Stephanie L. Burg" },
|
|
12
|
+
]
|
|
13
|
+
maintainers = [
|
|
14
|
+
{ name = "Max Champneys", email = "max.champneys@sheffield.ac.uk" },
|
|
15
|
+
]
|
|
16
|
+
description = "Lightweight `jit` and `grad` compatible reflectometry in jax."
|
|
17
|
+
license = "MIT"
|
|
18
|
+
license-files = ["LICENSE"]
|
|
19
|
+
keywords = ["Reflectometry", "Neutron reflectometry", "jax", "autograd"]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Programming Language :: Python",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Development Status :: 4 - Beta",
|
|
26
|
+
"Intended Audience :: Science/Research",
|
|
27
|
+
"Topic :: Scientific/Engineering :: Physics",
|
|
28
|
+
]
|
|
29
|
+
dynamic = ["version", "readme"]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
test = ["pytest>=7.0", "pandas>=2.2.2", "refnx>=0.1.45"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Repository = "https://github.com/MDCHAMP/refjax"
|
|
36
|
+
Issues = "https://github.com/MDCHAMP/refjax/issues"
|
|
37
|
+
Paper = "https://doi.org/10.1038/s43246-026-01341-4"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.dynamic]
|
|
41
|
+
readme = {file = ["README.md"], content-type = "text/markdown"}
|
|
42
|
+
version = {attr = "refjax.__version__"}
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.packages.find]
|
|
45
|
+
where = ["src"]
|
refjax-0.1.1/setup.cfg
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import jax.numpy as jnp
|
|
2
|
+
from functools import reduce
|
|
3
|
+
|
|
4
|
+
# %% Abeles Matrix formalism
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def abeles(params, Q):
|
|
8
|
+
"""Reflectivity of a stratified medium by the Abeles matrix formalism.
|
|
9
|
+
|
|
10
|
+
Parameters
|
|
11
|
+
----------
|
|
12
|
+
params : dict
|
|
13
|
+
Slab model with n layers between a fronting and a backing medium:
|
|
14
|
+
|
|
15
|
+
- "thick": (n,) layer thicknesses, in the length unit of 1/Q (Å for Q in Å⁻¹)
|
|
16
|
+
- "rough": (n + 1,) interfacial roughnesses, one per interface
|
|
17
|
+
- "SLD": (n + 2,) scattering length densities in 10⁻⁶ Å⁻², fronting first, backing last
|
|
18
|
+
- "scale": () multiplies the reflectivity
|
|
19
|
+
- "bkg": () constant background added to the reflectivity
|
|
20
|
+
Q : array, shape (m,)
|
|
21
|
+
Momentum transfer.
|
|
22
|
+
|
|
23
|
+
Returns
|
|
24
|
+
-------
|
|
25
|
+
array, shape (m,)
|
|
26
|
+
The reflectivity at each Q.
|
|
27
|
+
"""
|
|
28
|
+
# SLD profile differences
|
|
29
|
+
d_rho = (params["SLD"] - params["SLD"][0]).astype(complex) * 1e-6
|
|
30
|
+
# compute wavevectors for the nth layer
|
|
31
|
+
kn = jnp.sqrt(((Q[:, None] / 2) ** 2) - (4 * jnp.pi * d_rho))
|
|
32
|
+
# Fresnel reflection
|
|
33
|
+
a, b = kn[:, :-1], kn[:, 1:]
|
|
34
|
+
r = (a - b) / (a + b) * jnp.exp(-2 * a * b * params["rough"] ** 2)
|
|
35
|
+
# Compute and multiply characteristic matricies elementwise (n_layers + 1, Q.shape)
|
|
36
|
+
C00 = jnp.exp(1j * a * jnp.array([0, *params["thick"]])).T
|
|
37
|
+
C11 = C00**-1
|
|
38
|
+
C10 = r.T * C00
|
|
39
|
+
C01 = r.T * C11
|
|
40
|
+
|
|
41
|
+
# For some reason manually performing the 2x2 matrix multiplications
|
|
42
|
+
# is a huge speedup (~2x) in jit-ed calls compared to matmul (???)
|
|
43
|
+
# Could be something to do with the very wide broadcast size?
|
|
44
|
+
# The batch dim size is often >> 1k and idk if this is not efficient
|
|
45
|
+
# Perhaps there is an einsum that works better? - there isn't 08/04/24
|
|
46
|
+
# Tested it and seemingly not. Bizzare behavior. Absolute rabbithole.
|
|
47
|
+
# Perhaps constructing the large array (n_layers+1, Q.shape, 2,2) is expensive somehow? - Nope 08/04/24.
|
|
48
|
+
# Hard to imagine why this is - something to do with optimisations in jaxprs at a guess.
|
|
49
|
+
|
|
50
|
+
M00, M10, M01, M11 = C00[0], C10[0], C01[0], C11[0]
|
|
51
|
+
for c00, c10, c01, c11 in zip(C00[1:], C10[1:], C01[1:], C11[1:]):
|
|
52
|
+
a = M00 * c00 + M10 * c01
|
|
53
|
+
b = M00 * c10 + M10 * c11
|
|
54
|
+
c = M01 * c00 + M11 * c01
|
|
55
|
+
d = M01 * c10 + M11 * c11
|
|
56
|
+
M00, M10, M01, M11 = a, b, c, d
|
|
57
|
+
R = M01 / M00
|
|
58
|
+
return params["scale"] * (R * R.conj()).real + params["bkg"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# %% Spectroscopic ellipsometry
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def spec_ellips(params, lam):
|
|
65
|
+
"""Ellipsometric angles Psi and Delta of a stratified medium by the transfer-matrix method.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
params : dict
|
|
70
|
+
n layers between an ambient medium and a substrate:
|
|
71
|
+
|
|
72
|
+
- "nbar": (n + 2, lam.shape) complex refractive index n + ik (k >= 0) of each medium,
|
|
73
|
+
ambient first, substrate last
|
|
74
|
+
- "thick": (n,) layer thicknesses, in the same length unit as lam
|
|
75
|
+
- "aoi_rads": () angle of incidence in the ambient, in radians
|
|
76
|
+
lam : array
|
|
77
|
+
Wavelengths.
|
|
78
|
+
|
|
79
|
+
Returns
|
|
80
|
+
-------
|
|
81
|
+
psi, delta : arrays, each of shape lam.shape
|
|
82
|
+
In degrees: tan(psi) exp(i delta) = rp / rs in Woollam's convention, delta in [0, 360).
|
|
83
|
+
Compare delta to data modulo 360.
|
|
84
|
+
"""
|
|
85
|
+
nbar = params["nbar"]
|
|
86
|
+
# n cos(theta) in each medium (Snell); principal sqrt gives Im >= 0, the decaying wave for n + ik
|
|
87
|
+
ncos = jnp.sqrt(nbar**2 - (nbar[0] * jnp.sin(params["aoi_rads"])) ** 2)
|
|
88
|
+
cos = ncos / nbar
|
|
89
|
+
ncos22 = ncos[1:]
|
|
90
|
+
ncos11 = ncos[:-1]
|
|
91
|
+
ncos21 = nbar[1:] * cos[:-1]
|
|
92
|
+
ncos12 = nbar[:-1] * cos[1:]
|
|
93
|
+
rs = (ncos11 - ncos22) / (ncos11 + ncos22)
|
|
94
|
+
ts = 2 * ncos11 / (ncos11 + ncos22)
|
|
95
|
+
rp = (ncos21 - ncos12) / (ncos12 + ncos21)
|
|
96
|
+
tp = 2 * ncos11 / (ncos12 + ncos21)
|
|
97
|
+
dz = 1j * params["thick"][:, None] * 2 * jnp.pi * ncos[1:-1] / lam
|
|
98
|
+
one = jnp.ones_like(rs)
|
|
99
|
+
edr = jnp.exp(dz)
|
|
100
|
+
nedr = jnp.exp(-dz)
|
|
101
|
+
|
|
102
|
+
def TMM(r, t):
|
|
103
|
+
ms = jnp.array([[nedr, nedr * r[1:]], [edr * r[1:], edr]]) / t[1:]
|
|
104
|
+
ms0 = jnp.array([[one[0], r[0]], [r[0], one[0]]]) / t[0]
|
|
105
|
+
Ms = reduce(jnp.matmul, ms.transpose(2, 3, 0, 1), ms0.T)
|
|
106
|
+
return Ms[:, 1, 0] / Ms[:, 0, 0]
|
|
107
|
+
|
|
108
|
+
rho = TMM(rp, tp) / TMM(rs, ts)
|
|
109
|
+
# n + ik is the conjugate of Woollam's n - ik, hence the minus sign on delta
|
|
110
|
+
_psi = jnp.degrees(jnp.arctan(jnp.abs(rho)))
|
|
111
|
+
_delta = jnp.degrees(-jnp.angle(rho)) % 360
|
|
112
|
+
return _psi, _delta
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import jax
|
|
2
|
+
import jax.numpy as jnp
|
|
3
|
+
from numpy.polynomial.hermite import hermgauss
|
|
4
|
+
|
|
5
|
+
_ORDER = 17
|
|
6
|
+
herm_root, herm_weight = hermgauss(_ORDER)
|
|
7
|
+
|
|
8
|
+
def no_smear(params, Q, FWHM, model, **kwargs):
|
|
9
|
+
return model(params, Q)
|
|
10
|
+
|
|
11
|
+
def constant_gaussian(params, Q, const_smear, model, **kwargs):
|
|
12
|
+
'''
|
|
13
|
+
FWHM = const_smear% * Q
|
|
14
|
+
Gaussian kernel smearing.
|
|
15
|
+
There are a few options here can solve as a convolution or just do gauss-hermite?
|
|
16
|
+
For now treat as pointwise.
|
|
17
|
+
'''
|
|
18
|
+
return pointwise_gaussian(params, Q, Q*const_smear/100, model, **kwargs)
|
|
19
|
+
|
|
20
|
+
def pointwise_gaussian(params, Q, FWHMs, model, **kwargs):
|
|
21
|
+
'''
|
|
22
|
+
Pointwise gaussian smearing kernel.
|
|
23
|
+
Compute the expectation integral using a 17th order Gauss-Hermite quadrature.
|
|
24
|
+
Note that this differs from the approach in refnx which uses a finite width interval so numerics may differ.'''
|
|
25
|
+
Q_eval = 0.5 * FWHMs / jnp.sqrt(jnp.log(2.0)) * herm_root[:, None] + Q
|
|
26
|
+
R = jax.vmap(model, in_axes=(None, 0))(params, Q_eval)
|
|
27
|
+
return herm_weight @ R / jnp.sqrt(jnp.pi)
|
|
28
|
+
|
|
29
|
+
def _custom_kernel(params, Q, kernel, model, **kwargs):
|
|
30
|
+
'''TODO
|
|
31
|
+
Manually compute the expectation interval of R(q) w.r.t k(q)
|
|
32
|
+
Can use simpsons or a Gauss-quad maybe?
|
|
33
|
+
'''
|
|
34
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import jax
|
|
2
|
+
from refjax.smear import no_smear
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def kernel(model, smear=no_smear, smear_params=None, jit=True):
|
|
6
|
+
"""Build a reflection kernel from a reflection model and a smearing kernel"""
|
|
7
|
+
if smear_params is None:
|
|
8
|
+
smear_params = {}
|
|
9
|
+
|
|
10
|
+
def kern(params, q, dq=None):
|
|
11
|
+
return smear(params, q, dq, model, **smear_params)
|
|
12
|
+
if jit:
|
|
13
|
+
kern = jax.jit(kern)
|
|
14
|
+
return kern
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _from_refnx(refnx_model):
|
|
18
|
+
"""TODO Pull out required parameters from a refnx model"""
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
|
|
21
|
+
def _validata_params(params):
|
|
22
|
+
"""TODO Validata params manually because don't want to do this in a jit"""
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: refjax
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Lightweight `jit` and `grad` compatible reflectometry in jax.
|
|
5
|
+
Author: Stephanie L. Burg
|
|
6
|
+
Author-email: "Max D. Champneys" <max.champneys@sheffield.ac.uk>
|
|
7
|
+
Maintainer-email: Max Champneys <max.champneys@sheffield.ac.uk>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Project-URL: Repository, https://github.com/MDCHAMP/refjax
|
|
10
|
+
Project-URL: Issues, https://github.com/MDCHAMP/refjax/issues
|
|
11
|
+
Project-URL: Paper, https://doi.org/10.1038/s43246-026-01341-4
|
|
12
|
+
Keywords: Reflectometry,Neutron reflectometry,jax,autograd
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Development Status :: 4 - Beta
|
|
18
|
+
Classifier: Intended Audience :: Science/Research
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: numpy>=2.0
|
|
24
|
+
Requires-Dist: jax>=0.4.26
|
|
25
|
+
Requires-Dist: jaxlib>=0.4.26
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
28
|
+
Requires-Dist: pandas>=2.2.2; extra == "test"
|
|
29
|
+
Requires-Dist: refnx>=0.1.45; extra == "test"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# Refjax - Lightweight `jit` and `grad` compatible reflectometry in jax
|
|
33
|
+
|
|
34
|
+
This package provides lightweight, low-level access to neutron reflectometry and spectroscopic ellipsometry kernels in jax.
|
|
35
|
+
|
|
36
|
+
The provided kernels are all `jit` and `grad` compatible and easy to integrate with your favourite optimisation or uncertainty quantification library for fast parameterisation of stratified reflectometry models. Several examples for popular libraries are available in the [`examples`](https://github.com/MDCHAMP/refjax/tree/main/examples) folder.
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
From PyPI:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install refjax
|
|
45
|
+
# or
|
|
46
|
+
uv add refjax
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or from GitHub, pinned to the 0.1.1 release by its tag:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install "refjax @ git+https://github.com/MDCHAMP/refjax@v0.1.1"
|
|
53
|
+
# or
|
|
54
|
+
uv add git+https://github.com/MDCHAMP/refjax --tag v0.1.1
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
refjax needs Python >= 3.11, `jax` and `jaxlib` >= 0.4.26 and `numpy` >= 2.0. The test suite runs in CI at these minimum versions and at the latest releases.
|
|
58
|
+
|
|
59
|
+
### 64-bit precision is on by default
|
|
60
|
+
|
|
61
|
+
`import refjax` switches on jax's 64-bit mode (`jax.config.update("jax_enable_x64", True)`), so refjax computes in float64 and complex128. This setting is global: it applies to all jax code in the same Python process, not only to refjax. To work in 32-bit, switch it off after importing refjax, before creating any arrays: `jax.config.update("jax_enable_x64", False)`. refjax then computes in float32 and complex64; its tests run only in 64-bit.
|
|
62
|
+
|
|
63
|
+
## Neutron reflectometry
|
|
64
|
+
|
|
65
|
+
### Design pattern
|
|
66
|
+
|
|
67
|
+
Reflectometry models are created by creating reflectivity kernels with `refjax.kernel`:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
kernel = refjax.kernel(model, smear=refjax.smear.no_smear, smear_params=None, jit=True)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Currently implemented models:
|
|
74
|
+
- `refjax.model.abeles` Abeles matrix formalism for stratified media
|
|
75
|
+
|
|
76
|
+
Currently implemented smearing kernels:
|
|
77
|
+
- `refjax.smear.no_smear` No smearing kernel (default)
|
|
78
|
+
- `refjax.smear.constant_gaussian` Constant gaussian smearing at every Q value (the third argument is the FWHM as a percentage of Q)
|
|
79
|
+
- `refjax.smear.pointwise_gaussian` Pointwise Gaussian approximation to the true smearing kernel (requires FWHM resolution measurements), by 17th-order Gauss-Hermite quadrature
|
|
80
|
+
|
|
81
|
+
Once a kernel has been created, it is called with the model parameters and Q (and FWHM values for smeared kernels).
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
R = kernel(params, Q, FWHM) # fast because jit
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Parameters of `abeles`
|
|
88
|
+
|
|
89
|
+
For a slab model with `n` layers between a fronting and a backing medium, `params` is a dict:
|
|
90
|
+
|
|
91
|
+
| key | shape | meaning |
|
|
92
|
+
|---------|-----------|----------------------------------------------------------------------------|
|
|
93
|
+
| `thick` | `(n,)` | layer thicknesses, in the length unit of 1/Q (Å for Q in Å⁻¹) |
|
|
94
|
+
| `SLD` | `(n + 2,)` | scattering length densities in 10⁻⁶ Å⁻², fronting first, backing last |
|
|
95
|
+
| `rough` | `(n + 1,)` | interfacial roughnesses, one per interface |
|
|
96
|
+
| `scale` | `()` | multiplies the reflectivity |
|
|
97
|
+
| `bkg` | `()` | constant background added to the reflectivity |
|
|
98
|
+
|
|
99
|
+
### Example
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
import jax.numpy as jnp
|
|
103
|
+
import refjax
|
|
104
|
+
|
|
105
|
+
params = {
|
|
106
|
+
"thick": jnp.array([255.0, 17.0]),
|
|
107
|
+
"SLD": jnp.array([0.0, 6.4, 3.47, 2.07]),
|
|
108
|
+
"rough": jnp.array([5.2, 5.0, 2.07]),
|
|
109
|
+
"scale": 1.0,
|
|
110
|
+
"bkg": 2e-7,
|
|
111
|
+
}
|
|
112
|
+
Q = jnp.linspace(0.005, 0.3, 200) # Å⁻¹
|
|
113
|
+
FWHM = 0.05 * Q # resolution FWHM at each Q
|
|
114
|
+
kernel = refjax.kernel(refjax.model.abeles, refjax.smear.pointwise_gaussian)
|
|
115
|
+
R = kernel(params, Q, FWHM)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
[`examples/01_basic_usage.py`](https://github.com/MDCHAMP/refjax/blob/main/examples/01_basic_usage.py) walks through this model against the example data in [`examples/.example_NR_data.csv`](https://github.com/MDCHAMP/refjax/blob/main/examples/.example_NR_data.csv); the other scripts in [`examples`](https://github.com/MDCHAMP/refjax/tree/main/examples) show parameter identification with optax and numpyro.
|
|
119
|
+
|
|
120
|
+
## Spectroscopic ellipsometry
|
|
121
|
+
|
|
122
|
+
`refjax.model.spec_ellips(params, lam)` computes the ellipsometric angles Psi and Delta of a stratified medium (transfer-matrix method) at the wavelengths `lam`. It is `jit` and `grad` compatible and is called directly (not through `refjax.kernel`).
|
|
123
|
+
|
|
124
|
+
For `n` layers between an ambient medium and a substrate, `params` is a dict:
|
|
125
|
+
|
|
126
|
+
| key | shape | meaning |
|
|
127
|
+
|------------|----------------------|---------------------------------------------------------------------------------|
|
|
128
|
+
| `nbar` | `(n + 2, lam.shape)` | complex refractive index `n + ik` (`k >= 0`) of each medium, ambient first, substrate last |
|
|
129
|
+
| `thick` | `(n,)` | layer thicknesses, in the same length unit as `lam` |
|
|
130
|
+
| `aoi_rads` | `()` | angle of incidence in the ambient, in radians |
|
|
131
|
+
|
|
132
|
+
It returns `(psi, delta)` in degrees, each of shape `lam.shape`.
|
|
133
|
+
|
|
134
|
+
Conventions:
|
|
135
|
+
- `tan(psi) exp(i delta) = rp / rs` in Woollam's convention, as used by J.A. Woollam's instruments and CompleteEASE and set out in H. Fujiwara, *Spectroscopic Ellipsometry: Principles and Applications* (Wiley, 2007), [doi:10.1002/9780470060193](https://doi.org/10.1002/9780470060193): the complex index written n - ik, so refjax's n + ik enters conjugated. `delta` is wrapped to `[0, 360)`; compare it to data modulo 360.
|
|
136
|
+
- The complex index is `n + ik` with `k >= 0`. This is the complex conjugate of Woollam's `n - ik`, hence the minus sign on the phase of `rp / rs` in the code. A Woollam-convention index `N = n - ik` is passed as `N.conj()`.
|
|
137
|
+
- `n cos(theta)` in each medium follows from Snell's law with the principal square root, which gives `Im >= 0`: the decaying wave for `n + ik`.
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
import jax.numpy as jnp
|
|
141
|
+
import numpy as np
|
|
142
|
+
import refjax
|
|
143
|
+
|
|
144
|
+
lam = jnp.linspace(370.0, 1000.0, 50) # nm
|
|
145
|
+
# ambient, film, SiO2, Si substrate (n + ik), each constant in wavelength here
|
|
146
|
+
nbar = jnp.array([1.0, 1.58, 1.46, 3.9 + 0.02j])[:, None] * jnp.ones_like(lam)
|
|
147
|
+
params = {"nbar": nbar, "thick": jnp.array([17.0, 2.0]), "aoi_rads": np.radians(75.0)}
|
|
148
|
+
psi, delta = refjax.model.spec_ellips(params, lam)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
[`examples/06_spectroscopic_ellipsometry.py`](https://github.com/MDCHAMP/refjax/blob/main/examples/06_spectroscopic_ellipsometry.py) models and fits a thick polymer film on Si against a measured spectrum in [`examples/.example_ellips_data.csv`](https://github.com/MDCHAMP/refjax/blob/main/examples/.example_ellips_data.csv) (which carries the substrate's indices). [`test/test_ellips.py`](https://github.com/MDCHAMP/refjax/blob/main/test/test_ellips.py) checks `spec_ellips` against an independent numpy implementation and against that spectrum.
|
|
152
|
+
|
|
153
|
+
### Changes in 0.1.1
|
|
154
|
+
|
|
155
|
+
The first release on PyPI. Over the 0.1.0 tag: the smearing quadrature is computed in float64 (it was float32, an error of about 2e-8 relative in smeared reflectivity), importing refjax no longer starts jax's backend, `abeles` and `spec_ellips` have docstrings, and the packaging metadata is updated.
|
|
156
|
+
|
|
157
|
+
### Changes in 0.1.0
|
|
158
|
+
|
|
159
|
+
`spec_ellips` now follows the conventions above. `delta` is wrapped to `[0, 360)`; before 0.1.0 it was unwrapped along the wavelength axis, so it could differ from the 0.1.0 value by multiples of 360. `n cos(theta)` now comes from the principal square root rather than `arcsin` of Snell's law. Code written against the earlier `spec_ellips` should compare `delta` modulo 360.
|
|
160
|
+
|
|
161
|
+
## Development
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
uv run --extra test pytest
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Citing
|
|
168
|
+
|
|
169
|
+
If you use refjax in published work, please cite:
|
|
170
|
+
|
|
171
|
+
> Max D. Champneys, Andrew J. Parnell and Stephanie L. Burg, "Towards real-time surrogate-free Bayesian inversion for neutron reflectometry", *Communications Materials* (2026). [doi:10.1038/s43246-026-01341-4](https://doi.org/10.1038/s43246-026-01341-4)
|
|
172
|
+
|
|
173
|
+
```bibtex
|
|
174
|
+
@article{champneys2026refjax,
|
|
175
|
+
author = {Champneys, Max D. and Parnell, Andrew J. and Burg, Stephanie L.},
|
|
176
|
+
title = {Towards real-time surrogate-free {B}ayesian inversion for neutron reflectometry},
|
|
177
|
+
journal = {Communications Materials},
|
|
178
|
+
year = {2026},
|
|
179
|
+
doi = {10.1038/s43246-026-01341-4},
|
|
180
|
+
url = {https://doi.org/10.1038/s43246-026-01341-4}
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Please also state the refjax version you used (e.g. 0.1.1). Citation metadata is in [`CITATION.cff`](https://github.com/MDCHAMP/refjax/blob/main/CITATION.cff); GitHub's "Cite this repository" button reads it.
|
|
185
|
+
|
|
186
|
+
Having trouble? Feature request? Raise an issue on the [GitHub issues page](https://github.com/MDCHAMP/refjax/issues).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/refjax/__init__.py
|
|
5
|
+
src/refjax/model.py
|
|
6
|
+
src/refjax/smear.py
|
|
7
|
+
src/refjax/tools.py
|
|
8
|
+
src/refjax.egg-info/PKG-INFO
|
|
9
|
+
src/refjax.egg-info/SOURCES.txt
|
|
10
|
+
src/refjax.egg-info/dependency_links.txt
|
|
11
|
+
src/refjax.egg-info/requires.txt
|
|
12
|
+
src/refjax.egg-info/top_level.txt
|
|
13
|
+
test/test_ellips.py
|
|
14
|
+
test/test_refjax.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
refjax
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import jax
|
|
4
|
+
import jax.numpy as jnp
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import pytest
|
|
8
|
+
import refjax as rj
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def reference(N, d, lam, aoi):
|
|
12
|
+
# Independent numpy Psi, Delta: Woollam/Fujiwara convention (N = n - ik), Airy recursion
|
|
13
|
+
ncos = np.sqrt(N**2 - (N[0] * np.sin(aoi)) ** 2)
|
|
14
|
+
ncos = np.where(ncos.imag > 0, -ncos, ncos)
|
|
15
|
+
cos = ncos / N
|
|
16
|
+
out = []
|
|
17
|
+
for pol in "sp":
|
|
18
|
+
def fres(i, j):
|
|
19
|
+
if pol == "s":
|
|
20
|
+
return (ncos[i] - ncos[j]) / (ncos[i] + ncos[j])
|
|
21
|
+
return (N[j] * cos[i] - N[i] * cos[j]) / (N[j] * cos[i] + N[i] * cos[j])
|
|
22
|
+
|
|
23
|
+
r = fres(-2, -1)
|
|
24
|
+
for j in range(len(d) - 1, -1, -1):
|
|
25
|
+
e = np.exp(-4j * np.pi * d[j] * ncos[j + 1] / lam)
|
|
26
|
+
rij = fres(j, j + 1)
|
|
27
|
+
r = (rij + r * e) / (1 + rij * r * e)
|
|
28
|
+
out.append(r)
|
|
29
|
+
rho = out[1] / out[0]
|
|
30
|
+
return np.degrees(np.arctan(np.abs(rho))), np.degrees(np.angle(rho)) % 360
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
lam = np.linspace(370, 1000, 50)
|
|
34
|
+
aoi = np.radians(75.1)
|
|
35
|
+
nSi = 3.9 - 0.02j
|
|
36
|
+
|
|
37
|
+
cases = {
|
|
38
|
+
"bare Si": ([1.0, nSi], []),
|
|
39
|
+
"SiO2/Si": ([1.0, 1.46, nSi], [2.0]),
|
|
40
|
+
"PS/SiO2/Si": ([1.0, 1.58, 1.46, nSi], [17.0, 2.0]),
|
|
41
|
+
"thick PS/SiO2/Si": ([1.0, 1.58, 1.46, nSi], [600.0, 2.0]),
|
|
42
|
+
"absorbing film": ([1.0, 2.0 - 0.3j, nSi], [30.0]),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def params(n, d):
|
|
47
|
+
N = np.array(n, complex)[:, None] * np.ones_like(lam)
|
|
48
|
+
# refjax takes n + ik
|
|
49
|
+
return {"nbar": jnp.array(N.conj()), "thick": jnp.array(d, float), "aoi_rads": aoi}, N
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.parametrize("name", cases)
|
|
53
|
+
def test_spec_ellips(name):
|
|
54
|
+
p, N = params(*cases[name])
|
|
55
|
+
psi_ref, delta_ref = reference(N, np.array(cases[name][1], float), lam, aoi)
|
|
56
|
+
psi, delta = rj.model.spec_ellips(p, jnp.array(lam))
|
|
57
|
+
assert np.allclose(psi, psi_ref, atol=1e-9)
|
|
58
|
+
assert np.allclose(delta, delta_ref, atol=1e-9)
|
|
59
|
+
assert np.all((delta >= 0) & (delta < 360))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_ellips_can_jit_and_grad():
|
|
63
|
+
p, _ = params(*cases["PS/SiO2/Si"])
|
|
64
|
+
|
|
65
|
+
def f(thick):
|
|
66
|
+
return jnp.sum(rj.model.spec_ellips({**p, "thick": thick}, jnp.array(lam))[0])
|
|
67
|
+
|
|
68
|
+
g = jax.jit(jax.grad(f))(p["thick"])
|
|
69
|
+
assert np.all(np.isfinite(g))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# %% Measured data: one spectrum of a thick polymer film on Si (native oxide), 450-1000 nm, with the substrate's indices
|
|
73
|
+
# at the measurement temperature (Franta's Si, Malitson's SiO2). The parameters are a fit of a Cauchy film to it,
|
|
74
|
+
# whose reduced chi2 against the stated errors was 12.860.
|
|
75
|
+
|
|
76
|
+
data = pd.read_csv(Path(__file__).parent.parent / "examples" / ".example_ellips_data.csv")
|
|
77
|
+
film = {"A": 1.5592380152192518, "B": 0.009669302459697682, "thick_nm": 269.12932822155117}
|
|
78
|
+
oxide_nm = 1.7976495299127038
|
|
79
|
+
aoi_deg = 75.11362116
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_spec_ellips_vs_data():
|
|
83
|
+
lam = jnp.array(data.wavelength_nm)
|
|
84
|
+
n_film = film["A"] + film["B"] / (lam / 1000) ** 2
|
|
85
|
+
nbar = jnp.stack([jnp.ones_like(lam), n_film, jnp.array(data.n_sio2), jnp.array(data.n_si) + 1j * jnp.array(data.k_si)])
|
|
86
|
+
p = {"nbar": nbar.astype(complex), "thick": jnp.array([film["thick_nm"], oxide_nm]), "aoi_rads": jnp.radians(aoi_deg)}
|
|
87
|
+
psi, delta = rj.model.spec_ellips(p, lam)
|
|
88
|
+
r = np.concatenate([(psi - data.psi) / data.dpsi, ((delta - data.delta + 180) % 360 - 180) / data.ddelta])
|
|
89
|
+
chi2_red = np.sum(r**2) / (r.size - 3)
|
|
90
|
+
assert abs(chi2_red - 12.860) < 1e-2 # the fit's own value
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import jax
|
|
4
|
+
import jax.numpy as jnp
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import refjax as rj
|
|
8
|
+
from refnx.reflect.reflect_model import reflectivity
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# %% Fixture some data
|
|
12
|
+
|
|
13
|
+
#data
|
|
14
|
+
data = pd.read_csv(Path(__file__).parent.parent / "examples" / ".example_NR_data.csv")
|
|
15
|
+
|
|
16
|
+
#Parameters
|
|
17
|
+
struct = jnp.array(
|
|
18
|
+
[
|
|
19
|
+
[0.0, 0.0, 0.0, 0.0],
|
|
20
|
+
[254.9, 6.37, 0.0, 6.8],
|
|
21
|
+
[17.0, 3.47, 0.0, 5.2],
|
|
22
|
+
[0.0, 2.07, 0.0, 2.07],
|
|
23
|
+
]
|
|
24
|
+
) # blame refnx for this
|
|
25
|
+
|
|
26
|
+
scale = 1.0
|
|
27
|
+
bkg = 2e-7
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
F_refnx = reflectivity(np.array(data.Q), np.array(struct), scale,bkg, np.array(data.FWHM))
|
|
31
|
+
|
|
32
|
+
params = {
|
|
33
|
+
"thick": jnp.array([254.9, 17.0]),
|
|
34
|
+
"SLD": jnp.array([0.0, 6.37, 3.47, 2.07]),
|
|
35
|
+
"rough": jnp.array([6.8, 5.2, 2.07]),
|
|
36
|
+
"scale": scale,
|
|
37
|
+
"bkg": bkg,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# %%
|
|
42
|
+
|
|
43
|
+
def test_fit():
|
|
44
|
+
kernel = rj.kernel(rj.model.abeles, rj.smear.pointwise_gaussian, jit=0)
|
|
45
|
+
F = kernel(params, jnp.array(data.Q), jnp.array(data.FWHM))
|
|
46
|
+
|
|
47
|
+
# Vs refnx
|
|
48
|
+
# tolerance because the smearing quadrature methods are not identical
|
|
49
|
+
assert ((F-F_refnx)**2).sum()/F.shape[0] < 1e-6
|
|
50
|
+
|
|
51
|
+
# Vs data
|
|
52
|
+
assert ((F-data.R)**2).sum()/F.shape[0] < 1e-1 # low MSE
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_can_jit():
|
|
56
|
+
kernel = rj.kernel(rj.model.abeles, rj.smear.pointwise_gaussian)
|
|
57
|
+
kernel(params, jnp.array(data.Q), jnp.array(data.FWHM))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_can_grad():
|
|
61
|
+
kernel = jax.jit(rj.kernel(rj.model.abeles, rj.smear.pointwise_gaussian))
|
|
62
|
+
dk_dtheta = jax.jacfwd(kernel)
|
|
63
|
+
dk_dtheta(params, jnp.array(data.Q), jnp.array(data.FWHM))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_quadrature_nodes_float64():
|
|
67
|
+
# made at import, before refjax switches on x64: they must not be float32 jax arrays
|
|
68
|
+
assert rj.smear.herm_root.dtype == np.float64
|
|
69
|
+
assert rj.smear.herm_weight.dtype == np.float64
|
|
70
|
+
|