pyextal.acom.solve ================== .. py:module:: pyextal.acom.solve .. autoapi-nested-parse:: Solving the detector mapping and the orientation behind it. The mapping from a reflection to its pixel is linear:: pixel = center + (1 / pixel_size) * Rot(theta) * [x, y] so once a handful of reflections are identified the whole thing is a least-squares problem. Getting those first identifications is the only awkward part, and is done by matching *pairs* of peaks: the ratio of two |g| values and the angle between them are independent of both `pixel_size` and `theta`, which turns a four-parameter search into a table lookup. Attributes ---------- .. autoapisummary:: pyextal.acom.solve.EVIDENCE_PRIOR Classes ------- .. autoapisummary:: pyextal.acom.solve.DetectorMap Functions --------- .. autoapisummary:: pyextal.acom.solve._pair_features pyextal.acom.solve.match_pairs pyextal.acom.solve.assign pyextal.acom.solve.refine pyextal.acom.solve.score_mapping pyextal.acom.solve.solve_orientation Module Contents --------------- .. py:data:: EVIDENCE_PRIOR :value: 6.0 .. py:class:: DetectorMap Linear map from in-plane reciprocal coordinates to detector pixels. :ivar center: (2,) direct-beam pixel position. :vartype center: np.ndarray :ivar pixel_size: Inverse Angstroms per pixel. :vartype pixel_size: float :ivar theta: Rotation from the library's x axis to the detector, radians. :vartype theta: float :ivar handed: +1, or -1 if the pattern is mirrored relative to the library frame. :vartype handed: int :ivar rms: Residual of the fit, in pixels. :vartype rms: float :ivar n_used: Reflections the fit was based on. :vartype n_used: int .. py:attribute:: center :type: numpy.ndarray .. py:attribute:: pixel_size :type: float .. py:attribute:: theta :type: float .. py:attribute:: handed :type: int :value: 1 .. py:attribute:: rms :type: float .. py:attribute:: n_used :type: int :value: 0 .. py:method:: to_pixels(xy) Maps in-plane coordinates (inverse Angstroms) to pixels. :param xy: (n, 2) in-plane coordinates. :type xy: np.ndarray :returns: (n, 2) pixel positions. :rtype: np.ndarray .. py:method:: to_reciprocal(pixels) Maps pixels back to in-plane coordinates (inverse Angstroms). :param pixels: (n, 2) pixel positions. :type pixels: np.ndarray :returns: (n, 2) in-plane coordinates. :rtype: np.ndarray .. py:function:: _pair_features(vectors) Length ratios and included angles for every ordered pair of vectors. .. py:function:: match_pairs(peak_vectors, lib, ratio_tol=0.02, angle_tol=np.deg2rad(2.0), max_peaks=40, handed=None) Votes for a detector mapping by matching pairs of peaks to the library. :param peak_vectors: (n, 2) detected peak positions relative to the direct beam, in pixels. :type peak_vectors: np.ndarray :param lib: Candidate reflections. :type lib: ReflectionLibrary :param ratio_tol: Fractional tolerance on |g| ratios. Defaults to 0.02. :type ratio_tol: float, optional :param angle_tol: Tolerance on the included angle, radians. Defaults to 2 degrees. :type angle_tol: float, optional :param max_peaks: Use only the this many peaks closest to the direct beam; pair matching is quadratic. Defaults to 40. :type max_peaks: int, optional :param handed: Restrict proposals to ``+1`` or ``-1``. Both are tried when None, which is the default. :type handed: int, optional :returns: Candidate mappings, best first. Each holds ``pixel_size``, ``theta``, ``handed`` and ``votes``. :rtype: list[dict] .. note:: The two handedness families are often *exactly* degenerate. A projected zone-axis net is centrosymmetric, so a mirrored mapping reproduces every peak position to the same residual; on both SrTiO3 datasets here the two best scores agreed to 0.0000. Which one wins is then decided by candidate ordering, and it can differ between two scans of the same crystal on the same detector -- the mirror sends ``(h, k, 0)`` to ``(h, -k, 0)``, so the labels differ while the positions do not. Handedness is a property of the instrument, not of the specimen tilt. Determine it once, then pass it here. Note that :func:`pyextal.acom.resolve_label_sign` does *not* cover this: it tests ``g`` against ``-g``, an inversion, which is a different degeneracy. .. py:function:: assign(peak_vectors, lib, mapping, tol_px=3.0) Assigns each detected peak the nearest predicted reflection. :param peak_vectors: (n, 2) peak positions relative to the direct beam, in pixels. :type peak_vectors: np.ndarray :param lib: Candidate reflections. :type lib: ReflectionLibrary :param mapping: Mapping to test. :type mapping: DetectorMap :param tol_px: Largest accepted mismatch, in pixels. Defaults to 3.0. :type tol_px: float, optional :returns: Indices into `peak_vectors` and into `lib`, for peaks that found a reflection within `tol_px`. :rtype: tuple[np.ndarray, np.ndarray] .. py:function:: refine(peaks, lib, peak_idx, lib_idx, handed=1) Least-squares fit of the detector mapping to assigned peaks. The mapping is linear in its parameters once the assignment is fixed, so this is a direct solve rather than an iterative search. :param peaks: (n, 2) absolute peak positions, in pixels. :type peaks: np.ndarray :param lib: Candidate reflections. :type lib: ReflectionLibrary :param peak_idx: Indices into `peaks`. :type peak_idx: np.ndarray :param lib_idx: Matching indices into `lib`. :type lib_idx: np.ndarray :param handed: +1, or -1 for a mirrored pattern. Defaults to 1. :type handed: int, optional :returns: The fitted mapping, with residual RMS in pixels. :rtype: DetectorMap :raises ValueError: If fewer than two reflections were assigned. .. py:function:: score_mapping(peak_vectors, lib, mapping, shape, center, tol_px=3.0, strong_quantile=0.6, intensities=None, strong_rel=0.1) Scores how well a mapping explains the pattern. Residual alone cannot separate a correct solution from a symmetry-related one: on a square lattice a mirrored, sqrt(2)-rescaled mapping fits the matched peaks just as tightly. What separates them is the reflections the mapping predicts but the image does not show. So the score rewards explaining detected peaks *and* penalises strong predicted reflections that sit on empty frame. :param peak_vectors: (n, 2) peak positions relative to the direct beam. :type peak_vectors: np.ndarray :param lib: Candidate reflections. :type lib: ReflectionLibrary :param mapping: Mapping to score. :type mapping: DetectorMap :param shape: Detector shape. :type shape: tuple[int, int] :param center: (2,) direct-beam pixel position. :type center: np.ndarray :param tol_px: Match radius, pixels. Defaults to 3.0. :type tol_px: float, optional :param strong_quantile: Reflections above this quantile of |U_g| are the ones expected to be visible. Defaults to 0.6. :type strong_quantile: float, optional :param intensities: (n,) peak heights. Supplying them adds the sharpest discriminator available -- see below. :type intensities: np.ndarray, optional :param strong_rel: A reflection counts as strong when its |U_g| exceeds this fraction of the strongest. Defaults to 0.1. :type strong_rel: float, optional :returns: ``score`` plus the parts it is built from. :rtype: dict .. note:: Each fraction is weighted by the evidence behind it, ``n / (n + EVIDENCE_PRIOR)``. Without that, `explained_strong` and `intensity_on_strong` are computed over the candidate's own predictions and a zone axis that predicts almost nothing scores a perfect 1.0 on both. That is not hypothetical: on the SrTiO3 dataset taken 4 degrees off zone, [001] matched 17 of 20 detected peaks at the right scale and scored 1.44, while (5,-12,-1) matched 3 peaks at a scale 2.2x too large and scored 2.03. `matched_peaks` is biased towards mappings with too fine a scale: a smaller `pixel_size` puts more library reflections inside the same pixel radius, so more peaks find something to match. On SrTiO3 [001] a mapping wrong by a factor of sqrt(2) matched every peak while the correct one matched 61%. What separates them is `intensity_on_strong`: the wrong mapping put more than half the observed brightness onto near-extinct reflections, which cannot happen. `matched_peaks` is therefore carried at half weight. .. py:function:: solve_orientation(peaks, center, lib, shape, tol_px=3.0, n_candidates=12, intensities=None, pixel_size=None, pixel_tol=0.02, **match_kw) Finds the detector mapping that best explains a set of detected peaks. :param peaks: (n, 2) absolute peak positions, in pixels. :type peaks: np.ndarray :param center: (2,) direct-beam pixel position. :type center: array-like :param lib: Candidate reflections for one zone axis. :type lib: ReflectionLibrary :param shape: Detector shape. :type shape: tuple[int, int] :param tol_px: Match radius, pixels. Defaults to 3.0. :type tol_px: float, optional :param n_candidates: Pair-matching proposals to refine and score. Defaults to 12. :type n_candidates: int, optional :param intensities: (n,) peak heights, used to check that bright peaks land on reflections that can be bright. :type intensities: np.ndarray, optional :param pixel_size: Known detector scale, inverse Angstroms per pixel, from an earlier solve on the same camera length. Mappings further than `pixel_tol` from it are discarded, both as proposals and after the fit. Free when None, the default. :type pixel_size: float, optional :param pixel_tol: Fractional tolerance on `pixel_size`. Defaults to 0.02. :type pixel_tol: float, optional :param \*\*match_kw: Passed to :func:`match_pairs` -- ``handed=`` among them. :returns: Best mapping and its score, or ``(None, None)`` if nothing could be fitted. The score carries ``handed_margin`` and ``handed_degenerate``: when the mirror is degenerate the choice of handedness came from candidate ordering, not from the pattern, and two scans of the same crystal can disagree. Pin it with ``handed=`` once it is known. `pixel_size` exists for the same reason. Peak positions alone admit nets related by a scale of sqrt(2): on the SrTiO3 scan taken 7 degrees off [001] a blind search found (2,-3,-3) and (1,-6,-1) at 1.41x the true scale, scoring above [001]. The camera length does not change between scans, so a scale fitted once on an on-zone pattern removes that branch. :rtype: tuple[DetectorMap, dict] | tuple[None, None]