reflectofit 0.1.0__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.
- reflectofit-0.1.0/LICENSE +21 -0
- reflectofit-0.1.0/PKG-INFO +90 -0
- reflectofit-0.1.0/README.md +58 -0
- reflectofit-0.1.0/pyproject.toml +80 -0
- reflectofit-0.1.0/pyproject.toml.orig +68 -0
- reflectofit-0.1.0/src/reflectofit/__init__.py +5 -0
- reflectofit-0.1.0/src/reflectofit/cli.py +117 -0
- reflectofit-0.1.0/src/reflectofit/data.py +118 -0
- reflectofit-0.1.0/src/reflectofit/estimate.py +89 -0
- reflectofit-0.1.0/src/reflectofit/fit.py +490 -0
- reflectofit-0.1.0/src/reflectofit/metrics.py +95 -0
- reflectofit-0.1.0/src/reflectofit/model.py +204 -0
- reflectofit-0.1.0/src/reflectofit/py.typed +0 -0
- reflectofit-0.1.0/src/reflectofit/staged.py +293 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Isaac Yong
|
|
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.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: reflectofit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: X-ray reflectivity fitting engine and command line
|
|
5
|
+
Keywords: x-ray reflectivity,XRR,thin films,parratt,fitting
|
|
6
|
+
Author: Isaac Yong
|
|
7
|
+
Author-email: Isaac Yong <isaacyong@naver.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: numpy>=2.0
|
|
20
|
+
Requires-Dist: scipy>=1.13
|
|
21
|
+
Requires-Dist: reflectosim>=0.1,<0.2
|
|
22
|
+
Requires-Dist: emcee>=3.1 ; extra == 'mcmc'
|
|
23
|
+
Requires-Dist: reflectosim[torch]>=0.1,<0.2 ; extra == 'torch'
|
|
24
|
+
Requires-Python: >=3.12
|
|
25
|
+
Project-URL: Homepage, https://github.com/NaNIsaac/ReflectoFit
|
|
26
|
+
Project-URL: Repository, https://github.com/NaNIsaac/ReflectoFit
|
|
27
|
+
Project-URL: Issues, https://github.com/NaNIsaac/ReflectoFit/issues
|
|
28
|
+
Project-URL: Changelog, https://github.com/NaNIsaac/ReflectoFit/blob/main/CHANGELOG.md
|
|
29
|
+
Provides-Extra: mcmc
|
|
30
|
+
Provides-Extra: torch
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# ReflectoFit
|
|
34
|
+
|
|
35
|
+
[](https://github.com/NaNIsaac/ReflectoFit/actions/workflows/ci.yml) [](LICENSE)
|
|
36
|
+
|
|
37
|
+
<img src="https://raw.githubusercontent.com/NaNIsaac/ReflectoFit/main/docs/media/fit.png" width="720" alt="The first run below: Poisson counts from examples/film.toml, the starting model and the fit, with normalised residuals">
|
|
38
|
+
|
|
39
|
+
A fast X-ray reflectivity fitting engine and command line: layer models, an instrument model, least
|
|
40
|
+
squares, differential evolution and MCMC, model selection, and fit reports.
|
|
41
|
+
|
|
42
|
+
It is for anyone with a measured specular XRR curve of a thin film who wants the thickness, roughness
|
|
43
|
+
and density of each layer. The forward model is a float64 Parratt recursion (compiled with numba)
|
|
44
|
+
with Névot-Croce roughness, Gaussian resolution, beam footprint, Cu Kα₂ and a 2θ offset; it agrees
|
|
45
|
+
with refnx to 1e-10. A torch backend gives the same curves with gradients. The forward physics
|
|
46
|
+
lives in [ReflectoSim](https://github.com/NaNIsaac/ReflectoSim), which also generates synthetic XRR
|
|
47
|
+
data for machine learning; ReflectoFit is the inverse problem on top of it.
|
|
48
|
+
|
|
49
|
+
Status: early development. The design is in [`docs/DESIGN.md`](docs/DESIGN.md); model selection
|
|
50
|
+
follows.
|
|
51
|
+
|
|
52
|
+
<img src="https://raw.githubusercontent.com/NaNIsaac/ReflectoFit/main/docs/media/throughput.png" width="720" alt="Forward-model curves per second against batch size for seven engines, for one film, five layers and a 50-layer superlattice">
|
|
53
|
+
|
|
54
|
+
Forward-model throughput on the benchmark machine (`docs/design-bench/throughput.json`, DESIGN.md section 5). The numba backend computes about 57 000 five-layer curves per second in batches of 100 000 on CPU; hollow markers are values scaled or extrapolated from a smaller batch.
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
Not on PyPI yet. From a clone, with [uv](https://docs.astral.sh/uv/):
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
git clone https://github.com/NaNIsaac/ReflectoFit
|
|
62
|
+
cd ReflectoFit
|
|
63
|
+
uv sync
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## First run
|
|
67
|
+
|
|
68
|
+
Simulate a measured curve of a 240 Å film on SiO₂ on Si with Poisson counts, then fit it from a wrong
|
|
69
|
+
starting guess:
|
|
70
|
+
|
|
71
|
+
<!-- test -->
|
|
72
|
+
```sh
|
|
73
|
+
uv run reflectofit calc --model examples/film.toml --counts 1e8 -o film.dat
|
|
74
|
+
uv run reflectofit fit film.dat --model examples/film_start.toml --loss poisson --counts-scale 1e8 -o fit
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The fit prints each free parameter with its standard error and writes `fit/result.json`,
|
|
78
|
+
`fit/model.toml` (the fitted model, reusable as a start) and `fit/fitted.dat`.
|
|
79
|
+
|
|
80
|
+
Model files, every command and the Python API: [docs/usage.md](docs/usage.md).
|
|
81
|
+
|
|
82
|
+
## Development
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
uv run pytest
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# ReflectoFit
|
|
2
|
+
|
|
3
|
+
[](https://github.com/NaNIsaac/ReflectoFit/actions/workflows/ci.yml) [](LICENSE)
|
|
4
|
+
|
|
5
|
+
<img src="https://raw.githubusercontent.com/NaNIsaac/ReflectoFit/main/docs/media/fit.png" width="720" alt="The first run below: Poisson counts from examples/film.toml, the starting model and the fit, with normalised residuals">
|
|
6
|
+
|
|
7
|
+
A fast X-ray reflectivity fitting engine and command line: layer models, an instrument model, least
|
|
8
|
+
squares, differential evolution and MCMC, model selection, and fit reports.
|
|
9
|
+
|
|
10
|
+
It is for anyone with a measured specular XRR curve of a thin film who wants the thickness, roughness
|
|
11
|
+
and density of each layer. The forward model is a float64 Parratt recursion (compiled with numba)
|
|
12
|
+
with Névot-Croce roughness, Gaussian resolution, beam footprint, Cu Kα₂ and a 2θ offset; it agrees
|
|
13
|
+
with refnx to 1e-10. A torch backend gives the same curves with gradients. The forward physics
|
|
14
|
+
lives in [ReflectoSim](https://github.com/NaNIsaac/ReflectoSim), which also generates synthetic XRR
|
|
15
|
+
data for machine learning; ReflectoFit is the inverse problem on top of it.
|
|
16
|
+
|
|
17
|
+
Status: early development. The design is in [`docs/DESIGN.md`](docs/DESIGN.md); model selection
|
|
18
|
+
follows.
|
|
19
|
+
|
|
20
|
+
<img src="https://raw.githubusercontent.com/NaNIsaac/ReflectoFit/main/docs/media/throughput.png" width="720" alt="Forward-model curves per second against batch size for seven engines, for one film, five layers and a 50-layer superlattice">
|
|
21
|
+
|
|
22
|
+
Forward-model throughput on the benchmark machine (`docs/design-bench/throughput.json`, DESIGN.md section 5). The numba backend computes about 57 000 five-layer curves per second in batches of 100 000 on CPU; hollow markers are values scaled or extrapolated from a smaller batch.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
Not on PyPI yet. From a clone, with [uv](https://docs.astral.sh/uv/):
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
git clone https://github.com/NaNIsaac/ReflectoFit
|
|
30
|
+
cd ReflectoFit
|
|
31
|
+
uv sync
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## First run
|
|
35
|
+
|
|
36
|
+
Simulate a measured curve of a 240 Å film on SiO₂ on Si with Poisson counts, then fit it from a wrong
|
|
37
|
+
starting guess:
|
|
38
|
+
|
|
39
|
+
<!-- test -->
|
|
40
|
+
```sh
|
|
41
|
+
uv run reflectofit calc --model examples/film.toml --counts 1e8 -o film.dat
|
|
42
|
+
uv run reflectofit fit film.dat --model examples/film_start.toml --loss poisson --counts-scale 1e8 -o fit
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The fit prints each free parameter with its standard error and writes `fit/result.json`,
|
|
46
|
+
`fit/model.toml` (the fitted model, reusable as a start) and `fit/fitted.dat`.
|
|
47
|
+
|
|
48
|
+
Model files, every command and the Python API: [docs/usage.md](docs/usage.md).
|
|
49
|
+
|
|
50
|
+
## Development
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
uv run pytest
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
MIT
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "reflectofit"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "X-ray reflectivity fitting engine and command line"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
keywords = [
|
|
9
|
+
"x-ray reflectivity",
|
|
10
|
+
"XRR",
|
|
11
|
+
"thin films",
|
|
12
|
+
"parratt",
|
|
13
|
+
"fitting",
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Topic :: Scientific/Engineering :: Physics",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
requires-python = ">=3.12"
|
|
27
|
+
dependencies = [
|
|
28
|
+
"numpy>=2.0",
|
|
29
|
+
"scipy>=1.13",
|
|
30
|
+
"reflectosim>=0.1,<0.2",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[[project.authors]]
|
|
34
|
+
name = "Isaac Yong"
|
|
35
|
+
email = "isaacyong@naver.com"
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
torch = ["reflectosim[torch]>=0.1,<0.2"]
|
|
39
|
+
mcmc = ["emcee>=3.1"]
|
|
40
|
+
|
|
41
|
+
[project.scripts]
|
|
42
|
+
reflectofit = "reflectofit.cli:main"
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://github.com/NaNIsaac/ReflectoFit"
|
|
46
|
+
Repository = "https://github.com/NaNIsaac/ReflectoFit"
|
|
47
|
+
Issues = "https://github.com/NaNIsaac/ReflectoFit/issues"
|
|
48
|
+
Changelog = "https://github.com/NaNIsaac/ReflectoFit/blob/main/CHANGELOG.md"
|
|
49
|
+
|
|
50
|
+
[dependency-groups]
|
|
51
|
+
dev = [
|
|
52
|
+
"pytest>=8.0",
|
|
53
|
+
"torch>=2.5",
|
|
54
|
+
"emcee>=3.1",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[[tool.uv.index]]
|
|
58
|
+
name = "pytorch-cpu"
|
|
59
|
+
url = "https://download.pytorch.org/whl/cpu"
|
|
60
|
+
explicit = true
|
|
61
|
+
|
|
62
|
+
[[tool.uv.sources.torch]]
|
|
63
|
+
index = "pytorch-cpu"
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
testpaths = ["tests"]
|
|
67
|
+
|
|
68
|
+
[tool.ruff]
|
|
69
|
+
line-length = 100
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint]
|
|
72
|
+
extend-select = [
|
|
73
|
+
"B",
|
|
74
|
+
"I",
|
|
75
|
+
"UP",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
[build-system]
|
|
79
|
+
requires = ["uv_build>=0.12.7,<0.13"]
|
|
80
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "reflectofit"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "X-ray reflectivity fitting engine and command line"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Isaac Yong", email = "isaacyong@naver.com" }]
|
|
9
|
+
keywords = ["x-ray reflectivity", "XRR", "thin films", "parratt", "fitting"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
12
|
+
"Intended Audience :: Science/Research",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Programming Language :: Python :: 3.13",
|
|
16
|
+
"Programming Language :: Python :: 3.14",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Scientific/Engineering :: Physics",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
requires-python = ">=3.12"
|
|
22
|
+
dependencies = [
|
|
23
|
+
"numpy>=2.0",
|
|
24
|
+
"scipy>=1.13",
|
|
25
|
+
"reflectosim>=0.1,<0.2",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
torch = ["reflectosim[torch]>=0.1,<0.2"]
|
|
30
|
+
mcmc = ["emcee>=3.1"]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
reflectofit = "reflectofit.cli:main"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/NaNIsaac/ReflectoFit"
|
|
37
|
+
Repository = "https://github.com/NaNIsaac/ReflectoFit"
|
|
38
|
+
Issues = "https://github.com/NaNIsaac/ReflectoFit/issues"
|
|
39
|
+
Changelog = "https://github.com/NaNIsaac/ReflectoFit/blob/main/CHANGELOG.md"
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=8.0",
|
|
44
|
+
"torch>=2.5",
|
|
45
|
+
"emcee>=3.1",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# CPU torch wheels for this project's own lock (tests, CI); installs from PyPI are not affected.
|
|
49
|
+
[[tool.uv.index]]
|
|
50
|
+
name = "pytorch-cpu"
|
|
51
|
+
url = "https://download.pytorch.org/whl/cpu"
|
|
52
|
+
explicit = true
|
|
53
|
+
|
|
54
|
+
[tool.uv.sources]
|
|
55
|
+
torch = [{ index = "pytorch-cpu" }]
|
|
56
|
+
|
|
57
|
+
[build-system]
|
|
58
|
+
requires = ["uv_build>=0.12.7,<0.13"]
|
|
59
|
+
build-backend = "uv_build"
|
|
60
|
+
|
|
61
|
+
[tool.pytest.ini_options]
|
|
62
|
+
testpaths = ["tests"]
|
|
63
|
+
|
|
64
|
+
[tool.ruff]
|
|
65
|
+
line-length = 100
|
|
66
|
+
|
|
67
|
+
[tool.ruff.lint]
|
|
68
|
+
extend-select = ["B", "I", "UP"]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Command line: reflectofit calc | fit | score | stitch."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from reflectosim.grid import q_grid
|
|
10
|
+
from reflectosim.noise import poisson_counts
|
|
11
|
+
|
|
12
|
+
from reflectofit import __version__, data, fit, model
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _calc(args) -> int:
|
|
16
|
+
m = model.load(args.model)
|
|
17
|
+
q = q_grid(args.grid, m.instrument.wavelength)
|
|
18
|
+
r = m.curves(q, m.start())[0]
|
|
19
|
+
y = (
|
|
20
|
+
poisson_counts(r[None, :], args.counts, np.random.default_rng(args.seed))[0]
|
|
21
|
+
if args.counts
|
|
22
|
+
else r
|
|
23
|
+
)
|
|
24
|
+
data.write(args.out, data.Curve(q, y, meta={"wavelength": m.instrument.wavelength}), x=args.x)
|
|
25
|
+
print(f"wrote {args.out} ({q.size} points{', Poisson counts' if args.counts else ''})")
|
|
26
|
+
return 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _fit(args) -> int:
|
|
30
|
+
m = model.load(args.model)
|
|
31
|
+
curve = data.read(args.data, x=args.x, wavelength=m.instrument.wavelength)
|
|
32
|
+
common = {"loss": args.loss, "counts_scale": args.counts_scale}
|
|
33
|
+
if args.method == "lsq":
|
|
34
|
+
res = fit.lsq(m, curve, trust=args.trust, max_nfev=args.max_nfev, **common)
|
|
35
|
+
else:
|
|
36
|
+
res = fit.de(m, curve, seed=args.seed, **common)
|
|
37
|
+
out = Path(args.out)
|
|
38
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
(out / "result.json").write_text(res.to_json(), encoding="utf-8")
|
|
40
|
+
(out / "model.toml").write_text(model.dumps(res.model), encoding="utf-8")
|
|
41
|
+
lam = res.model.curves(curve.q, res.model.start())[0] * (args.counts_scale or 1.0)
|
|
42
|
+
data.write(out / "fitted.dat", data.Curve(curve.q, lam, meta=curve.meta), x=args.x)
|
|
43
|
+
width = max(len(k) for k in res.values)
|
|
44
|
+
for k, v in res.values.items():
|
|
45
|
+
print(f"{k:<{width}} {v:.6g} +/- {res.errors[k]:.2g}")
|
|
46
|
+
if res.at_bound:
|
|
47
|
+
print(f"on a bound (errors not meaningful): {', '.join(res.at_bound)}")
|
|
48
|
+
print(f"{res.loss} objective {res.objective:.6g} over {res.n_points} points; {res.message}")
|
|
49
|
+
print(f"wrote {out / 'result.json'}, {out / 'model.toml'}, {out / 'fitted.dat'}")
|
|
50
|
+
return 0 if res.success else 2
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _score(args) -> int:
|
|
54
|
+
m = model.load(args.model)
|
|
55
|
+
curve = data.read(args.data, x=args.x, wavelength=m.instrument.wavelength)
|
|
56
|
+
print(json.dumps(fit.score(m, curve, counts_scale=args.counts_scale), indent=1))
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _stitch(args) -> int:
|
|
61
|
+
scans = [np.loadtxt(p, ndmin=2) for p in args.scans]
|
|
62
|
+
out = data.stitch(scans, method=args.method)
|
|
63
|
+
np.savetxt(args.out, out)
|
|
64
|
+
print(f"wrote {args.out} ({len(out)} points from {len(scans)} scans)")
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main(argv: list[str] | None = None) -> int:
|
|
69
|
+
p = argparse.ArgumentParser(prog="reflectofit", description="X-ray reflectivity fitting")
|
|
70
|
+
p.add_argument("--version", action="version", version=f"reflectofit {__version__}")
|
|
71
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
72
|
+
|
|
73
|
+
c = sub.add_parser("calc", help="compute a model curve, optionally with Poisson counts")
|
|
74
|
+
c.add_argument("--model", required=True, help="model TOML")
|
|
75
|
+
c.add_argument("--grid", default="tth:0.1:6:300", help="tth:START:STOP:N or q:START:STOP:N")
|
|
76
|
+
c.add_argument(
|
|
77
|
+
"--counts", type=float, default=None, help="counts at R = 1; draws Poisson counts"
|
|
78
|
+
)
|
|
79
|
+
c.add_argument("--seed", type=int, default=0)
|
|
80
|
+
c.add_argument("--x", choices=("tth", "q"), default="tth", help="x column of the output")
|
|
81
|
+
c.add_argument("-o", "--out", required=True)
|
|
82
|
+
c.set_defaults(run=_calc)
|
|
83
|
+
|
|
84
|
+
for name, helptext, run in (
|
|
85
|
+
("fit", "fit a model to a curve", _fit),
|
|
86
|
+
("score", "losses of a model", _score),
|
|
87
|
+
):
|
|
88
|
+
f = sub.add_parser(name, help=helptext)
|
|
89
|
+
f.add_argument("data", help="2- or 3-column text: x, intensity[, error]")
|
|
90
|
+
f.add_argument("--model", required=True, help="model TOML with [fit] bounds")
|
|
91
|
+
f.add_argument("--x", choices=("tth", "q"), default="tth", help="x column of the data")
|
|
92
|
+
f.add_argument(
|
|
93
|
+
"--counts-scale", type=float, default=None, help="data are counts; counts at R = 1"
|
|
94
|
+
)
|
|
95
|
+
f.set_defaults(run=run)
|
|
96
|
+
if name == "fit":
|
|
97
|
+
f.add_argument("--method", choices=("lsq", "de"), default="lsq")
|
|
98
|
+
f.add_argument("--loss", choices=("log", "raw", "poisson", "chi2"), default="log")
|
|
99
|
+
f.add_argument(
|
|
100
|
+
"--trust", type=float, default=None, help="limit each start to +/- fraction"
|
|
101
|
+
)
|
|
102
|
+
f.add_argument("--max-nfev", type=int, default=200)
|
|
103
|
+
f.add_argument("--seed", type=int, default=0)
|
|
104
|
+
f.add_argument("-o", "--out", default="fit", help="output directory")
|
|
105
|
+
|
|
106
|
+
s = sub.add_parser("stitch", help="join scans measured through different attenuators")
|
|
107
|
+
s.add_argument("scans", nargs="+", help="scan files (x, intensity[, error]) in increasing x")
|
|
108
|
+
s.add_argument("--method", choices=("boundary", "median"), default="boundary")
|
|
109
|
+
s.add_argument("-o", "--out", required=True)
|
|
110
|
+
s.set_defaults(run=_stitch)
|
|
111
|
+
|
|
112
|
+
args = p.parse_args(argv)
|
|
113
|
+
return args.run(args)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
sys.exit(main())
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Measured curves: reading, stitching attenuated segments, held-out splits.
|
|
2
|
+
|
|
3
|
+
Measured files are never part of this repository (DESIGN.md section 6); tests use synthetic files.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from numpy.typing import NDArray
|
|
12
|
+
from reflectosim.units import CU_KALPHA1, q_to_tth, tth_to_q
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Curve:
|
|
17
|
+
q: NDArray[np.float64]
|
|
18
|
+
y: NDArray[np.float64]
|
|
19
|
+
sigma: NDArray[np.float64] | None = None
|
|
20
|
+
meta: dict = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
def tth(self, wavelength: float | None = None) -> NDArray[np.float64]:
|
|
23
|
+
return q_to_tth(self.q, wavelength or self.meta.get("wavelength", CU_KALPHA1))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read(path: str | Path, x: Literal["tth", "q"] = "tth", wavelength: float = CU_KALPHA1) -> Curve:
|
|
27
|
+
"""Read 2- or 3-column text (x, intensity[, error]); whitespace or commas, comment and header
|
|
28
|
+
lines skipped. Sorted by q; equal q values are kept."""
|
|
29
|
+
rows = []
|
|
30
|
+
for line in Path(path).read_text(encoding="utf-8", errors="ignore").splitlines():
|
|
31
|
+
line = line.strip()
|
|
32
|
+
if not line or line[0] in "#!;%/":
|
|
33
|
+
continue
|
|
34
|
+
parts = line.replace(",", " ").split()
|
|
35
|
+
try:
|
|
36
|
+
rows.append([float(p) for p in parts[:3]])
|
|
37
|
+
except ValueError:
|
|
38
|
+
continue
|
|
39
|
+
rows = [r for r in rows if len(r) >= 2]
|
|
40
|
+
if not rows:
|
|
41
|
+
raise ValueError(f"no numeric data with at least two columns in {path}")
|
|
42
|
+
width = min(len(r) for r in rows)
|
|
43
|
+
arr = np.array([r[:width] for r in rows])
|
|
44
|
+
q = tth_to_q(arr[:, 0], wavelength) if x == "tth" else arr[:, 0]
|
|
45
|
+
order = np.argsort(q, kind="stable")
|
|
46
|
+
sigma = arr[order, 2] if width >= 3 else None
|
|
47
|
+
meta = {"source": str(path), "x": x, "wavelength": wavelength}
|
|
48
|
+
return Curve(np.asarray(q)[order], arr[order, 1], sigma, meta)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def write(path: str | Path, curve: Curve, x: Literal["tth", "q"] = "tth") -> None:
|
|
52
|
+
cols = [curve.tth() if x == "tth" else curve.q, curve.y]
|
|
53
|
+
if curve.sigma is not None:
|
|
54
|
+
cols.append(curve.sigma)
|
|
55
|
+
header = f"{'2theta_deg' if x == 'tth' else 'q_invA'} intensity" + (
|
|
56
|
+
" error" if curve.sigma is not None else ""
|
|
57
|
+
)
|
|
58
|
+
np.savetxt(path, np.column_stack(cols), header=header)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def stitch(
|
|
62
|
+
scans: list[NDArray], method: Literal["boundary", "median"] = "boundary"
|
|
63
|
+
) -> NDArray[np.float64]:
|
|
64
|
+
"""Join scans (N, 2+) of [x, intensity, (error, ...)] measured through different attenuators.
|
|
65
|
+
|
|
66
|
+
boundary (xrr_combine, ReflectoLearn): scans in increasing x share exactly one boundary point;
|
|
67
|
+
the last scan is the reference, each earlier scan is scaled so its last point equals the next
|
|
68
|
+
scan's first point, and the duplicate is dropped.
|
|
69
|
+
median (xray_ooguy): the first scan is the reference; each later scan is scaled by the median of
|
|
70
|
+
the point-by-point ratio to the curve assembled so far (linear interpolation) over their overlap,
|
|
71
|
+
which needs at least two points; all points are kept. A scan without such an overlap raises.
|
|
72
|
+
Every column after x is scaled together.
|
|
73
|
+
"""
|
|
74
|
+
scans = [np.asarray(s, dtype=float) for s in scans]
|
|
75
|
+
if not scans:
|
|
76
|
+
return np.empty((0, 3))
|
|
77
|
+
if method == "boundary":
|
|
78
|
+
out = [scans[-1]]
|
|
79
|
+
head = scans[-1][0, 1]
|
|
80
|
+
for scan in reversed(scans[:-1]):
|
|
81
|
+
scan = scan.copy()
|
|
82
|
+
scan[:, 1:] *= head / scan[-1, 1]
|
|
83
|
+
scan = scan[:-1]
|
|
84
|
+
head = scan[0, 1]
|
|
85
|
+
out.append(scan)
|
|
86
|
+
return np.concatenate(out[::-1])
|
|
87
|
+
if method != "median":
|
|
88
|
+
raise ValueError(f"unknown method {method!r}; use 'boundary' or 'median'")
|
|
89
|
+
order = sorted(scans, key=lambda s: s[0, 0])
|
|
90
|
+
acc = order[0].copy()
|
|
91
|
+
for scan in order[1:]:
|
|
92
|
+
inside = scan[:, 0] <= acc[-1, 0]
|
|
93
|
+
prev = np.interp(scan[inside, 0], acc[:, 0], acc[:, 1], left=np.nan)
|
|
94
|
+
ok = np.isfinite(prev) & (prev > 0) & (scan[inside, 1] > 0)
|
|
95
|
+
if ok.sum() < 2:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"scan starting at x = {scan[0, 0]} overlaps the curve in fewer than two points"
|
|
98
|
+
)
|
|
99
|
+
scaled = scan.copy()
|
|
100
|
+
scaled[:, 1:] *= np.median(prev[ok] / scan[inside, 1][ok])
|
|
101
|
+
acc = np.concatenate([acc, scaled])
|
|
102
|
+
acc = acc[np.argsort(acc[:, 0], kind="stable")]
|
|
103
|
+
return acc
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def block_split(
|
|
107
|
+
q: NDArray, n_blocks: int, held: tuple[int, ...], q_min: float = 0.0
|
|
108
|
+
) -> tuple[NDArray[np.bool_], NDArray[np.bool_]]:
|
|
109
|
+
"""Training and held-out masks: points with q < q_min always train; the rest are cut into
|
|
110
|
+
`n_blocks` contiguous blocks of equal size (np.array_split) and the blocks in `held` (0-based)
|
|
111
|
+
are held out (ReflectoLearn#35 section 3.2)."""
|
|
112
|
+
q = np.asarray(q)
|
|
113
|
+
upper = np.flatnonzero(q >= q_min)
|
|
114
|
+
held_mask = np.zeros(q.shape, bool)
|
|
115
|
+
for i, block in enumerate(np.array_split(upper, n_blocks)):
|
|
116
|
+
if i in held:
|
|
117
|
+
held_mask[block] = True
|
|
118
|
+
return ~held_mask, held_mask
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Quick estimators read directly off a curve, without a fit."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from numpy.typing import NDArray
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def critical_q(
|
|
10
|
+
q: NDArray, y: NDArray, rule: Literal["steepest", "half_plateau"] = "steepest"
|
|
11
|
+
) -> float:
|
|
12
|
+
"""q of the critical edge.
|
|
13
|
+
|
|
14
|
+
steepest: the steepest descent of log R above the plateau (the minimum of d log10 R / dq,
|
|
15
|
+
refined by a parabola through it and its neighbours).
|
|
16
|
+
half_plateau: where the 3-point mean of y first falls to half the plateau, the plateau being
|
|
17
|
+
the largest 3-point mean in the first 10 % of points; linear interpolation between the two
|
|
18
|
+
points around the crossing (the rule of ReflectoLearn#35 protocol A1, step 2). NaN if it never
|
|
19
|
+
falls that far.
|
|
20
|
+
|
|
21
|
+
Neither is exactly the film's q_c: the half-plateau point is about 9 % high for 250 Å films and
|
|
22
|
+
can be several times low for 20-40 Å dense films whose edge tunnels (physics review on
|
|
23
|
+
ReflectoLearn#35).
|
|
24
|
+
"""
|
|
25
|
+
q = np.asarray(q, float)
|
|
26
|
+
y = np.asarray(y, float)
|
|
27
|
+
if rule == "half_plateau":
|
|
28
|
+
ys = np.convolve(y, np.ones(3) / 3, mode="same")
|
|
29
|
+
ys[0], ys[-1] = y[0], y[-1]
|
|
30
|
+
top = int(np.argmax(ys[: max(len(ys) // 10, 3)]))
|
|
31
|
+
below = np.flatnonzero(ys[top:] < 0.5 * ys[top])
|
|
32
|
+
if below.size == 0:
|
|
33
|
+
return float("nan")
|
|
34
|
+
k = top + int(below[0])
|
|
35
|
+
if k == 0:
|
|
36
|
+
return float(q[0])
|
|
37
|
+
f = (ys[k - 1] - 0.5 * ys[top]) / (ys[k - 1] - ys[k])
|
|
38
|
+
return float(q[k - 1] + f * (q[k] - q[k - 1]))
|
|
39
|
+
if rule != "steepest":
|
|
40
|
+
raise ValueError(f"unknown rule {rule!r}; use 'steepest' or 'half_plateau'")
|
|
41
|
+
logy = np.log10(np.clip(y, np.finfo(float).tiny, None))
|
|
42
|
+
start = int(np.argmax(logy))
|
|
43
|
+
slope = np.gradient(logy[start:], q[start:])
|
|
44
|
+
i = int(np.argmin(slope))
|
|
45
|
+
if 0 < i < slope.size - 1:
|
|
46
|
+
a, b, c = slope[i - 1 : i + 2]
|
|
47
|
+
denom = a - 2 * b + c
|
|
48
|
+
if denom > 0:
|
|
49
|
+
dq = q[start + i + 1] - q[start + i]
|
|
50
|
+
return float(q[start + i] + 0.5 * (a - c) / denom * dq)
|
|
51
|
+
return float(q[start + i])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def kiessig_thickness(
|
|
55
|
+
q: NDArray,
|
|
56
|
+
y: NDArray,
|
|
57
|
+
d_range: tuple[float, float] = (20.0, 2000.0),
|
|
58
|
+
sld: float | None = None,
|
|
59
|
+
d_min: float = 60.0,
|
|
60
|
+
mask: NDArray | None = None,
|
|
61
|
+
) -> float:
|
|
62
|
+
"""Film thickness [Å] from the Kiessig period: the strongest peak of the real-space transform
|
|
63
|
+
of the detrended log R (port of ReflectoLearn `refine.fft_thickness`, b6fb0c4).
|
|
64
|
+
|
|
65
|
+
Only points with y > 0 (and inside `mask`, if given) are used: a zero-count point has no
|
|
66
|
+
logarithm. Fringes are periodic in the film's own wavevector sqrt(q² − q_c²); with `sld`
|
|
67
|
+
[1e-6 Å⁻²] the transform runs over it, otherwise over q (which overestimates d by a few percent
|
|
68
|
+
near the edge). NaN with fewer than 40 usable points or no peak in [max(d_min, lo), hi].
|
|
69
|
+
"""
|
|
70
|
+
q = np.asarray(q, float)
|
|
71
|
+
y = np.asarray(y, float)
|
|
72
|
+
ok = y > 0 if mask is None else (y > 0) & np.asarray(mask, bool)
|
|
73
|
+
usable = np.flatnonzero(ok)
|
|
74
|
+
if usable.size < 40:
|
|
75
|
+
return float("nan")
|
|
76
|
+
logy = np.log10(y[usable])
|
|
77
|
+
qu = q[usable]
|
|
78
|
+
qc2 = 16e-6 * np.pi * max(sld, 0.0) if sld is not None else 0.0
|
|
79
|
+
z = np.arange(max(d_min, d_range[0]) - 1.0, d_range[1] + 2.0, 1.0)
|
|
80
|
+
start = int(np.argmax(logy)) # skip the plateau and the critical edge
|
|
81
|
+
idx = np.flatnonzero((np.arange(qu.size) > start + 5) & (qu**2 > 1.2 * qc2))
|
|
82
|
+
if idx.size < 40:
|
|
83
|
+
return float("nan")
|
|
84
|
+
qq, yy = np.sqrt(qu[idx] ** 2 - qc2), logy[idx]
|
|
85
|
+
yy = yy - np.polyval(np.polyfit(qq, yy, 5), qq)
|
|
86
|
+
yy = yy * np.hanning(yy.size)
|
|
87
|
+
amp = np.abs(np.exp(-1j * np.outer(z, qq)) @ yy)
|
|
88
|
+
peak = (amp[1:-1] > amp[:-2]) & (amp[1:-1] >= amp[2:])
|
|
89
|
+
return float(z[1:-1][peak][np.argmax(amp[1:-1][peak])]) if peak.any() else float("nan")
|