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.
@@ -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
@@ -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)