Source code for lauelab.strain._inputs

# Copyright © 2026 UChicago Argonne, LLC. All rights reserved.
# Full license accessible at https://github.com/AdvancedPhotonSource/lauelab/blob/main/LICENSE
"""Strict strain preparation over existing indexing translations."""

from __future__ import annotations

from dataclasses import dataclass, field
import hashlib
import json
from numbers import Integral
from pathlib import Path

import numpy as np

from lauelab.indexing import (
    DetectorGeometry,
    FrameResult,
    Geometry,
    InputError,
    validate_results_file,
)
from ._tensor import _matrix, _real


def _json(value):
    def convert(v):
        if isinstance(v, dict):
            return {str(k): convert(x) for k, x in v.items()}
        if isinstance(v, (list, tuple, np.ndarray)):
            return [convert(x) for x in v]
        if isinstance(v, np.generic):
            return convert(v.item())
        if isinstance(v, float) and not np.isfinite(v):
            return None
        if isinstance(v, Path):
            return str(v)
        return v

    return json.dumps(convert(value), allow_nan=False, sort_keys=True)


def _readonly(value, dtype=np.float64):
    array = np.array(value, dtype=dtype, copy=True)
    array.setflags(write=False)
    return array


def _provenance(text):
    def reject(value):
        raise InputError(f"nonfinite JSON provenance value {value}")

    try:
        value = json.loads(text, parse_constant=reject)
    except (ValueError, TypeError) as error:
        raise InputError(f"invalid JSON provenance: {error}") from error
    if not isinstance(value, dict):
        raise InputError("provenance must be a JSON object")
    return value


def _index(value, name):
    if (
        isinstance(value, (bool, np.bool_))
        or not isinstance(value, Integral)
        or value < 0
    ):
        raise InputError(f"{name} must be a nonnegative integer")
    return int(value)


