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.
@@ -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
+ [![Tests](https://github.com/NaNIsaac/ReflectoFit/actions/workflows/ci.yml/badge.svg)](https://github.com/NaNIsaac/ReflectoFit/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](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
+ [![Tests](https://github.com/NaNIsaac/ReflectoFit/actions/workflows/ci.yml/badge.svg)](https://github.com/NaNIsaac/ReflectoFit/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](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,5 @@
1
+ """X-ray reflectivity fitting engine on the ReflectoSim forward model."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("reflectofit")
@@ -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")