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.
- reflectofit/__init__.py +5 -0
- reflectofit/cli.py +117 -0
- reflectofit/data.py +118 -0
- reflectofit/estimate.py +89 -0
- reflectofit/fit.py +490 -0
- reflectofit/metrics.py +95 -0
- reflectofit/model.py +204 -0
- reflectofit/py.typed +0 -0
- reflectofit/staged.py +293 -0
- reflectofit-0.1.0.dist-info/METADATA +90 -0
- reflectofit-0.1.0.dist-info/RECORD +14 -0
- reflectofit-0.1.0.dist-info/WHEEL +4 -0
- reflectofit-0.1.0.dist-info/entry_points.txt +3 -0
- reflectofit-0.1.0.dist-info/licenses/LICENSE +21 -0
reflectofit/__init__.py
ADDED
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
|
reflectofit/estimate.py
ADDED
|
@@ -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")
|