[docs] @dataclass(frozen=True) class StrainReflections: """Owned aligned measurements prepared for one independent pattern fit. Parameters ---------- qhat, hkl : numpy.ndarray Measured directions and integer Miller indices, both shape ``(n, 3)``. Directions are normalized. Zero HKLs and coincident harmonics error. initial_reciprocal : numpy.ndarray or None Starting reciprocal rows, shape ``(3, 3)``, in 1/nm including 2π. None is permitted for invalid-input records, but cannot be fitted. xy : numpy.ndarray or None Zero-based frame centroids ``(x, y)``, shape ``(n, 2)``. None gives all-NaN pixels for direction-only data. peak_indices : numpy.ndarray or None Unique original measured-peak IDs, shape ``(n,)``. Defaults to row indices; these IDs are never renumbered after preparation/subsetting. frame_id : str or int or None Source identity; integer and string identities remain distinct. pattern_index : int Nonnegative source pattern rank, default zero. provenance_json : str JSON object recording source and calibration context, default ``"{}"``. Notes ----- Arrays are copied and marked read-only. ``provenance`` returns a copy. No fitted or predicted values occur here. Malformed arrays or identities raise InputError. """ qhat: np.ndarray hkl: np.ndarray initial_reciprocal: np.ndarray | None xy: np.ndarray | None = None peak_indices: np.ndarray | None = None frame_id: str | int | None = None pattern_index: int = 0 provenance_json: str = field(default="{}", repr=False) def __post_init__(self): q = _real(self.qhat, "qhat") h = _real(self.hkl, "hkl") if q.ndim != 2 or q.shape[1:] != (3,) or h.shape != q.shape: raise InputError("qhat and hkl must have matching shape (n,3)") if np.any(h != np.round(h)) or np.any(np.abs(h) > np.iinfo(np.int32).max): raise InputError("hkl must contain integer Miller indices in int32 range") h = h.astype(np.int64) if np.any(np.all(h == 0, axis=1)): raise InputError("hkl cannot be (0,0,0)") if len(h): primitive = h // np.gcd.reduce(np.abs(h), axis=1)[:, None] if len(np.unique(primitive, axis=0)) != len(h): raise InputError( "coincident HKL harmonics cannot count as independent reflections" ) scale = np.max(np.abs(q), axis=1) if np.any(scale <= 0): raise InputError("measured scattering directions must be nonzero") with np.errstate(over="ignore", under="ignore"): norm = np.linalg.norm(q, axis=1) if np.any(~np.isfinite(norm) | (np.abs(norm - 1) > 1e-15)): q = q / scale[:, None] q = q / np.linalg.norm(q, axis=1)[:, None] peaks = ( np.arange(len(q)) if self.peak_indices is None else np.asarray(self.peak_indices) ) if ( peaks.shape != (len(q),) or peaks.dtype.kind not in "iu" or np.any(peaks < 0) or np.any(peaks > np.iinfo(np.int64).max) or len(np.unique(peaks)) != len(peaks) ): raise InputError( "peak_indices must be unique nonnegative integers aligned with qhat" ) xy = np.full((len(q), 2), np.nan) if self.xy is None else np.asarray(self.xy) if ( xy.dtype.kind not in "iuf" or xy.shape != (len(q), 2) or np.isinf(xy).any() or np.any(np.isnan(xy).any(axis=1) != np.isnan(xy).all(axis=1)) ): raise InputError( "xy must have shape (n,2), with NaNs only for unavailable pixels" ) if self.frame_id is not None and ( isinstance(self.frame_id, (bool, np.bool_)) or not isinstance(self.frame_id, (str, Integral)) ): raise InputError("frame_id must be a string, integer or None") b = self.initial_reciprocal if b is not None: b = _matrix(b, "initial_reciprocal") if b.shape != (3, 3): raise InputError("initial_reciprocal must have shape (3,3)") object.__setattr__(self, "initial_reciprocal", _readonly(b)) for name, array, dtype in ( ("qhat", q, float), ("hkl", h, np.int64), ("xy", xy, float), ("peak_indices", peaks, np.int64), ): object.__setattr__(self, name, _readonly(array, dtype)) object.__setattr__(self, "pattern_index", _index(self.pattern_index, "pattern")) if isinstance(self.frame_id, Integral): object.__setattr__(self, "frame_id", int(self.frame_id)) _provenance(self.provenance_json) @property def provenance(self): return json.loads(self.provenance_json)
class _Source: """Parse once per call/batch; keep errors before permissive XML truncation.""" def __init__(self, source, geometry=None): self.errors = {} self.info = {"kind": "memory"} if isinstance(source, (str, Path)): from lauelab.visualization.results import load_results from lauelab.visualization.xml import _load_visualization_xml path = Path(source) self.info = { "kind": "xml" if path.suffix.lower() == ".xml" else "hdf5", "path": str(path.resolve()), "size_bytes": path.stat().st_size, } if self.info["kind"] == "xml": source = _load_visualization_xml( path, geometry=geometry, pattern_errors=self.errors ) self.info["centroid_storage"] = "parsed decimal float64" else: validate_results_file(path) source = load_results(path, geometry=geometry) self.info["centroid_storage"] = "float32 (indexing format v1)" self.source = source if isinstance(source, FrameResult): self.frame_ids = (None,) self.keys = [(None, i) for i in range(source.n_patterns)] return from lauelab.visualization.data import ResultSet, VisualizationDataset if isinstance(source, ResultSet): self.frame_ids = source.frame_ids self.frame_rows = {fid: i for i, fid in enumerate(source.frame_ids)} self.keys = [ (fid, i) for fid, frame in zip(source.frame_ids, source.results) for i in range(frame.n_patterns) ] elif isinstance(source, VisualizationDataset): self.frame_ids = source.frame_ids self.keys = [ (source.frame_ids[f], int(p)) for f, p in zip(source.pattern_frame_indices, source.pattern_indices) ] if len(set(self.keys)) != len(self.keys): raise InputError("source frame/pattern identities must be unique") self.pattern_rows = {key: i for i, key in enumerate(self.keys)} from lauelab.visualization.tables import _peak_lookup self.peak_lookup = _peak_lookup(source) self.assignment_order = np.argsort( source.assignment_pattern_rows, kind="stable" ) self.assignment_offsets = np.searchsorted( source.assignment_pattern_rows[self.assignment_order], np.arange(source.n_patterns + 1), ) elif isinstance(source, StrainReflections): self.frame_ids = (source.frame_id,) self.keys = [(source.frame_id, source.pattern_index)] else: raise TypeError( "source must be FrameResult, ResultSet, VisualizationDataset, prepared reflections or an XML/HDF5 path" ) def extract(self, frame_id, pattern): source = self.source if isinstance(source, FrameResult): if pattern >= source.n_patterns: raise InputError(f"pattern {pattern} does not exist") pat = source.patterns[pattern] metadata = source.metadata return ( source.peaks, pat.pk_index, pat.hkl, pat.reciprocal, { "start": source.start, "group": source.group, "depth": source.depth, "detector_id": metadata.get("detector_id"), "input_image": source.input_image, "image_shape": source.image_shape, }, None, ) from lauelab.visualization.data import ResultSet if isinstance(source, ResultSet): if frame_id is None and len(source.results) == 1: frame_id = source.frame_ids[0] try: i = self.frame_rows[frame_id] except KeyError as error: raise InputError("select an existing frame_id explicitly") from error values = list(_Source(source.results[i]).extract(None, pattern)) values[4]["frame_id"] = frame_id values[5] = source.geometry return tuple(values) ids = source.frame_ids if frame_id is None and len(ids) == 1: frame_id = ids[0] key = (frame_id, pattern) if key in self.errors: raise InputError(self.errors[key]) if key not in self.pattern_rows: raise InputError(f"select an existing frame_id and pattern: {key!r}") row = self.pattern_rows[key] fi = int(source.pattern_frame_indices[row]) assignments = self.assignment_order[ self.assignment_offsets[row] : self.assignment_offsets[row + 1] ] peak_ids = source.assignment_peak_indices[assignments] if len(assignments) != source.pattern_n_indexed[row]: raise InputError( "declared assignment count disagrees with explicit associations" ) try: from lauelab.visualization.tables import _peak_rows rows = _peak_rows( source, np.full(len(peak_ids), fi), peak_ids, lookup=self.peak_lookup ) except KeyError as error: raise InputError("assignment refers to a missing measured peak") from error # Preserve the common translated model and its explicit association join. peaks = source.peaks[rows] return ( peaks, np.arange(len(peaks)), source.assignment_hkl[assignments], source.pattern_reciprocals[row], { "start": source.starts[fi], "group": source.groups[fi], "depth": source.depths[fi], "detector_id": source.detector_ids[fi], "input_image": source.input_images[fi], "image_shape": source.image_shapes[fi], "source_peak_indices": peak_ids, "frame_id": frame_id, }, source.geometry, )
[docs] def prepare_reflections( source, *, frame_id=None, pattern=0, geometry=None, detector_index=None, depth=None, peak_indices=None, ): """Prepare one indexed pattern using measured centroids and calibration. Parameters ---------- source : object FrameResult, ResultSet, VisualizationDataset, prepared StrainReflections or an indexing XML/HDF5 path. Existing readers are reused directly. frame_id : str or int or None Required for a multi-frame source; a sole frame is selected by default. pattern : int Nonnegative pattern rank, default zero. geometry : lauelab.indexing.Geometry or str or pathlib.Path or None Effective calibration, overriding saved geometry when supplied. detector_index : int or None Physical geometry slot, not ordinal active-detector position. Must agree with recorded detector identity. Ambiguous selection errors. depth : float or None Sample depth in µm. Explicit zero also overrides stored depth. peak_indices : numpy.ndarray or None Unique original measured-peak IDs to select, retaining source assignment order. No automatic rejection or rematching occurs. Returns ------- StrainReflections Owned float64 directions/centroids, integer HKLs and calibration provenance. An already prepared input is returned with its fixed context; conflicting selection/context overrides raise InputError. Notes ----- Invalid selected inputs raise InputError, ValueError or TypeError. File I/O errors propagate. Raw directions can be passed to refine_deviatoric without geometry. """ pattern = _index(pattern, "pattern") context = source if isinstance(source, _Source) else _Source(source, geometry) if isinstance(context.source, StrainReflections): data = context.source if pattern not in (0, data.pattern_index) or frame_id not in ( None, data.frame_id, ): raise InputError( "prepared reflection identity conflicts with requested selection" ) if any(x is not None for x in (geometry, detector_index, depth, peak_indices)): raise InputError( "prepared reflections already fix measurement context; prepare the original source to change it" ) return data peaks, indices, hkl, initial, meta, stored_geometry = context.extract( frame_id, pattern ) indices = np.asarray(indices) hkl = np.asarray(hkl) if ( indices.dtype.kind not in "iu" or indices.shape != (len(hkl),) or np.any(indices < 0) or np.any(indices >= len(peaks)) or len(np.unique(indices)) != len(indices) ): raise InputError("invalid or repeated measured peak associations") original_ids = np.asarray(meta.get("source_peak_indices", indices)) if peak_indices is not None: wanted = np.asarray(peak_indices) if not wanted.size: wanted = np.empty(0, dtype=np.int64) if ( wanted.ndim != 1 or wanted.dtype.kind not in "iu" or len(np.unique(wanted)) != len(wanted) ): raise InputError("peak_indices must be unique source peak IDs") if not np.isin(wanted, original_ids).all(): raise InputError("selection includes a peak not assigned to this pattern") take = np.isin(original_ids, wanted) indices, hkl, original_ids = indices[take], hkl[take], original_ids[take] xy = np.column_stack([peaks["fit_x"][indices], peaks["fit_y"][indices]]) calibration = geometry if geometry is not None else stored_geometry if calibration is None: raise InputError( "centroid-based strain preparation requires calibrated geometry" ) calibration = ( calibration if isinstance(calibration, Geometry) else Geometry(calibration) ) detector_id = meta.get("detector_id") if detector_index is None: if detector_id: detector_index = calibration.find_detector(detector_id) else: active = [i for i in range(3) if _active(calibration, i)] if len(active) != 1: raise InputError("select a physical detector_index explicitly") detector_index = active[0] detector_index = _index(detector_index, "detector_index") detector = calibration.detector(detector_index) if detector_id and detector.detector_id != detector_id: raise InputError("detector selection conflicts with measured frame metadata") effective_depth = depth if depth is not None else meta.get("depth") if depth is not None and (not np.isscalar(depth) or not np.isfinite(depth)): raise InputError("depth override must be finite") if effective_depth is not None and not np.isfinite(effective_depth): effective_depth = None start, group = tuple(meta["start"]), tuple(meta["group"]) qhat = calibration.pixels_to_q( xy, detector_index=detector_index, start=start, group=group, depth=effective_depth, ) provenance = { "source": context.info, "selection": meta, "directions": "measured centroids and effective geometry", "start": start, "group": group, "depth_um": effective_depth, "detector_index": detector_index, "detector_id": detector.detector_id, "detector_calibration": { name: getattr(detector, name) for name in ( "nx", "ny", "size_x", "size_y", "detector_id", "translation", "rotation_vector", "rotation", ) }, "geometry_xml": calibration._source_xml, "geometry_path": str(calibration.path), "centroid_sha256": hashlib.sha256( np.ascontiguousarray(xy).tobytes() ).hexdigest(), } return StrainReflections( qhat, hkl, initial, xy, original_ids, meta.get("frame_id", frame_id), pattern, _json(provenance), )
def _active(geometry, index): try: geometry.detector(index) return True except ValueError: return False def _pixel_predictions(data, reciprocal): info = data.provenance if "detector_calibration" not in info: return np.full((len(data.hkl), 2), np.nan) detector = DetectorGeometry(**info["detector_calibration"]) full = detector.q_to_pixel(data.hkl @ reciprocal, depth=info["depth_um"]) group = np.asarray(info["group"]) return (full - np.asarray(info["start"]) - (group - 1) / 2) / group