reflectofit 0.1.0__py3-none-any.whl

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,5 @@
1
+ """X-ray reflectivity fitting engine on the ReflectoSim forward model."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("reflectofit")
reflectofit/cli.py ADDED
@@ -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())
reflectofit/data.py ADDED
@@ -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")