zebsdsolve 0.1.0__py3-none-win_arm64.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.
zebsdsolve/__init__.py ADDED
@@ -0,0 +1,669 @@
1
+ """zebsdsolve: EBSD pattern-centre fitting and indexing on the GPU (Zig + WGSL over WebGPU).
2
+
3
+ The plain functions in :mod:`zebsdsolve.api` (re-exported here) cover the common workflow::
4
+
5
+ import zebsdsolve as ze
6
+
7
+ result = ze.index("scan.h5", ["Ni.npz"], fit="pose", bandwidth=64)
8
+ result.quaternions, result.ncc, result.phase # per map cell
9
+ result.geometry["pc"], result.geometry["rot"] # the fitted pattern centre and detector rotation
10
+
11
+ `Solver` is the engine itself, for when the steps are driven one at a time (as the web app does):
12
+ open a scan, add phases, look at patterns, fit the pattern centre, fit the pose, index.
13
+
14
+ Conventions: quaternions are (w, x, y, z) and map sample directions to crystal directions;
15
+ Bunge angles are radians. The geometry is one pose of the detector and, for each pattern, the
16
+ point of the sample the beam stood on: the pattern centre (Bruker convention: x from the left
17
+ and y from the top in fractions of the pattern's width and height, z in fractions of its
18
+ height) is that pose's position, written for the map's centre. Map cells are numbered row-major.
19
+ """
20
+ import ctypes
21
+ import json
22
+ import os
23
+
24
+ import numpy as np
25
+
26
+ from . import _native as _n
27
+
28
+ __version__ = "0.1.0"
29
+
30
+ _lib = None
31
+ _wgpu = None
32
+
33
+ LAUE = {1: "-1", 2: "2/m", 3: "mmm", 4: "4/m", 5: "4/mmm", 6: "-3", 7: "-3m", 8: "6/m", 9: "6/mmm", 10: "m-3", 11: "m-3m"}
34
+ METHODS = ("spherical", "dictionary", "pca")
35
+ MAPS = {"ipf_x": 0, "ipf_y": 1, "ipf_z": 2, "ncc": 3, "nmi": 4, "phase": 5, "z": 6, "sweep": 7}
36
+
37
+
38
+ class Error(RuntimeError):
39
+ """An engine call failed; the message says which and why."""
40
+
41
+
42
+ def _load():
43
+ global _lib, _wgpu
44
+ if _lib is None:
45
+ _lib, _wgpu = _n.load()
46
+ if _lib.ze_version().decode() != __version__:
47
+ raise Error(f"library version {_lib.ze_version().decode()} does not match the package's {__version__}")
48
+ return _lib
49
+
50
+
51
+ def _f32(a):
52
+ return np.ascontiguousarray(a, dtype=np.float32)
53
+
54
+
55
+ def _ptr(a, t):
56
+ return a.ctypes.data_as(t)
57
+
58
+
59
+ class Solver:
60
+ """One engine on one GPU device. Close it (or use it as a context manager) to free the device."""
61
+
62
+ def __init__(self):
63
+ lib = _load()
64
+ self._lib = lib
65
+ self._e = lib.ze_create(_wgpu.encode())
66
+ if not self._e:
67
+ buf = ctypes.create_string_buffer(512)
68
+ n = lib.ze_create_error(buf, 512)
69
+ raise Error(buf.raw[:n].decode() or "engine creation failed")
70
+
71
+ def close(self):
72
+ if getattr(self, "_e", None):
73
+ self._lib.ze_destroy(self._e)
74
+ self._e = None
75
+
76
+ def __enter__(self):
77
+ return self
78
+
79
+ def __exit__(self, *exc):
80
+ self.close()
81
+
82
+ def __del__(self):
83
+ try:
84
+ self.close()
85
+ except Exception:
86
+ pass
87
+
88
+ # ── plumbing ──
89
+
90
+ def _error(self):
91
+ buf = ctypes.create_string_buffer(1024)
92
+ n = self._lib.ze_error(self._e, buf, 1024)
93
+ return buf.raw[:n].decode(errors="replace")
94
+
95
+ def _check(self, rc, what):
96
+ err = self._error()
97
+ if rc < 0 or err:
98
+ raise Error(f"{what}: {err or rc}")
99
+ return rc
100
+
101
+ @property
102
+ def adapter(self):
103
+ """The GPU adapter's description."""
104
+ buf = ctypes.create_string_buffer(256)
105
+ n = self._lib.ze_adapter(self._e, buf, 256)
106
+ return buf.raw[:n].decode(errors="replace")
107
+
108
+ def events(self):
109
+ """Progress events queued since the last call, as a list of dicts."""
110
+ out = []
111
+ buf = ctypes.create_string_buffer(1 << 16)
112
+ while True:
113
+ n = self._lib.ze_events(self._e, buf, len(buf))
114
+ if n == 0:
115
+ break
116
+ out += [json.loads(line) for line in buf.raw[:n].decode(errors="replace").splitlines() if line]
117
+ return out
118
+
119
+ def cancel(self):
120
+ """Ask a running fit or indexing to stop at its next step."""
121
+ self._lib.ze_cancel(self._e)
122
+
123
+ # ── settings and geometry ──
124
+
125
+ def configure(self, **settings):
126
+ """Set options by name (src/settings.zig lists them all, with what they mean)."""
127
+ if settings:
128
+ j = json.dumps(settings).encode()
129
+ self._check(self._lib.ze_configure(self._e, j, len(j)), "configure")
130
+ return self
131
+
132
+ @property
133
+ def settings(self):
134
+ """Every option's current value."""
135
+ cap = 8192
136
+ buf = ctypes.create_string_buffer(cap)
137
+ n = self._lib.ze_settings(self._e, buf, cap)
138
+ if n > cap:
139
+ buf = ctypes.create_string_buffer(n)
140
+ n = self._lib.ze_settings(self._e, buf, n)
141
+ return json.loads(buf.raw[:n].decode())
142
+
143
+ @property
144
+ def geometry(self):
145
+ """The detector geometry: a dict with pc (3), sample_tilt_deg, detector_tilt_deg, rot (3,
146
+ rotation vector, radians, in the detector's own axes: swing about its row direction,
147
+ tilt about its column direction, twist about its normal), px_um, step_x_um, step_y_um."""
148
+ g = _n.Geometry()
149
+ self._lib.ze_get_geometry(self._e, ctypes.byref(g))
150
+ return {
151
+ "pc": list(g.pc), "sample_tilt_deg": g.sample_tilt_deg, "detector_tilt_deg": g.detector_tilt_deg,
152
+ "rot": list(g.rot), "px_um": g.px_um, "step_x_um": g.step_x_um, "step_y_um": g.step_y_um,
153
+ }
154
+
155
+ def set_geometry(self, **fields):
156
+ """Set any of the geometry's fields, e.g. ``set_geometry(pc=(0.5, 0.2, 0.5), px_um=70)``."""
157
+ g = _n.Geometry()
158
+ self._lib.ze_get_geometry(self._e, ctypes.byref(g))
159
+ for k, v in fields.items():
160
+ if k in ("pc", "rot"):
161
+ setattr(g, k, (ctypes.c_double * 3)(*[float(x) for x in v]))
162
+ elif hasattr(g, k):
163
+ setattr(g, k, float(v))
164
+ else:
165
+ raise Error(f"geometry has no field {k!r}")
166
+ self._lib.ze_set_geometry(self._e, ctypes.byref(g))
167
+ return self
168
+
169
+ # ── scan ──
170
+
171
+ def open(self, *paths):
172
+ """Open a scan file, and any side files it needs after it. NORDIF's Pattern.dat needs its
173
+ Setting.txt, which is picked up from beside it when not given."""
174
+ paths = [str(p) for p in paths]
175
+ if len(paths) == 1 and paths[0].lower().endswith(".dat"):
176
+ side = os.path.join(os.path.dirname(paths[0]), "Setting.txt")
177
+ if os.path.isfile(side):
178
+ paths.append(side)
179
+ joined = "\n".join(paths).encode()
180
+ self._check(self._lib.ze_open_scan(self._e, joined, len(joined)), "open")
181
+ return self
182
+
183
+ def synthesize(self, **settings):
184
+ """Make a toy scan from the phases loaded, in place of any scan open, and return its
185
+ answer: a dict with quaternions (rows, cols, 4), phase (rows, cols; −1: a blank cell),
186
+ grain (rows, cols) and geometry (the geometry it was simulated under, which is its true
187
+ one: set it before this call, and move it afterwards to give a fit something to find).
188
+
189
+ Keywords are settings. The microstructure, from `toy_seed`: `toy_rows` × `toy_cols`
190
+ cells cut into `toy_grains` grains (1: a single crystal), each with a random orientation
191
+ and phase; `toy_spread_deg` turns every cell a little within its grain; `toy_blank` is
192
+ the share of cells holding no crystal. The detector: patterns of `toy_height` ×
193
+ `toy_width` pixels, `toy_contrast` of whose intensity is the simulation and the rest a
194
+ background (flat, or a hump of `toy_background_sigma` × the width). The counting: a
195
+ Poisson draw per pixel at a mean of `toy_counts` electrons (0: no noise), from
196
+ `toy_noise_seed` (0: from `toy_seed`), so one microstructure can be counted many times."""
197
+ if settings:
198
+ self.configure(**settings)
199
+ st = self.settings
200
+ rows, cols = st["toy_rows"], st["toy_cols"]
201
+ n = rows * cols
202
+ q = np.empty(4 * n, np.float32)
203
+ ph = np.empty(n, np.int32)
204
+ gr = np.empty(n, np.int32)
205
+ geometry = self.geometry
206
+ i32p = ctypes.POINTER(ctypes.c_int32)
207
+ self._check(self._lib.ze_synthesize(self._e, _ptr(q, _n.f32p), ph.ctypes.data_as(i32p), gr.ctypes.data_as(i32p), n), "synthesize")
208
+ return {"quaternions": q.reshape(rows, cols, 4), "phase": ph.reshape(rows, cols), "grain": gr.reshape(rows, cols), "geometry": geometry}
209
+
210
+ def set_patterns(self, patterns):
211
+ """Take patterns from an array: (rows, cols, h, w) or (n, h, w), uint8, uint16 or float32."""
212
+ a = np.asarray(patterns)
213
+ if a.ndim == 3:
214
+ a = a[None]
215
+ if a.ndim != 4:
216
+ raise Error("patterns must be (rows, cols, h, w) or (n, h, w)")
217
+ if a.dtype == np.uint8:
218
+ pixel = 0
219
+ elif a.dtype == np.uint16:
220
+ pixel = 1
221
+ else:
222
+ a = a.astype(np.float32)
223
+ pixel = 2
224
+ a = np.ascontiguousarray(a)
225
+ rows, cols, h, w = a.shape
226
+ self._check(self._lib.ze_set_patterns(self._e, a.ctypes.data, a.nbytes, pixel, rows, cols, h, w), "set_patterns")
227
+ return self
228
+
229
+ @property
230
+ def scan(self):
231
+ """What the scan is: map and pattern shape, pixel type, format, what metadata it carries."""
232
+ s = _n.ScanInfo()
233
+ self._lib.ze_scan_info(self._e, ctypes.byref(s))
234
+ buf = ctypes.create_string_buffer(64)
235
+ n = self._lib.ze_scan_format(self._e, buf, 64)
236
+ fmt = buf.raw[:n].decode()
237
+ return {
238
+ "rows": s.rows, "cols": s.cols, "pattern_shape": (s.pat_h, s.pat_w), "dtype": ["uint8", "uint16", "float32"][s.pixel],
239
+ "format": fmt, "has_background": bool(s.has_background), "has_pc": bool(s.has_pc),
240
+ "voltage_kv": s.voltage_kv or None, "bytes": int(s.bytes),
241
+ }
242
+
243
+ def shape(self, full=False):
244
+ """(height, width) of a pattern: reduced by the `binning` setting, or (`full`) as stored."""
245
+ out = (ctypes.c_uint32 * 2)()
246
+ self._check(self._lib.ze_pattern_shape(self._e, int(full), out), "pattern_shape")
247
+ return int(out[0]), int(out[1])
248
+
249
+ @property
250
+ def pattern_shape(self):
251
+ """(height, width) of a pattern as the search sees it: reduced by the `binning` setting."""
252
+ return self.shape()
253
+
254
+ def pattern(self, cell, processed=True, full=False):
255
+ """One pattern as float32 (h, w), at the reduced size (the `binning` setting) or (`full`)
256
+ the stored one. `processed`: True, as it is indexed (averaged with its neighbours when
257
+ `nlpar` is on, then filtered); False, as it is stored; "averaged", averaged with its
258
+ neighbours and not filtered."""
259
+ h, w = self.shape(full)
260
+ out = np.empty(h * w, np.float32)
261
+ stage = 2 if processed == "averaged" else int(bool(processed))
262
+ self._check(self._lib.ze_pattern(self._e, int(cell), stage, int(full), _ptr(out, _n.f32p), out.size), "pattern")
263
+ return out.reshape(h, w)
264
+
265
+ def nlpar_view(self):
266
+ """What averaging with its neighbours (`nlpar`) did to the pattern `pattern` last
267
+ returned: a dict with its `noise` as stored (standard deviation a pixel), the share of
268
+ that noise the average keeps (`left`), the share of the average that is the pattern
269
+ itself (`own`), and `shares`, the average's shares of the stored patterns around it
270
+ (side × side, the pattern itself in the middle, NaN outside the map). None when the
271
+ pattern was not averaged."""
272
+ out = np.empty(3, np.float32)
273
+ side = self._lib.ze_nlpar_view(self._e, _ptr(out, _n.f32p), None, 0)
274
+ if side == 0:
275
+ return None
276
+ shares = np.empty(side * side, np.float32)
277
+ self._lib.ze_nlpar_view(self._e, _ptr(out, _n.f32p), _ptr(shares, _n.f32p), shares.size)
278
+ return {"noise": float(out[0]), "left": float(out[1]), "own": float(out[2]), "shares": shares.reshape(side, side)}
279
+
280
+ def lattice(self, n):
281
+ """About `n` map cells on a lattice over the whole map (every cell when it has no more):
282
+ the cells the static background is averaged over, and a quick preview's."""
283
+ count = self._check(self._lib.ze_lattice(self._e, int(n), None, 0), "lattice")
284
+ out = np.empty(count, np.uint64)
285
+ self._check(self._lib.ze_lattice(self._e, int(n), _ptr(out, _n.u64p), count), "lattice")
286
+ return out
287
+
288
+ @property
289
+ def pixel_angle(self):
290
+ """Angular width of one stored detector pixel as the master filters take it, degrees:
291
+ the `pixel_angle_deg` setting, or the pixel at the pattern centre when that is 0."""
292
+ return float(self._lib.ze_pixel_angle(self._e))
293
+
294
+ def pattern_stats(self, cells=None):
295
+ """(mean, standard deviation) of the stored patterns of the given map cells (all of them
296
+ by default, shaped (rows, cols)): maps of either show the microstructure before indexing."""
297
+ s = self.scan
298
+ whole = cells is None
299
+ c = np.arange(s["rows"] * s["cols"], dtype=np.uint64) if whole else np.ascontiguousarray(cells, dtype=np.uint64).reshape(-1)
300
+ out = np.empty(2 * len(c), np.float32)
301
+ step = 4096
302
+ for i in range(0, len(c), step):
303
+ part = np.ascontiguousarray(c[i:i + step])
304
+ o = out[2 * i:2 * (i + len(part))]
305
+ self._check(self._lib.ze_pattern_stats(self._e, _ptr(part, _n.u64p), len(part), _ptr(o, _n.f32p), o.size), "pattern_stats")
306
+ mean, std = out[0::2], out[1::2]
307
+ return (mean.reshape(s["rows"], s["cols"]), std.reshape(s["rows"], s["cols"])) if whole else (mean, std)
308
+
309
+ # ── phases ──
310
+
311
+ def add_phase(self, path):
312
+ """Add a candidate phase from a master pattern (ebsdsim .npz or EMsoft .h5); returns its index."""
313
+ p = str(path).encode()
314
+ return self._check(self._lib.ze_add_phase(self._e, p, len(p)), "add_phase")
315
+
316
+ def clear_phases(self):
317
+ self._lib.ze_clear_phases(self._e)
318
+
319
+ @property
320
+ def phases(self):
321
+ """The candidate phases, each a dict."""
322
+ out = []
323
+ for i in range(self._lib.ze_phase_count(self._e)):
324
+ p = _n.PhaseInfo()
325
+ self._check(self._lib.ze_phase_info(self._e, i, ctypes.byref(p)), "phase_info")
326
+ out.append({
327
+ "name": p.name.decode(errors="replace"), "side": p.side, "point_group": p.point_group, "space_group": p.space_group,
328
+ "laue": LAUE.get(p.laue, "?"), "voltage_kv": p.voltage_kv,
329
+ "n_fold": p.n_fold, "mirror": bool(p.mirror), "inversion": bool(p.inversion),
330
+ })
331
+ return out
332
+
333
+ def master(self, phase=0):
334
+ """The phase's master pattern as the search compares patterns with it (smoothed, and
335
+ filtered as the patterns are): (2, side, side), north then south."""
336
+ side = self.phases[phase]["side"]
337
+ out = np.empty(2 * side * side, np.float32)
338
+ self._check(self._lib.ze_master_image(self._e, phase, _ptr(out, _n.f32p), out.size), "master")
339
+ return out.reshape(2, side, side)
340
+
341
+ def simulate(self, quaternions, cells=None, phase=0, full=False):
342
+ """Simulated patterns (n, h, w) of a phase at the given orientations, each as the map cell
343
+ `cells[k]` would record it (the map's reference cell, its centre, by default), at the
344
+ reduced size or (`full`) the stored one."""
345
+ q = _f32(quaternions).reshape(-1, 4)
346
+ n = len(q)
347
+ if cells is None:
348
+ s = self.scan
349
+ cells = np.full(n, (s["rows"] // 2) * s["cols"] + s["cols"] // 2 if s["rows"] else 0)
350
+ c = np.ascontiguousarray(cells, dtype=np.uint64).reshape(-1)
351
+ h, w = self.shape(full)
352
+ out = np.empty(n * h * w, np.float32)
353
+ self._check(self._lib.ze_simulate(self._e, phase, _ptr(c, _n.u64p), _ptr(q, _n.f32p), n, int(full), _ptr(out, _n.f32p), out.size), "simulate")
354
+ return out.reshape(n, h, w)
355
+
356
+ # ── fits ──
357
+
358
+ def fit_pc(self):
359
+ """Fit the pattern centre by the voting search (settings fit_grid, pc_*). The geometry takes
360
+ the result; returns a dict with pc, start, score, start_score, n_patterns, n_candidates, at_border."""
361
+ r = _n.PcResult()
362
+ self._check(self._lib.ze_fit_pc(self._e, ctypes.byref(r)), "fit_pc")
363
+ return {
364
+ "pc": list(r.pc), "start": list(r.start), "score": r.score, "start_score": r.start_score,
365
+ "n_patterns": r.n_patterns, "n_candidates": r.n_candidates, "levels": r.levels, "at_border": bool(r.at_border),
366
+ }
367
+
368
+ def pc_scores(self):
369
+ """The last search level's votes: (scores[nz, ny, nx], pcx values, pcy values, pcz values)."""
370
+ shape = (ctypes.c_uint32 * 3)()
371
+ n = self._lib.ze_pc_scores(self._e, shape, None, 0, None, 0)
372
+ nz, ny, nx = (int(v) for v in shape)
373
+ axes = np.empty(nx + ny + nz, np.float64)
374
+ scores = np.empty(max(n, 0), np.float32)
375
+ self._lib.ze_pc_scores(self._e, shape, _ptr(axes, _n.f64p), axes.size, _ptr(scores, _n.f32p), scores.size)
376
+ return scores.reshape(nz, ny, nx), axes[:nx], axes[nx:nx + ny], axes[nx + ny:]
377
+
378
+ def pc_levels(self):
379
+ """Every grid the last pattern-centre search scored, in order: a list of dicts with
380
+ scores[nz, ny, nx] (the fit patterns' mean score per candidate), the candidates' x, y, z,
381
+ the winner (ix, iy, iz: the candidate nearest the grid's choice), walked (the grid then
382
+ moved onto a winner at its border), per fit pattern the candidate it scored highest
383
+ under (pattern_best, rows of (ix, iy, iz)), its score there (pattern_ncc) and its score
384
+ under every candidate (pattern_scores[n, nz, ny, nx]); and, when ballots decided
385
+ (`pc_vote`), their density[nz, ny, nx], the choice in candidate steps (peak: x, y, z),
386
+ and how many ballots were cast and how many lay near the choice (ballots, inliers)."""
387
+ out = []
388
+ for i in range(self._lib.ze_pc_level_count(self._e)):
389
+ shape = (ctypes.c_uint32 * 3)()
390
+ info = (ctypes.c_uint32 * 2)()
391
+ n = self._lib.ze_pc_level(self._e, i, shape, info, None, 0, None, 0, None, None, 0)
392
+ nz, ny, nx = (int(v) for v in shape)
393
+ axes = np.empty(nx + ny + nz, np.float64)
394
+ scores = np.empty(n, np.float32)
395
+ best = np.empty(1024, np.uint32)
396
+ ncc = np.empty(1024, np.float32)
397
+ self._lib.ze_pc_level(self._e, i, shape, info, _ptr(axes, _n.f64p), axes.size, _ptr(scores, _n.f32p), n, _ptr(best, _n.u32p), _ptr(ncc, _n.f32p), best.size)
398
+ n_pat = self._lib.ze_fit_patterns(self._e, None, None, None, None, 0) or len(best)
399
+
400
+ def split(c):
401
+ return np.stack([c % nx, (c // nx) % ny, c // (nx * ny)], -1)
402
+
403
+ per = np.empty(self._lib.ze_pc_level_scores(self._e, i, None, 0), np.float32)
404
+ self._lib.ze_pc_level_scores(self._e, i, _ptr(per, _n.f32p), per.size)
405
+ out.append({"scores": scores.reshape(nz, ny, nx), "x": axes[:nx], "y": axes[nx:nx + ny], "z": axes[nx + ny:],
406
+ "pattern_scores": per.reshape(-1, nz, ny, nx),
407
+ **self._pc_ballots(i, (nz, ny, nx)),
408
+ "winner": tuple(int(v) for v in split(np.array(int(info[0])))), "walked": bool(info[1]),
409
+ "pattern_best": split(best[:n_pat].astype(np.int64)), "pattern_ncc": ncc[:n_pat].copy()})
410
+ return out
411
+
412
+ def _pc_ballots(self, i, shape):
413
+ info = np.empty(5, np.float64)
414
+ n = self._lib.ze_pc_level_ballots(self._e, i, None, 0, _ptr(info, _n.f64p))
415
+ den = np.empty(n, np.float32)
416
+ self._lib.ze_pc_level_ballots(self._e, i, _ptr(den, _n.f32p), n, _ptr(info, _n.f64p))
417
+ return {"density": den.reshape(shape) if n else None, "peak": tuple(float(v) for v in info[:3]), "ballots": int(info[3]), "inliers": int(info[4])}
418
+
419
+ def fit_pose(self):
420
+ """Fit the detector's pose and the fit patterns' orientations together (settings pose_grid,
421
+ pose_iters): the whole pose, six degrees of freedom. Any of them can be held with the
422
+ settings pose_x, pose_y, pose_z (the pattern centre's coordinates) and pose_tilt,
423
+ pose_swing, pose_twist (the detector's turns about the source). `pose_scale` adds a
424
+ seventh, the scale between the beam's travel and the detector (Sim(3)): the detector's
425
+ pixel size. The geometry takes the result; returns a dict, its `rot` the detector's
426
+ swing, tilt and twist (radians) and `px_um` the pixel size."""
427
+ r = _n.PoseResult()
428
+ self._check(self._lib.ze_fit_pose(self._e, ctypes.byref(r)), "fit_pose")
429
+ return {
430
+ "pc": list(r.pc), "rot": list(r.rot), "start_pc": list(r.start_pc), "start_rot": list(r.start_rot),
431
+ "ncc": r.ncc, "start_ncc": r.start_ncc, "mean_turn": r.mean_turn, "px_um": r.px_um, "start_px_um": r.start_px_um,
432
+ "iterations": r.iterations, "accepted": r.accepted, "n_patterns": r.n_patterns, "converged": bool(r.converged),
433
+ }
434
+
435
+ def fit_patterns(self):
436
+ """The patterns of the last fit: (cells, phases, quaternions (n, 4), ncc)."""
437
+ n = self._lib.ze_fit_patterns(self._e, None, None, None, None, 0)
438
+ cells = np.empty(n, np.uint64)
439
+ phases = np.empty(n, np.int32)
440
+ quats = np.empty(4 * n, np.float32)
441
+ ncc = np.empty(n, np.float32)
442
+ self._lib.ze_fit_patterns(self._e, _ptr(cells, _n.u64p), _ptr(phases, _n.i32p), _ptr(quats, _n.f32p), _ptr(ncc, _n.f32p), n)
443
+ return cells, phases, quats.reshape(n, 4), ncc
444
+
445
+ # ── indexing ──
446
+
447
+ def index_cells(self, cells, method=None, bandwidth=None, dict_grid=None):
448
+ """Index the given map cells without storing anything: a dict of arrays shaped
449
+ (phases, n[, 4]): quaternions, ncc, nmi, step (the rotation of the last refinement step
450
+ that improved the match, radians: small once the refinement has settled) and z (how far
451
+ the NMI stands above chance, in chance's standard deviations). The search is the
452
+ settings' (`method`, `bandwidth`, `dict_grid`) unless given here."""
453
+ c = np.ascontiguousarray(cells, dtype=np.uint64).reshape(-1)
454
+ n_ph = self._lib.ze_phase_count(self._e)
455
+ out = np.empty(n_ph * len(c) * 8, np.float32)
456
+ if method is None and bandwidth is None and dict_grid is None:
457
+ self._check(self._lib.ze_index_cells(self._e, _ptr(c, _n.u64p), len(c), _ptr(out, _n.f32p), out.size), "index_cells")
458
+ else:
459
+ st = self.settings
460
+ m = METHODS.index(method if method is not None else st["method"])
461
+ self._check(self._lib.ze_index_cells_by(self._e, _ptr(c, _n.u64p), len(c), m, int(bandwidth or st["bandwidth"]), int(dict_grid or st["dict_grid"]),
462
+ _ptr(out, _n.f32p), out.size), "index_cells")
463
+ r = out.reshape(n_ph, len(c), 8)
464
+ return {"quaternions": r[..., :4].copy(), "ncc": r[..., 4].copy(), "nmi": r[..., 5].copy(), "step": r[..., 6].copy(), "z": r[..., 7].copy()}
465
+
466
+ def tune(self, progress=None):
467
+ """Fit how the work is cut up to this machine and this scan. A sample of the region is
468
+ indexed and timed, and `batch`, `refine_pixels`, `wg_refine` and `wg_fft` each in turn
469
+ climb from the value they have: doubled while that is faster, else halved while that
470
+ is. The sample is sized by the bandwidth (8192 patterns up to 32, 4096 up to 64, 1024 up
471
+ to 128, 128 beyond), so a trial stays a fraction of a second: about ten trials, seconds
472
+ in all. Nothing stored is touched, and neither the orientations found nor which matches
473
+ are accepted depend on these settings. Returns the settings before and after and the
474
+ engine's summary. `progress(calls_done)` is called after each timed call; return False
475
+ from it to stop (the best so far stays)."""
476
+ import time
477
+
478
+ keys = ("batch", "refine_pixels", "wg_refine", "wg_fft")
479
+ before = {k: self.settings[k] for k in keys}
480
+ self._check(self._lib.ze_tune_begin(self._e), "tune")
481
+ done = 0
482
+ while True:
483
+ t = time.perf_counter()
484
+ rc = self._lib.ze_tune_trial(self._e)
485
+ seconds = time.perf_counter() - t
486
+ if rc != 0:
487
+ # a value this machine cannot run (out of memory, say) just loses
488
+ message = self._error()
489
+ if "Cancelled" in message:
490
+ raise Error("tune: " + message)
491
+ seconds = -1.0
492
+ done += 1
493
+ more = self._lib.ze_tune_report(self._e, seconds)
494
+ if not more or (progress is not None and progress(done) is False):
495
+ break
496
+ said = [e["text"] for e in self.events() if e["kind"] == "log" and e["text"].startswith("tuned on")]
497
+ return {"before": before, "after": {k: self.settings[k] for k in keys}, "summary": said[-1] if said else ""}
498
+
499
+ def chance(self, phase=0):
500
+ """What normalized mutual information chance gives on this scan for a phase: (median,
501
+ spread), measured once on a sample of the region's patterns against simulations at
502
+ random orientations, the same for every pattern. z in the results is the NMI on that
503
+ scale; with `accept_nmi` 0 a match is accepted at median + accept_z × spread, and
504
+ `accept_nmi` above 0 is an absolute threshold in its place."""
505
+ out = np.empty(2, np.float32)
506
+ self._check(self._lib.ze_chance(self._e, int(phase), _ptr(out, _n.f32p)), "chance")
507
+ return float(out[0]), float(out[1])
508
+
509
+ def index_begin(self):
510
+ """Start indexing the region of interest; `index_step` then does a batch at a time."""
511
+ self._check(self._lib.ze_index_begin(self._e), "index")
512
+
513
+ def index_step(self):
514
+ """Index the next batch; returns the number of cells left to visit (0: done)."""
515
+ return self._check(self._lib.ze_index_step(self._e), "index")
516
+
517
+ @property
518
+ def index_status(self):
519
+ """Where indexing stands: the sweep in progress (of `sweeps`) and what it searches with,
520
+ the patterns done and in it, the cells of the region and how many are accepted."""
521
+ st = _n.IndexStatus()
522
+ self._lib.ze_index_status(self._e, ctypes.byref(st))
523
+ out = {k: int(getattr(st, k)) for k in ("sweep", "sweeps", "bandwidth", "dict_grid", "done", "total", "region", "accepted")}
524
+ out["method"] = METHODS[st.method]
525
+ return out
526
+
527
+ @property
528
+ def sweep_plan(self):
529
+ """What each sweep searches with, in order: a list of (bandwidth, dictionary grid, method)."""
530
+ out = (ctypes.c_uint32 * 3)()
531
+ plan = []
532
+ for i in range(self.settings["sweeps"]):
533
+ self._lib.ze_sweep(self._e, i, out)
534
+ plan.append((int(out[0]), int(out[1]), METHODS[out[2]]))
535
+ return plan
536
+
537
+ def index(self, progress=None):
538
+ """Index the region of interest (settings roi_*, the whole map by default), a batch at a
539
+ time, in settings.sweeps sweeps. `progress(status)` is called after each batch with
540
+ `index_status`; return False from it to stop."""
541
+ self.index_begin()
542
+ while True:
543
+ left = self.index_step()
544
+ if progress is not None and progress(self.index_status) is False:
545
+ break
546
+ if left == 0:
547
+ break
548
+ return self.results()
549
+
550
+ def results(self):
551
+ """The stored results per map cell: a dict of arrays shaped (rows, cols[, k]):
552
+ quaternions (4), euler (3, Bunge, radians), ncc, nmi, phase (−1: not indexed), z (how far
553
+ the NMI stands above chance, standard deviations) and sweep (the sweep that found an
554
+ accepted match, one at or above the threshold (`accept_nmi`, or `accept_z`) whose refinement had settled; −1: none did, and
555
+ the cell holds the best there was)."""
556
+ s = self.scan
557
+ rows, cols = s["rows"], s["cols"]
558
+ n = rows * cols
559
+
560
+ def get(kind, dtype, k):
561
+ a = np.empty(n * k, dtype)
562
+ self._check(self._lib.ze_results(self._e, kind, a.ctypes.data, a.nbytes), "results")
563
+ return a.reshape((rows, cols, k) if k > 1 else (rows, cols))
564
+
565
+ return {
566
+ "quaternions": get(0, np.float32, 4), "euler": get(4, np.float32, 3),
567
+ "ncc": get(1, np.float32, 1), "nmi": get(2, np.float32, 1), "phase": get(3, np.int32, 1),
568
+ "z": get(5, np.float32, 1), "sweep": get(6, np.int32, 1),
569
+ }
570
+
571
+ def map(self, kind="ipf_z"):
572
+ """A result map as RGBA uint8 (rows, cols, 4): ipf_x, ipf_y, ipf_z, ncc, nmi, phase, z or sweep."""
573
+ s = self.scan
574
+ out = np.empty(s["rows"] * s["cols"] * 4, np.uint8)
575
+ self._check(self._lib.ze_map(self._e, MAPS[kind], _ptr(out, _n.u8p), out.size), "map")
576
+ return out.reshape(s["rows"], s["cols"], 4)
577
+
578
+ def ipf_key(self, phase=0, size=256, lower=False):
579
+ """The key to a phase's inverse pole figure colours, as the settings have them
580
+ (`ipf_colors`: "classic", "hue", "perfect" or "perfect_rotation"; `ipf_lightness` and
581
+ `ipf_gray` for the last two): (rgba, box). `rgba` is uint8 (size, size, 4): the crystal
582
+ directions that stand for all the others, in stereographic projection with the crystal's
583
+ x to the right ([001] at the origin), transparent outside them. That is the triangle
584
+ between [001], [101] and [111] for the cubic classes, a wedge from the c axis for any
585
+ other, and twice either under a key that gives a class a light and a dark half. `box` is
586
+ that region's bounding box in the projection (x0, y0, x1, y1), drawn from the image's
587
+ lower left corner. `lower` asks for the region's part in the lower hemisphere instead,
588
+ which only "perfect_rotation" has, and only for the classes of a single rotation axis
589
+ (2/m, 4/m, 6/m, -3, -1): for the others the image is empty and the box all zero."""
590
+ out = np.empty(size * size * 4, np.uint8)
591
+ box = np.empty(6, np.float32)
592
+ self._check(self._lib.ze_ipf_key(self._e, int(phase), int(size), int(bool(lower)), _ptr(out, _n.u8p), out.size, _ptr(box, _n.f32p)), "ipf_key")
593
+ return out.reshape(size, size, 4), tuple(float(v) for v in box[:4])
594
+
595
+ def export(self, path):
596
+ """Write the results as an EDAX .ang or an HKL .ctf file, by the path's extension."""
597
+ kind = {"ang": 0, "ctf": 1}.get(str(path).rsplit(".", 1)[-1].lower())
598
+ if kind is None:
599
+ raise Error("export writes .ang or .ctf")
600
+ n = self._check(self._lib.ze_export(self._e, kind, None, 0), "export")
601
+ buf = ctypes.create_string_buffer(n)
602
+ self._check(self._lib.ze_export(self._e, kind, buf, n), "export")
603
+ with open(path, "wb") as f:
604
+ f.write(buf.raw[:n])
605
+
606
+ def reduce(self, quaternions, phase=0):
607
+ """Orientations as the results store them: (quaternions, euler), each quaternion's
608
+ representative in the phase's fundamental zone and its Bunge angles (radians). The zone
609
+ is that of the proper rotations of the phase's point group (for GaN, 6mm, the six about
610
+ c: not its Laue class's twelve, half of which turn the polar axis over)."""
611
+ q = _f32(quaternions).reshape(-1, 4).copy()
612
+ euler = np.empty((len(q), 3), np.float64)
613
+ for i in range(len(q)):
614
+ self._lib.ze_reduce(self._e, phase, _ptr(q[i:i + 1], _n.f32p), _ptr(euler[i:i + 1], _n.f64p))
615
+ return q, euler
616
+
617
+ def disorientation(self, a, b, phase=0):
618
+ """Smallest rotation between orientations a and b (arrays of quaternions) under the
619
+ phase's symmetry, radians."""
620
+ qa = _f32(a).reshape(-1, 4)
621
+ qb = _f32(b).reshape(-1, 4)
622
+ out = np.empty(len(qa), np.float64)
623
+ for i in range(len(qa)):
624
+ out[i] = self._lib.ze_disorientation(self._e, phase, _ptr(qa[i:i + 1], _n.f32p), _ptr(qb[i:i + 1], _n.f32p))
625
+ return out
626
+
627
+ # ── tests and profiling ──
628
+
629
+ def _compile_wgsl(self, code, entry):
630
+ """Build one kernel; None when it builds, else the compiler's message."""
631
+ msg = ctypes.create_string_buffer(4096)
632
+ c = code.encode()
633
+ en = entry.encode()
634
+ n = self._lib.ze_compile_wgsl(self._e, c, len(c), en, len(en), msg, 4096)
635
+ return None if n == 0 else msg.raw[:n].decode(errors="replace")
636
+
637
+ def gpu_counts(self):
638
+ """GPU traffic since the last call: dispatches, submits, reads, waits."""
639
+ out = (ctypes.c_uint32 * 4)()
640
+ self._lib.ze_gpu_counts(self._e, out)
641
+ return dict(zip(("dispatches", "submits", "reads", "waits"), (int(v) for v in out)))
642
+
643
+ def profile(self, on=True):
644
+ """Time every dispatch on the GPU from now on; `profile_report()` prints the totals."""
645
+ return bool(self._lib.ze_profile(self._e, int(on)))
646
+
647
+ def profile_report(self):
648
+ buf = ctypes.create_string_buffer(1 << 16)
649
+ n = self._lib.ze_profile_report(self._e, buf, len(buf))
650
+ return buf.raw[:n].decode()
651
+
652
+
653
+ def shader_modules():
654
+ """The engine's WGSL modules as {name: source}, the text it compiles."""
655
+ lib = _load()
656
+ out = {}
657
+ i = 0
658
+ while True:
659
+ name = ctypes.create_string_buffer(64)
660
+ n = lib.ze_shader(i, name, 64, None, 0)
661
+ if n == 0:
662
+ return out
663
+ buf = ctypes.create_string_buffer(n)
664
+ lib.ze_shader(i, name, 64, buf, n)
665
+ out[name.value.decode()] = buf.raw[:n].decode()
666
+ i += 1
667
+
668
+
669
+ from .api import index, fit_pattern_center, fit_pose, load_patterns, Result # noqa: E402,F401
zebsdsolve/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .app import main
2
+
3
+ main()