reflectosim 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.
- reflectosim/__init__.py +32 -0
- reflectosim/cli.py +76 -0
- reflectosim/dataset.py +84 -0
- reflectosim/forward.py +305 -0
- reflectosim/grid.py +16 -0
- reflectosim/kernel.py +148 -0
- reflectosim/materials.py +15 -0
- reflectosim/noise.py +27 -0
- reflectosim/priors.py +216 -0
- reflectosim/py.typed +0 -0
- reflectosim/resolution.py +494 -0
- reflectosim/simulate.py +142 -0
- reflectosim/structure.py +79 -0
- reflectosim/torch_backend.py +131 -0
- reflectosim/units.py +31 -0
- reflectosim-0.1.0.dist-info/METADATA +128 -0
- reflectosim-0.1.0.dist-info/RECORD +20 -0
- reflectosim-0.1.0.dist-info/WHEEL +4 -0
- reflectosim-0.1.0.dist-info/entry_points.txt +3 -0
- reflectosim-0.1.0.dist-info/licenses/LICENSE +21 -0
reflectosim/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""X-ray reflectivity forward physics and realistic synthetic data for machine learning."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from reflectosim import materials
|
|
6
|
+
from reflectosim.forward import Instrument, forward
|
|
7
|
+
from reflectosim.priors import InstrumentPrior, OxidePrior, StackPrior, SubstratePrior
|
|
8
|
+
from reflectosim.simulate import Batch, simulate
|
|
9
|
+
from reflectosim.structure import Layer, Stack
|
|
10
|
+
from reflectosim.units import CU_KALPHA1, CU_KALPHA2, fwhm_to_sigma, q_to_tth, tth_to_q
|
|
11
|
+
|
|
12
|
+
__version__ = version("reflectosim")
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CU_KALPHA1",
|
|
16
|
+
"CU_KALPHA2",
|
|
17
|
+
"Batch",
|
|
18
|
+
"Instrument",
|
|
19
|
+
"InstrumentPrior",
|
|
20
|
+
"Layer",
|
|
21
|
+
"OxidePrior",
|
|
22
|
+
"Stack",
|
|
23
|
+
"StackPrior",
|
|
24
|
+
"SubstratePrior",
|
|
25
|
+
"__version__",
|
|
26
|
+
"forward",
|
|
27
|
+
"fwhm_to_sigma",
|
|
28
|
+
"materials",
|
|
29
|
+
"q_to_tth",
|
|
30
|
+
"simulate",
|
|
31
|
+
"tth_to_q",
|
|
32
|
+
]
|
reflectosim/cli.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Command line: reflectosim sim | describe."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from reflectosim import __version__, dataset, priors
|
|
10
|
+
from reflectosim.grid import q_grid
|
|
11
|
+
from reflectosim.simulate import simulate
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _seed(text: str):
|
|
15
|
+
parts = [int(p) for p in text.split(",")]
|
|
16
|
+
return parts[0] if len(parts) == 1 else tuple(parts)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _sim(args) -> int:
|
|
20
|
+
stack, inst = priors.load(args.prior)
|
|
21
|
+
q = q_grid(args.grid, inst.wavelength)
|
|
22
|
+
batch = simulate(stack, inst, q, n=args.n, seed=_seed(args.seed))
|
|
23
|
+
out = dataset.write(args.out, batch, stack, inst)
|
|
24
|
+
print(f"wrote {out}: {args.n} curves x {q.size} points, oxide {stack.oxide}, "
|
|
25
|
+
f"resolution nodes {batch.nodes or 'per point'}") # fmt: skip
|
|
26
|
+
return 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _describe(args) -> int:
|
|
30
|
+
arrays, params, meta = dataset.read(args.dataset)
|
|
31
|
+
n, nq = arrays["counts"].shape
|
|
32
|
+
print(
|
|
33
|
+
f"{args.dataset}: {n} curves x {nq} points, q {arrays['q'][0]:.4g}-{arrays['q'][-1]:.4g} 1/A"
|
|
34
|
+
)
|
|
35
|
+
width = max(len(k) for k in params)
|
|
36
|
+
for k, v in params.items():
|
|
37
|
+
ok = np.isfinite(v)
|
|
38
|
+
if ok.any():
|
|
39
|
+
print(f" {k:<{width}} {v[ok].min():.4g} .. {v[ok].max():.4g} ({ok.sum()} rows)")
|
|
40
|
+
print(
|
|
41
|
+
json.dumps(
|
|
42
|
+
{
|
|
43
|
+
k: meta[k]
|
|
44
|
+
for k in ("reflectosim", "reflectofit", "seed", "resolution_nodes")
|
|
45
|
+
if k in meta
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main(argv: list[str] | None = None) -> int:
|
|
53
|
+
p = argparse.ArgumentParser(prog="reflectosim", description="synthetic X-ray reflectivity data")
|
|
54
|
+
p.add_argument("--version", action="version", version=f"reflectosim {__version__}")
|
|
55
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
56
|
+
|
|
57
|
+
s = sub.add_parser("sim", help="simulate a dataset from a prior TOML")
|
|
58
|
+
s.add_argument(
|
|
59
|
+
"--prior", required=True, help="prior TOML: [stack], [oxide], [substrate], [instrument]"
|
|
60
|
+
)
|
|
61
|
+
s.add_argument("--n", type=int, required=True, help="number of curves")
|
|
62
|
+
s.add_argument("--grid", default="tth:0.05:8:500", help="tth:START:STOP:N or q:START:STOP:N")
|
|
63
|
+
s.add_argument("--seed", default="0", help="an int or comma-separated ints, e.g. 20261024,1")
|
|
64
|
+
s.add_argument("-o", "--out", required=True, help="output .h5 (needs h5py) or .npz")
|
|
65
|
+
s.set_defaults(run=_sim)
|
|
66
|
+
|
|
67
|
+
d = sub.add_parser("describe", help="summarise a dataset file")
|
|
68
|
+
d.add_argument("dataset")
|
|
69
|
+
d.set_defaults(run=_describe)
|
|
70
|
+
|
|
71
|
+
args = p.parse_args(argv)
|
|
72
|
+
return args.run(args)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
sys.exit(main())
|
reflectosim/dataset.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Dataset files: HDF5 (with the `hdf5` extra) or compressed npz, chosen by the file suffix.
|
|
2
|
+
|
|
3
|
+
Arrays: `q` (Q,), `counts` and `reflectivity` (n, Q), `counts_scale` (n,), and one (n,) array per
|
|
4
|
+
parameter under `params/<name>` (HDF5) or `params.<name>` (npz). The metadata is one JSON string with
|
|
5
|
+
the priors, the seed, the resolution node count, the package versions and the conventions, enough
|
|
6
|
+
to regenerate the file.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from importlib.metadata import version
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from reflectosim import priors
|
|
16
|
+
from reflectosim.simulate import Batch
|
|
17
|
+
|
|
18
|
+
CONVENTIONS = (
|
|
19
|
+
"ambient first, roughness of the interface above, Im SLD > 0 "
|
|
20
|
+
"absorbs, SLD 1e-6 A^-2, q A^-1, angles in degrees; reflectivity is I0 * smeared R + background "
|
|
21
|
+
"before noise; counts are Poisson(counts_scale * reflectivity)"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def metadata(batch: Batch, stack: priors.StackPrior, instrument: priors.InstrumentPrior) -> dict:
|
|
26
|
+
seed = list(batch.seed) if isinstance(batch.seed, tuple | list) else batch.seed
|
|
27
|
+
return {
|
|
28
|
+
"reflectosim": version("reflectosim"),
|
|
29
|
+
"stack_prior": priors.to_dict(stack),
|
|
30
|
+
"instrument_prior": priors.to_dict(instrument),
|
|
31
|
+
"seed": seed,
|
|
32
|
+
"n": int(batch.counts.shape[0]),
|
|
33
|
+
"resolution_nodes": batch.nodes,
|
|
34
|
+
"backend": "reflectosim numba float64",
|
|
35
|
+
"conventions": CONVENTIONS,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write(
|
|
40
|
+
path: str | Path, batch: Batch, stack: priors.StackPrior, instrument: priors.InstrumentPrior
|
|
41
|
+
) -> Path:
|
|
42
|
+
path = Path(path)
|
|
43
|
+
meta = json.dumps(metadata(batch, stack, instrument))
|
|
44
|
+
arrays = {
|
|
45
|
+
"q": batch.q,
|
|
46
|
+
"counts": batch.counts.astype(np.float32),
|
|
47
|
+
"reflectivity": batch.reflectivity.astype(np.float32),
|
|
48
|
+
"counts_scale": batch.counts_scale,
|
|
49
|
+
}
|
|
50
|
+
if path.suffix in (".h5", ".hdf5"):
|
|
51
|
+
import h5py
|
|
52
|
+
|
|
53
|
+
with h5py.File(path, "w") as f:
|
|
54
|
+
for k, v in arrays.items():
|
|
55
|
+
f.create_dataset(k, data=v, compression="gzip" if v.ndim == 2 else None)
|
|
56
|
+
g = f.create_group("params")
|
|
57
|
+
for k, v in batch.params.items():
|
|
58
|
+
g.create_dataset(k, data=v)
|
|
59
|
+
f.attrs["metadata"] = meta
|
|
60
|
+
elif path.suffix == ".npz":
|
|
61
|
+
np.savez_compressed(
|
|
62
|
+
path, **arrays, **{f"params.{k}": v for k, v in batch.params.items()}, metadata=meta
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
raise ValueError(f"unknown dataset suffix {path.suffix!r}; use .h5 or .npz")
|
|
66
|
+
return path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def read(path: str | Path) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray], dict]:
|
|
70
|
+
"""(arrays, params, metadata) of a dataset file."""
|
|
71
|
+
path = Path(path)
|
|
72
|
+
if path.suffix in (".h5", ".hdf5"):
|
|
73
|
+
import h5py
|
|
74
|
+
|
|
75
|
+
with h5py.File(path, "r") as f:
|
|
76
|
+
arrays = {k: f[k][()] for k in ("q", "counts", "reflectivity", "counts_scale")}
|
|
77
|
+
params = {k: f["params"][k][()] for k in f["params"]}
|
|
78
|
+
meta = json.loads(f.attrs["metadata"])
|
|
79
|
+
return arrays, params, meta
|
|
80
|
+
with np.load(path) as z:
|
|
81
|
+
arrays = {k: z[k] for k in ("q", "counts", "reflectivity", "counts_scale")}
|
|
82
|
+
params = {k.removeprefix("params."): z[k] for k in z.files if k.startswith("params.")}
|
|
83
|
+
meta = json.loads(str(z["metadata"]))
|
|
84
|
+
return arrays, params, meta
|
reflectosim/forward.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Observed reflectivity: the layer kernel plus the instrument.
|
|
2
|
+
|
|
3
|
+
The nominal 2θ plus the alignment offset is the true angle; the resolution is a Gaussian in q around
|
|
4
|
+
the q of that angle. At each quadrature node: R for Kα₁ (and Kα₂ at the same angle, mixed by intensity
|
|
5
|
+
ratio) times the footprint factor at that angle. The weighted sum over nodes is scaled by I0 and the
|
|
6
|
+
background is added (DESIGN.md section 2). Every instrument field may be a scalar or a (B,) array, one value per curve.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import warnings
|
|
10
|
+
from dataclasses import dataclass, fields
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from numpy.typing import ArrayLike, NDArray
|
|
15
|
+
from scipy.special import erf
|
|
16
|
+
|
|
17
|
+
from reflectosim import resolution
|
|
18
|
+
from reflectosim.kernel import parratt, smear
|
|
19
|
+
from reflectosim.structure import as_batch
|
|
20
|
+
from reflectosim.units import CU_KALPHA1, CU_KALPHA2
|
|
21
|
+
|
|
22
|
+
MAX_TTH = 179.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Instrument:
|
|
27
|
+
"""Instrument model. Angles in degrees, lengths in mm, resolution as Gaussian σ in q."""
|
|
28
|
+
|
|
29
|
+
wavelength: float = CU_KALPHA1
|
|
30
|
+
i0: Any = 1.0
|
|
31
|
+
background: Any = 0.0
|
|
32
|
+
tth_offset: Any = 0.0
|
|
33
|
+
sigma_q: Any = 0.0
|
|
34
|
+
"""Constant resolution term, Gaussian σ [Å⁻¹]."""
|
|
35
|
+
dq_over_q: Any = 0.0
|
|
36
|
+
"""Relative resolution term: σ = dq_over_q · q. Combined in quadrature with sigma_q."""
|
|
37
|
+
kalpha2_ratio: Any = 0.0
|
|
38
|
+
"""Kα₂/Kα₁ intensity ratio at the detector; 0 for a monochromatic beam."""
|
|
39
|
+
kalpha2_wavelength: float = CU_KALPHA2
|
|
40
|
+
sample_length: Any = None
|
|
41
|
+
"""Sample length along the beam [mm]; with beam_sigma enables the footprint factor."""
|
|
42
|
+
beam_sigma: Any = None
|
|
43
|
+
"""Gaussian σ of the beam profile at the sample [mm] (FWHM / 2.3548), beam centred."""
|
|
44
|
+
nodes: int | None = None
|
|
45
|
+
"""None: each point gets the node count its resolution width, the fringes and any nearby
|
|
46
|
+
critical edge need (`reflectosim.resolution`). An int: that many nodes for every point, faster
|
|
47
|
+
and of the accuracy the count allows; also the way to vmap over resolution parameters."""
|
|
48
|
+
|
|
49
|
+
def replace(self, **changes) -> "Instrument":
|
|
50
|
+
values = {f.name: getattr(self, f.name) for f in fields(self)}
|
|
51
|
+
return Instrument(**(values | changes))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _NumpyOps:
|
|
55
|
+
asarray = staticmethod(np.asarray)
|
|
56
|
+
sqrt = staticmethod(np.sqrt)
|
|
57
|
+
sin = staticmethod(np.sin)
|
|
58
|
+
arcsin = staticmethod(np.arcsin)
|
|
59
|
+
clip = staticmethod(np.clip)
|
|
60
|
+
erf = staticmethod(erf)
|
|
61
|
+
exp = staticmethod(np.exp)
|
|
62
|
+
cat = staticmethod(np.concatenate)
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def column(x, b):
|
|
66
|
+
return np.broadcast_to(np.asarray(x, dtype=float), (b,)).reshape(b, 1, 1)
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def take_rows(values, idx):
|
|
70
|
+
"""values (B, Q) gathered at idx (B, M)."""
|
|
71
|
+
return np.take_along_axis(values, idx, axis=1)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def sum_rows(values, idx, width):
|
|
75
|
+
"""(B, width) sums of values (B, M) by idx (B, M)."""
|
|
76
|
+
b = values.shape[0]
|
|
77
|
+
flat = (idx + width * np.arange(b)[:, None]).ravel()
|
|
78
|
+
return np.bincount(flat, values.ravel(), minlength=b * width).reshape(b, width)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _numpy(x) -> np.ndarray:
|
|
82
|
+
"""Values of an array or tensor as numpy, without its graph (also under torch.func.jacfwd)."""
|
|
83
|
+
if not hasattr(x, "detach"):
|
|
84
|
+
return np.asarray(x)
|
|
85
|
+
x = x.detach()
|
|
86
|
+
try:
|
|
87
|
+
return x.cpu().numpy()
|
|
88
|
+
except RuntimeError: # a functorch-wrapped tensor has no storage; its values are still readable
|
|
89
|
+
return np.array(x.tolist())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _active(x) -> bool:
|
|
93
|
+
"""Whether an instrument term is switched on. A tensor counts as on: its value may be traced
|
|
94
|
+
(torch.func.jacfwd, vmap), so no branch may depend on it."""
|
|
95
|
+
if x is None:
|
|
96
|
+
return False
|
|
97
|
+
if hasattr(x, "detach"):
|
|
98
|
+
return True
|
|
99
|
+
return bool(np.any(np.asarray(x) != 0))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
ROWS_PER_BLOCK = 256
|
|
103
|
+
"""Curves per block of the resolution integral (bounds the flat node arrays)."""
|
|
104
|
+
NODES_PER_CALL = 2_000_000
|
|
105
|
+
"""Padded resolution nodes per kernel call (bounds the memory of a block)."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _at_angles(ops, kernel, x, packed, inst: Instrument):
|
|
109
|
+
"""R at nominal-true q values x (B, M): Kα₁ (and Kα₂ at the same angle) times the footprint."""
|
|
110
|
+
b = x.shape[0]
|
|
111
|
+
wl1 = inst.wavelength
|
|
112
|
+
# a point below q = 0 is the mirror image of a grazing angle; keep it at grazing incidence
|
|
113
|
+
sin_t = ops.clip(x * (wl1 / (4.0 * np.pi)), 1e-12, 1.0)
|
|
114
|
+
r = kernel((4.0 * np.pi / wl1) * sin_t, *packed)
|
|
115
|
+
if _active(inst.kalpha2_ratio):
|
|
116
|
+
a = ops.column(inst.kalpha2_ratio, b)[..., 0]
|
|
117
|
+
r2 = kernel((4.0 * np.pi / inst.kalpha2_wavelength) * sin_t, *packed)
|
|
118
|
+
r = (r + a * r2) / (1.0 + a)
|
|
119
|
+
if inst.sample_length is not None and inst.beam_sigma is not None:
|
|
120
|
+
length = ops.column(inst.sample_length, b)[..., 0]
|
|
121
|
+
beam = ops.column(inst.beam_sigma, b)[..., 0]
|
|
122
|
+
r = r * ops.erf(length * sin_t / (2.0 * np.sqrt(2.0) * beam))
|
|
123
|
+
return r
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _point_smear(ops, kernel, q_true, sigma, packed, inst: Instrument):
|
|
127
|
+
"""Resolution with a node count per point (`reflectosim.resolution`) for one block of curves:
|
|
128
|
+
all nodes of a curve in one flat list, one kernel call, summed back per point."""
|
|
129
|
+
b, nq = q_true.shape[0], q_true.shape[1]
|
|
130
|
+
qt, sg = _numpy(q_true)[..., 0], _numpy(sigma)[..., 0]
|
|
131
|
+
ka2 = _active(inst.kalpha2_ratio)
|
|
132
|
+
counts, u_all, w_all, err, capped = resolution.layout(
|
|
133
|
+
qt, sg, tuple(_numpy(a) for a in packed), inst.wavelength,
|
|
134
|
+
inst.kalpha2_wavelength if ka2 else None,
|
|
135
|
+
) # fmt: skip
|
|
136
|
+
loose = capped | (err > resolution.REFINE_TOL)
|
|
137
|
+
if loose.any():
|
|
138
|
+
warnings.warn(
|
|
139
|
+
f"resolution: {int(loose.sum())} points above the refinement tolerance "
|
|
140
|
+
f"({int(capped.sum())} stopped at MAX_DEPTH); estimated relative error up to "
|
|
141
|
+
f"{err[loose].max():.1e} (resolution_report gives it per point)",
|
|
142
|
+
RuntimeWarning,
|
|
143
|
+
stacklevel=4,
|
|
144
|
+
)
|
|
145
|
+
if ops is _NumpyOps:
|
|
146
|
+
# one fused numba loop over the points and their nodes (no padding, no temporaries)
|
|
147
|
+
footprint = inst.sample_length is not None and inst.beam_sigma is not None
|
|
148
|
+
nan = np.full(b, np.nan)
|
|
149
|
+
return smear(
|
|
150
|
+
qt,
|
|
151
|
+
sg,
|
|
152
|
+
counts,
|
|
153
|
+
u_all,
|
|
154
|
+
w_all,
|
|
155
|
+
*packed,
|
|
156
|
+
inst.wavelength,
|
|
157
|
+
inst.kalpha2_wavelength if ka2 else 0.0,
|
|
158
|
+
ops.column(inst.kalpha2_ratio, b).ravel(),
|
|
159
|
+
ops.column(inst.sample_length, b).ravel() if footprint else nan,
|
|
160
|
+
ops.column(inst.beam_sigma, b).ravel() if footprint else nan,
|
|
161
|
+
)
|
|
162
|
+
# per curve: owner point of each node, its u and weight
|
|
163
|
+
ends = np.cumsum(counts.reshape(b, nq).sum(axis=1))
|
|
164
|
+
layouts = [
|
|
165
|
+
(np.repeat(np.arange(nq), c), x, y)
|
|
166
|
+
for c, x, y in zip(
|
|
167
|
+
counts.reshape(b, nq),
|
|
168
|
+
np.split(u_all, ends[:-1]),
|
|
169
|
+
np.split(w_all, ends[:-1]),
|
|
170
|
+
strict=True,
|
|
171
|
+
)
|
|
172
|
+
]
|
|
173
|
+
sizes = [o.size for o, _, _ in layouts]
|
|
174
|
+
out = []
|
|
175
|
+
start = 0
|
|
176
|
+
while start < b:
|
|
177
|
+
# curves in one kernel call while the padded node array stays within NODES_PER_CALL
|
|
178
|
+
stop, m = start + 1, sizes[start]
|
|
179
|
+
while stop < b and max(m, sizes[stop]) * (stop + 1 - start) <= NODES_PER_CALL:
|
|
180
|
+
m = max(m, sizes[stop])
|
|
181
|
+
stop += 1
|
|
182
|
+
rows = slice(start, stop)
|
|
183
|
+
part = layouts[rows]
|
|
184
|
+
# padding nodes sit on point 0 with weight 0, so a curve's result does not depend on its batch
|
|
185
|
+
owner = np.stack([np.pad(o, (0, m - o.size)) for o, _, _ in part])
|
|
186
|
+
u = np.stack([np.pad(x, (0, m - x.size)) for _, x, _ in part])
|
|
187
|
+
w = np.stack([np.pad(x, (0, m - x.size)) for _, _, x in part])
|
|
188
|
+
nodes = ops.take_rows(q_true[rows, :, 0], owner) + ops.take_rows(
|
|
189
|
+
sigma[rows, :, 0], owner
|
|
190
|
+
) * ops.asarray(u)
|
|
191
|
+
r = _at_angles(ops, kernel, nodes, tuple(a[rows] for a in packed), _rows(inst, rows, b))
|
|
192
|
+
out.append(ops.sum_rows(r * ops.asarray(w), owner, nq))
|
|
193
|
+
start = stop
|
|
194
|
+
return ops.cat(out) if len(out) > 1 else out[0]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _observed(ops, kernel, q, b, packed, inst: Instrument):
|
|
198
|
+
"""Shared pipeline. q (Q,) nominal; returns (B, Q).
|
|
199
|
+
|
|
200
|
+
The offset comes first: the true angle is the nominal 2θ plus the offset, and the resolution
|
|
201
|
+
is a Gaussian in q around the q of that true angle. Kα₁, Kα₂ and the footprint are evaluated at
|
|
202
|
+
the angle of each point of the resolution integral.
|
|
203
|
+
"""
|
|
204
|
+
q = q.reshape(1, -1, 1)
|
|
205
|
+
wl1 = inst.wavelength
|
|
206
|
+
to_deg = 180.0 / np.pi
|
|
207
|
+
theta = ops.arcsin(ops.clip(q * (wl1 / (4.0 * np.pi)), 0.0, 1.0)) * to_deg
|
|
208
|
+
theta = ops.clip(theta + 0.5 * ops.column(inst.tth_offset, b), 1e-9, 0.5 * MAX_TTH)
|
|
209
|
+
q_true = (4.0 * np.pi / wl1) * ops.sin(theta / to_deg) # (B, Q, 1)
|
|
210
|
+
resolved = _active(inst.sigma_q) or _active(inst.dq_over_q)
|
|
211
|
+
if not resolved or inst.nodes == 1:
|
|
212
|
+
r = _at_angles(ops, kernel, q_true[..., 0], packed, inst)
|
|
213
|
+
else:
|
|
214
|
+
sigma2 = ops.column(inst.sigma_q, b) ** 2 + (ops.column(inst.dq_over_q, b) * q_true) ** 2
|
|
215
|
+
# the floor keeps d sqrt / d sigma finite (zero) for a curve without resolution
|
|
216
|
+
sigma = ops.sqrt(ops.clip(sigma2, 1e-300, None))
|
|
217
|
+
if inst.nodes is None:
|
|
218
|
+
blocks = []
|
|
219
|
+
for start in range(0, b, ROWS_PER_BLOCK):
|
|
220
|
+
rows = slice(start, min(start + ROWS_PER_BLOCK, b))
|
|
221
|
+
part = tuple(a[rows] for a in packed)
|
|
222
|
+
blocks.append(
|
|
223
|
+
_point_smear(ops, kernel, q_true[rows], sigma[rows], part, _rows(inst, rows, b))
|
|
224
|
+
)
|
|
225
|
+
r = ops.cat(blocks) if len(blocks) > 1 else blocks[0]
|
|
226
|
+
else:
|
|
227
|
+
u = np.linspace(-resolution.SPAN, resolution.SPAN, inst.nodes)
|
|
228
|
+
w = np.exp(-0.5 * u**2)
|
|
229
|
+
nodes = q_true + sigma * ops.asarray(u).reshape(1, 1, -1)
|
|
230
|
+
shape = nodes.shape
|
|
231
|
+
r = _at_angles(ops, kernel, nodes.reshape(b, -1), packed, inst).reshape(shape)
|
|
232
|
+
r = (r * ops.asarray(w / w.sum()).reshape(1, 1, -1)).sum(-1)
|
|
233
|
+
return ops.column(inst.i0, b)[..., 0] * r + ops.column(inst.background, b)[..., 0]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _rows(inst: Instrument, rows: slice, total: int) -> Instrument:
|
|
237
|
+
"""The instrument for a block of curves (per-curve fields sliced)."""
|
|
238
|
+
changes = {}
|
|
239
|
+
for f in fields(inst):
|
|
240
|
+
v = getattr(inst, f.name)
|
|
241
|
+
per_curve = v is not None and np.ndim(v) == 1 and len(v) == total
|
|
242
|
+
if f.name not in ("wavelength", "kalpha2_wavelength", "nodes") and per_curve:
|
|
243
|
+
changes[f.name] = v[rows]
|
|
244
|
+
return inst.replace(**changes) if changes else inst
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def forward(
|
|
248
|
+
q: ArrayLike, model, instrument: Instrument | None = None, backend: str = "numba"
|
|
249
|
+
) -> NDArray[np.float64]:
|
|
250
|
+
"""Observed reflectivity for one model (returns (Q,)) or a batch (returns (B, Q)).
|
|
251
|
+
|
|
252
|
+
`model` is a Stack, a packed (sld, thickness, roughness) tuple of (N,) or (B, N) arrays, or a
|
|
253
|
+
sequence of either. Without an instrument this is the bare |r|² of the stack.
|
|
254
|
+
"""
|
|
255
|
+
if backend == "torch":
|
|
256
|
+
from reflectosim.torch_backend import forward as torch_forward
|
|
257
|
+
|
|
258
|
+
return torch_forward(q, model, instrument)
|
|
259
|
+
if backend != "numba":
|
|
260
|
+
raise ValueError(f"unknown backend {backend!r}; use 'numba' or 'torch'")
|
|
261
|
+
packed, single = as_batch(model)
|
|
262
|
+
q = np.asarray(q, dtype=float)
|
|
263
|
+
if q.ndim != 1:
|
|
264
|
+
raise ValueError("q must be one-dimensional; all curves of a batch share the q grid")
|
|
265
|
+
b = packed[0].shape[0]
|
|
266
|
+
if instrument is None:
|
|
267
|
+
r = parratt(np.broadcast_to(q, (b, q.size)), *packed)
|
|
268
|
+
else:
|
|
269
|
+
r = _observed(_NumpyOps, parratt, q, b, packed, instrument)
|
|
270
|
+
return r[0] if single else r
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def resolution_report(q: ArrayLike, model, instrument: Instrument) -> dict[str, NDArray]:
|
|
274
|
+
"""For the default resolution rule: nodes per point, the estimated relative error of the refined
|
|
275
|
+
range between the critical q, and whether refinement stopped at `resolution.MAX_DEPTH`, each
|
|
276
|
+
(B, Q) (or (Q,) for one model). What `forward` integrates with, without evaluating the curve."""
|
|
277
|
+
packed, single = as_batch(model)
|
|
278
|
+
q = np.asarray(q, dtype=float)
|
|
279
|
+
b = packed[0].shape[0]
|
|
280
|
+
inst = instrument
|
|
281
|
+
wl1 = inst.wavelength
|
|
282
|
+
|
|
283
|
+
def col(v):
|
|
284
|
+
return np.broadcast_to(np.asarray(v, dtype=float), (b,))[:, None]
|
|
285
|
+
|
|
286
|
+
theta = np.degrees(np.arcsin(np.clip(q * wl1 / (4.0 * np.pi), 0.0, 1.0)))[None, :]
|
|
287
|
+
theta = np.clip(theta + 0.5 * col(inst.tth_offset), 1e-9, 0.5 * MAX_TTH)
|
|
288
|
+
q_true = 4.0 * np.pi / wl1 * np.sin(np.radians(theta))
|
|
289
|
+
sigma = np.sqrt(
|
|
290
|
+
np.clip(col(inst.sigma_q) ** 2 + (col(inst.dq_over_q) * q_true) ** 2, 1e-300, None)
|
|
291
|
+
)
|
|
292
|
+
ka2 = _active(inst.kalpha2_ratio)
|
|
293
|
+
counts, _, _, err, capped = resolution.layout(
|
|
294
|
+
q_true,
|
|
295
|
+
sigma,
|
|
296
|
+
packed,
|
|
297
|
+
wl1,
|
|
298
|
+
inst.kalpha2_wavelength if ka2 else None,
|
|
299
|
+
)
|
|
300
|
+
out = {
|
|
301
|
+
"nodes": counts.reshape(b, -1),
|
|
302
|
+
"error": err.reshape(b, -1),
|
|
303
|
+
"capped": capped.reshape(b, -1),
|
|
304
|
+
}
|
|
305
|
+
return {k: v[0] for k, v in out.items()} if single else out
|
reflectosim/grid.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""The q grid of the command lines."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from reflectosim.units import tth_to_q
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def q_grid(spec: str, wavelength: float) -> np.ndarray:
|
|
9
|
+
"""'tth:START:STOP:N' (degrees) or 'q:START:STOP:N' (Å⁻¹) as q in Å⁻¹."""
|
|
10
|
+
axis, start, stop, n = spec.split(":")
|
|
11
|
+
values = np.linspace(float(start), float(stop), int(n))
|
|
12
|
+
if axis == "tth":
|
|
13
|
+
return tth_to_q(values, wavelength)
|
|
14
|
+
if axis == "q":
|
|
15
|
+
return values
|
|
16
|
+
raise SystemExit(f"grid axis must be tth or q, got {axis!r}")
|
reflectosim/kernel.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Float64 Parratt recursion compiled with numba.
|
|
2
|
+
|
|
3
|
+
k_z² = (q/2)² − 4π(ρ − ρ_ambient) with ρ in Å⁻², Névot-Croce roughness. The SLD is conjugated so
|
|
4
|
+
that the principal square root gives Im k_z ≥ 0 (decay into absorbers) with the exp(+2i k_z d)
|
|
5
|
+
recursion; callers pass Im SLD > 0 for absorption.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
|
|
10
|
+
import numba
|
|
11
|
+
import numpy as np
|
|
12
|
+
from numpy.typing import NDArray
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@numba.njit(cache=True)
|
|
16
|
+
def _curve_points(q, sld, thickness, roughness, start, stop, i, out):
|
|
17
|
+
n = sld.shape[1]
|
|
18
|
+
kz = np.empty(n, np.complex128)
|
|
19
|
+
rho = np.empty(n, np.complex128)
|
|
20
|
+
for j in range(n):
|
|
21
|
+
rho[j] = 4.0e-6 * np.pi * np.conj(sld[i, j] - sld[i, 0])
|
|
22
|
+
for iq in range(start, stop):
|
|
23
|
+
h = 0.5 * q[i, iq]
|
|
24
|
+
for j in range(n):
|
|
25
|
+
kz[j] = np.sqrt(h * h - rho[j])
|
|
26
|
+
x = 0j
|
|
27
|
+
for j in range(n - 2, -1, -1):
|
|
28
|
+
k1 = kz[j]
|
|
29
|
+
k2 = kz[j + 1]
|
|
30
|
+
s = roughness[i, j + 1]
|
|
31
|
+
r = (k1 - k2) / (k1 + k2) * np.exp(-2.0 * k1 * k2 * s * s)
|
|
32
|
+
if j + 1 < n - 1:
|
|
33
|
+
p = np.exp(2j * k2 * thickness[i, j + 1])
|
|
34
|
+
xp = x * p
|
|
35
|
+
else:
|
|
36
|
+
xp = 0j
|
|
37
|
+
x = (r + xp) / (1.0 + r * xp)
|
|
38
|
+
out[i, iq] = x.real * x.real + x.imag * x.imag
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@numba.njit(parallel=True, cache=True)
|
|
42
|
+
def _parratt(q, sld, thickness, roughness, out):
|
|
43
|
+
b, nq = q.shape
|
|
44
|
+
chunk = 64
|
|
45
|
+
per_curve = (nq + chunk - 1) // chunk
|
|
46
|
+
for t in numba.prange(b * per_curve):
|
|
47
|
+
i = t // per_curve
|
|
48
|
+
start = (t % per_curve) * chunk
|
|
49
|
+
_curve_points(q, sld, thickness, roughness, start, min(start + chunk, nq), i, out)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parratt(
|
|
53
|
+
q: NDArray[np.float64],
|
|
54
|
+
sld: NDArray[np.complex128],
|
|
55
|
+
thickness: NDArray[np.float64],
|
|
56
|
+
roughness: NDArray[np.float64],
|
|
57
|
+
) -> NDArray[np.float64]:
|
|
58
|
+
"""|r|² for q (B, Q) and media arrays (B, N), ambient first. Returns (B, Q)."""
|
|
59
|
+
q = np.ascontiguousarray(q, dtype=np.float64)
|
|
60
|
+
out = np.empty(q.shape)
|
|
61
|
+
_parratt(
|
|
62
|
+
q,
|
|
63
|
+
np.ascontiguousarray(sld, dtype=np.complex128),
|
|
64
|
+
np.ascontiguousarray(thickness, dtype=np.float64),
|
|
65
|
+
np.ascontiguousarray(roughness, dtype=np.float64),
|
|
66
|
+
out,
|
|
67
|
+
)
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@numba.njit(cache=True)
|
|
72
|
+
def _reflectivity(qv, rho, thickness, roughness, i):
|
|
73
|
+
"""|r|² at one q for curve i (rho = 4π · conj(ρ − ρ_ambient) [Å⁻²], ambient first)."""
|
|
74
|
+
n = rho.shape[1]
|
|
75
|
+
h = 0.5 * qv
|
|
76
|
+
k2 = np.sqrt(h * h - rho[i, n - 1])
|
|
77
|
+
x = 0j
|
|
78
|
+
for j in range(n - 2, -1, -1):
|
|
79
|
+
k1 = np.sqrt(h * h - rho[i, j])
|
|
80
|
+
s = roughness[i, j + 1]
|
|
81
|
+
r = (k1 - k2) / (k1 + k2) * np.exp(-2.0 * k1 * k2 * s * s)
|
|
82
|
+
xp = x * np.exp(2j * k2 * thickness[i, j + 1]) if j + 1 < n - 1 else 0j
|
|
83
|
+
x = (r + xp) / (1.0 + r * xp)
|
|
84
|
+
k2 = k1
|
|
85
|
+
return x.real * x.real + x.imag * x.imag
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@numba.njit(parallel=True, cache=True)
|
|
89
|
+
def _smear(
|
|
90
|
+
q_true, sigma, starts, u, w, rho, thickness, roughness, wl1, wl2, ratio, length, beam, out
|
|
91
|
+
):
|
|
92
|
+
b, nq = q_true.shape
|
|
93
|
+
for t in numba.prange(b * nq):
|
|
94
|
+
i, iq = t // nq, t % nq
|
|
95
|
+
acc = 0.0
|
|
96
|
+
for m in range(starts[t], starts[t + 1]):
|
|
97
|
+
sin_t = min(
|
|
98
|
+
max((q_true[i, iq] + sigma[i, iq] * u[m]) * wl1 / (4.0 * np.pi), 1e-12), 1.0
|
|
99
|
+
)
|
|
100
|
+
r = _reflectivity(4.0 * np.pi / wl1 * sin_t, rho, thickness, roughness, i)
|
|
101
|
+
if wl2 > 0:
|
|
102
|
+
r2 = _reflectivity(4.0 * np.pi / wl2 * sin_t, rho, thickness, roughness, i)
|
|
103
|
+
r = (r + ratio[i] * r2) / (1.0 + ratio[i])
|
|
104
|
+
if length[i] == length[i]:
|
|
105
|
+
r *= math.erf(length[i] * sin_t / (2.0 * np.sqrt(2.0) * beam[i]))
|
|
106
|
+
acc += w[m] * r
|
|
107
|
+
out[i, iq] = acc
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def smear(
|
|
111
|
+
q_true: NDArray[np.float64],
|
|
112
|
+
sigma: NDArray[np.float64],
|
|
113
|
+
counts: NDArray[np.int64],
|
|
114
|
+
u: NDArray[np.float64],
|
|
115
|
+
w: NDArray[np.float64],
|
|
116
|
+
sld: NDArray[np.complex128],
|
|
117
|
+
thickness: NDArray[np.float64],
|
|
118
|
+
roughness: NDArray[np.float64],
|
|
119
|
+
wl1: float,
|
|
120
|
+
wl2: float,
|
|
121
|
+
ratio: NDArray[np.float64],
|
|
122
|
+
length: NDArray[np.float64],
|
|
123
|
+
beam: NDArray[np.float64],
|
|
124
|
+
) -> NDArray[np.float64]:
|
|
125
|
+
"""Σ w · R over each point's nodes q_true + sigma · u, for (B, Q) points whose nodes are listed
|
|
126
|
+
point after point (counts (B·Q,) nodes each): Kα₁ (and Kα₂ at the same angle when wl2 > 0, mixed
|
|
127
|
+
by ratio), times the footprint factor where length is not NaN. Returns (B, Q)."""
|
|
128
|
+
sld = np.ascontiguousarray(sld, dtype=np.complex128)
|
|
129
|
+
rho = 4.0e-6 * np.pi * np.conj(sld - sld[:, :1])
|
|
130
|
+
starts = np.concatenate([[0], np.cumsum(counts)])
|
|
131
|
+
out = np.empty(q_true.shape)
|
|
132
|
+
_smear(
|
|
133
|
+
np.ascontiguousarray(q_true, dtype=np.float64),
|
|
134
|
+
np.ascontiguousarray(sigma, dtype=np.float64),
|
|
135
|
+
starts,
|
|
136
|
+
np.ascontiguousarray(u, dtype=np.float64),
|
|
137
|
+
np.ascontiguousarray(w, dtype=np.float64),
|
|
138
|
+
rho,
|
|
139
|
+
np.ascontiguousarray(thickness, dtype=np.float64),
|
|
140
|
+
np.ascontiguousarray(roughness, dtype=np.float64),
|
|
141
|
+
float(wl1),
|
|
142
|
+
float(wl2),
|
|
143
|
+
np.ascontiguousarray(ratio, dtype=np.float64),
|
|
144
|
+
np.ascontiguousarray(length, dtype=np.float64),
|
|
145
|
+
np.ascontiguousarray(beam, dtype=np.float64),
|
|
146
|
+
out,
|
|
147
|
+
)
|
|
148
|
+
return out
|
reflectosim/materials.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""X-ray SLD from composition and mass density (DECISIONS #8: computed, never copied).
|
|
2
|
+
|
|
3
|
+
Uses the X-ray scattering factor tables of `periodictable`; at Cu Kα₁ Si at 2.33 g/cm³ gives
|
|
4
|
+
20.071 + 0.457i and SiO₂ at 2.20 g/cm³ gives 18.865 + 0.244i (1e-6 Å⁻²).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from periodictable import xsf
|
|
8
|
+
|
|
9
|
+
from reflectosim.units import CU_KALPHA1
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def sld(formula: str, density: float, wavelength: float = CU_KALPHA1) -> complex:
|
|
13
|
+
"""Complex SLD [1e-6 Å⁻²] of a compound at a mass density [g/cm³]; Im > 0 is absorption."""
|
|
14
|
+
re, im = xsf.xray_sld(formula, density=density, wavelength=wavelength)
|
|
15
|
+
return complex(re, im)
|