Source code for hkl.configuration

"""
Save and restore Diffractometer Configuration.

PUBLIC API

.. autosummary::

    ~DiffractometerConfiguration

PRIVATE API

note: DC classes: Diffractometer Configuration

.. autosummary::

    ~_check_key
    ~_check_not_value
    ~_check_range
    ~_check_type
    ~_check_value
    ~DCConstraint
    ~DCLattice
    ~DCReflection
    ~DCSample
    ~DCConfiguration

.. note:: New in v1.1.
"""

__all__ = [
    "DiffractometerConfiguration",
]

import datetime
import json
import pathlib
import typing
from dataclasses import asdict
from dataclasses import dataclass
from dataclasses import field
from typing import Dict
from typing import List

import numpy
import pyRestTable
import yaml
from apischema import deserialize
from apischema import serialize

from .util import libhkl

# TODO: can these be learned from the diffractometer? (not constraints but axes?)
AX_MIN = -360.0  # lowest allowed value for real-space axis
AX_MAX = 360.0  # highest allowed value for real-space axis

DEFAULT_WAVELENGTH = 1.54  # angstrom
EXPORT_FORMATS = "dict json yaml".split()

SIGNIFICANT_DIGITS = 7


# standard value checks, raise exception(s) when appropriate
[docs] def _check_key(key, biblio, intro): """(internal) Raise KeyError if key is not in biblio.""" if key not in biblio: raise KeyError(f"{intro}: expected {key!r} not in {biblio}")
[docs] def _check_not_value(actual, avoid, intro): """(internal) Raise ValueError if actual IS equal to expected.""" if actual == avoid: raise ValueError(f"{intro}: received: {actual} cannot be: {avoid}")
[docs] def _check_range(value, low, high, intro): """(internal) Raise ValueError if value is not between low & high.""" if low > high: raise ValueError(f"{intro}: {low} should not be greater than {high}") if not low <= value <= high: raise ValueError(f"{intro}: {value} is not between {low} & {high}")
[docs] def _check_type(actual, expected, intro): """(internal) Raise TypeError if actual is not an instance of expected.""" if not isinstance(actual, expected): raise TypeError(f"{intro}: received: {actual} expected: {expected}")
[docs] def _check_value(actual, expected, intro): """(internal) Raise ValueError if actual is not equal to expected.""" if actual != expected: raise ValueError(f"{intro}: received: {actual} expected: {expected}")
[docs] @dataclass class DCConstraint: """ (internal) Configuration of one diffractometer axis constraint. """ low_limit: float """ Lowest acceptable value for this axis when computing real-space solutions from given reciprocal-space positions. """ high_limit: float """ Highest acceptable value for this axis when computing real-space solutions from given reciprocal-space positions. """ value: float """ Constant value used (on condition) for ``forward(hkl)`` calculation. Implemented by diffractometer :attr:`~hkl.engine.Engine.mode`. The diffractometer engine's :attr:`~hkl.engine.Engine.mode` (such as E4CV's ``constant_phi`` mode) controls whether or not the axis is to be held constant. """ fit: bool = True """ (deprecated) Not used as a constraint. Value is ignored. See :class:`~hkl.util.Constraint`. """ @property def values(self): """Return the list of values in order.""" return list(self.__dict__.values())
[docs] def validate(self, cname): """ Check this constraint has values the diffractometer can accept. Assumes diffractometer real axis limits of AX_MIN to AX_MAX degrees. """ _check_range(self.low_limit, AX_MIN, AX_MAX, f"{cname} low_limit") _check_range(self.high_limit, AX_MIN, AX_MAX, f"{cname} high_limit") _check_range(self.value, AX_MIN, AX_MAX, f"{cname} value")
# no additional validation needed for 'fit'
[docs] @dataclass class DCLattice: """(internal) Configuration of one crystal lattice.""" a: float """unit cell length :math:`a` (angstrom)""" b: float """unit cell length :math:`b` (angstrom)""" c: float """unit cell length :math:`c` (angstrom)""" alpha: float """unit cell angle alpha (degrees)""" beta: float """unit cell angle beta (degrees)""" gamma: float """unit cell angle gamma (degrees)"""
[docs] def validate(self, *_args): """Check this lattice has values the diffractometer can accept.""" _check_range(self.a, 1e-6, 1e6, "a") _check_range(self.b, 1e-6, 1e6, "b") _check_range(self.c, 1e-6, 1e6, "c") _check_range(self.alpha, 1e-6, 180.0 - 1e-6, "alpha") _check_range(self.beta, 1e-6, 180.0 - 1e-6, "beta") _check_range(self.gamma, 1e-6, 180.0 - 1e-6, "gamma") # exclude zero _check_not_value(self.alpha, 0.0, "alpha") _check_not_value(self.beta, 0.0, "beta") _check_not_value(self.gamma, 0.0, "gamma")
@property def values(self): """Return the list of values in order.""" return list(self.__dict__.values())
[docs] @dataclass class DCReflection: """(internal) Configuration of one orientation reflection.""" reflection: Dict[str, float] """ Reciprocal-space axis positions. Keys must match in the list of ``reciprocal_axes``. """ position: Dict[str, float] """ Real-space axis positions. Keys must match in the list of ``canonical_axes``. """ wavelength: float """Wavelength (angstroms) at which *this* reflection was measured.""" orientation_reflection: bool """Use this reflection for calculating :math:`UB` matrix?""" flag: int = 1 """(only used by *libhkl*)"""
[docs] def validate(self, dc_obj): """Check this reflection has values the diffractometer can accept.""" _check_range(self.wavelength, 1e-6, 1e6, "wavelength") # physics: reciprocal-space won't stretch any further q_max = 4 * numpy.pi / self.wavelength q_min = -q_max for axis, value in self.reflection.items(): _check_key(axis, dc_obj.reciprocal_axes_names, f"reciprocal-space axis {axis}") _check_range(value, q_min, q_max, f"reciprocal-space axis {axis}") for axis, value in self.position.items(): _check_key(axis, dc_obj.canonical_axes_names, f"real-space axis {axis}") _check_range(value, AX_MIN, AX_MAX, f"real-space axis {axis}")
# do not validate 'flag' (not used in hklpy)
[docs] @dataclass class DCSample: """(internal) Configuration of one crystalline sample with a lattice.""" name: str """Name of this crystalline sample.""" lattice: DCLattice """Crystal lattice parameters (angstroms and degrees)""" reflections: List[DCReflection] """List of orientation reflections.""" UB: List[List[float]] """ Orientation matrix (3 x 3). U is the crystal orientation matrix relative to the diffractometer and B is the transition matrix of a non-orthonormal (the reciprocal of the crystal) in an orthonormal system. """ # TODO: Once py38 is dropped, re-enable the default value setting U: List[List[float]] # = field(default_factory=list[list[float]]) """ Orientation matrix (3 x 3) of the crystal relative to the diffractometer. (optional) """
[docs] def validate(self, dc_obj): """Check this sample has values the diffractometer can accept.""" self.lattice.validate() _check_not_value(self.name.strip(), "", "name cannot be empty") for reflection in self.reflections: reflection.validate(dc_obj) for k in "U UB".split(): arr = numpy.array(getattr(self, k)) _check_value(arr.shape, (3, 3), f"{k} matrix shape") for i in range(len(arr)): # Want i, j for reporting for j in range(len(arr[i])): _check_type(arr[i][j], (float, numpy.floating), f"{k}[{i}][{j}]")
[docs] def write(self, diffractometer): """Write sample details to diffractometer.""" sample = diffractometer.calc._samples.get(self.name) lattice_parameters = list(self.lattice.values) if sample is None: sample = diffractometer.calc.new_sample(self.name, lattice=lattice_parameters) else: sample.lattice = lattice_parameters reflection_list = [] for reflection in self.reflections: rdict = asdict(reflection) # fmt: off args = [ # hkl values *list(rdict["reflection"].values()), tuple(rdict["position"].values())] # fmt: on # temporarily, change the wavelength w0 = diffractometer.calc.wavelength w1 = rdict["wavelength"] try: diffractometer.calc.wavelength = w1 r = sample.add_reflection(*args) if rdict["orientation_reflection"]: reflection_list.append(r) except RuntimeError as exc: raise RuntimeError(f"could not add reflection({args}, wavelength={w1})") from exc finally: diffractometer.calc.wavelength = w0 # Remaining code will not be executed if exception was raised. if len(reflection_list) > 1: r1, r2 = reflection_list[0], reflection_list[1] sample.compute_UB(r1, r2)
[docs] @dataclass class DCConfiguration: """ (internal) Full structure of the diffractometer configuration. Optional (keyword) attributes are not used to restore a diffractometer's configuration. Required (non-optional) attributes are used by ``restore()`` to either match the diffractometer or restore the configuration. """ geometry: str """ Name of the diffractometer geometry as provided by the back-end computation library. MUST match diffractometer to restore. """ engine: str """ Name of the computational support for the reciprocal-space (pseudo) axes. MUST match in the list provided by the diffractometer geometry to restore. The *engine* defines the list of the reciprocal-space (pseudo) axes. """ library: str """ Name of the back-end computation library. MUST match diffractometer to restore. """ mode: str """ Diffractometer calculation mode. Chosen from list provided by the back-end computation library. MUST match in the list provided by the diffractometer to restore. """ canonical_axes: List[str] """ List of the diffractometer real-space axis names. Both the exact spelling and order are defined by the back-end computation library. MUST match diffractometer to restore. """ real_axes: List[str] """ User-defined real-space axis names. MUST match diffractometer to restore. The length and order of this list must be the same as the ``canonical_axes``. It is used to resolve any (real-space) ``positioner`` names in this file. """ reciprocal_axes: List[str] """ List of names of the diffractometer reciprocal-space (pseudo) axes. Both the exact spelling and order are defined by the back-end computation library ``engine``. MUST match diffractometer to restore. """ constraints: Dict[str, DCConstraint] """ Limits to be imposed on the real-space axes for operations and computations. Keys must match in the list of ``canonical_axes``. """ samples: Dict[str, DCSample] """ Crystalline samples (lattice and orientation reflections). The sample name is used as the key in the dictionary. """ # -------------------- optional attributes name: str = "" """ Name of this diffractometer. (optional) """ datetime: str = "" """ Date and time this configuration was recorded. (optional) """ wavelength_angstrom: float = field(default_factory=float) """ Wavelength (angstrom) of the incident radiation. (optional) """ energy_keV: float = field(default_factory=float) """ Energy (keV) of the incident beam. Useful for synchrotron X-ray instruments. (optional) """ hklpy_version: str = "" """ Version of the *hklpy* Python package used to create this diffractometer configuration content. (optional) """ library_version: str = "" """ Version information of the back-end computation library. (optional) """ python_class: str = "" """ Name of the Python class that defines this diffractometer. (optional) """ other: Dict[str, typing.Any] = field(default_factory=dict) """ *Any* other content goes into this dictionary (comments, unanticipated keys, ...) (optional) """
[docs] def validate(self, dc_obj): """ Check this configuration has values the diffractometer can accept. PARAMETERS dc_obj *DiffractometerConfiguration*: The DiffractometerConfiguration object. """ diffractometer = dc_obj.diffractometer _check_value(self.geometry, diffractometer.calc._geometry.name_get(), "geometry") _check_key(self.engine, diffractometer.calc._engine_names, "engine") _check_value(self.engine, diffractometer.calc.engine.name, "engine") _check_value(self.library, libhkl.__name__, "library") _check_value(self.canonical_axes, dc_obj.canonical_axes_names, "canonical_axes") _check_value(self.reciprocal_axes, dc_obj.reciprocal_axes_names, "reciprocal_axes") _check_key(self.mode, diffractometer.engine.modes, "mode") for k in "canonical real reciprocal".split(): # number of axes must match _check_value( len(getattr(self, f"{k}_axes")), len(getattr(dc_obj, f"{k}_axes_names")), f"number of {k}_axes" ) for cname, constraint in self.constraints.items(): try: _check_key(cname, dc_obj.canonical_axes_names, "constraint axis") except KeyError: _check_key(cname, dc_obj.real_axes_names, "constraint axis") constraint.validate(cname) for sample in self.samples.values(): sample.validate(dc_obj)
[docs] def write(self, diffractometer, restore_constraints=True): """Update diffractometer with configuration.""" from .util import Constraint # fmt: off if not isinstance(restore_constraints, bool): raise TypeError( "'restore_constraints' must be True or False," f" received {restore_constraints}" ) # fmt: on # don't reset the wavelength # don't reset the (real-space) positions # don't reset the (reciprocal-space) positions diffractometer.engine.mode = self.mode # fmt: off if restore_constraints: diffractometer.apply_constraints( { k: Constraint(*constraint.values) for k, constraint in self.constraints.items() } ) # fmt: on for sample in self.samples.values(): sample.write(diffractometer)
[docs] class DiffractometerConfiguration: """ Save and restore Diffractometer Configuration. .. autosummary:: ~export ~preview ~restore ~model ~reset_diffractometer ~reset_diffractometer_constraints ~reset_diffractometer_samples ~from_dict ~from_json ~from_yaml ~to_dict ~to_json ~to_yaml ~canonical_axes_names ~real_axes_names ~reciprocal_axes_names """ def __init__(self, diffractometer): from .diffract import Diffractometer if not isinstance(diffractometer, Diffractometer): raise TypeError("diffractometer should be 'Diffractometer' or subclass.") self.diffractometer = diffractometer
[docs] def export(self, fmt="json"): """ Export configuration in a recognized format (dict, JSON, YAML, file). PARAMETERS fmt *str* or *pathlib.Path* object: One of these: ``None``, ``"dict"``, ``"json"``, ``"yaml"``. If ``None`` (or empty string or no argument at all), then JSON will be the default. """ path = None if isinstance(fmt, pathlib.Path): path = fmt fmt = "json" # use default format fmt = (fmt or "json").lower() if fmt == "yml": fmt = "yaml" # a common substitution, just being friendly if fmt not in EXPORT_FORMATS: raise ValueError(f"fmt must be one of {EXPORT_FORMATS}, received {fmt!r}") data = getattr(self, f"to_{fmt}")() if path is not None: with open(path, "w") as f: f.write(data) return data
[docs] def preview(self, data, show_constraints=False, show_reflections=False): """ List the samples in the configuration. PARAMETERS data *dict* or *str* *pathlib.Path* object: Structure (dict, json, or yaml) with diffractometer configuration or pathlib object referring to a file with one of these formats. show_constraints *bool*: If ``True`` (default: ``False``), will also show any constraints in a separate table. show_reflections *bool*: If ``True`` (default: ``False``), will also show reflections, if any, in a separate table for each sample. """ if isinstance(data, pathlib.Path): if not data.exists(): raise FileNotFoundError(f"{data}") with open(data) as f: data = f.read() if isinstance(data, str): if data.strip().startswith("{"): data = json.loads(data) else: data = yaml.load(data, Loader=yaml.Loader) return self._preview(data, show_constraints, show_reflections)
[docs] def _preview(self, data, show_constraints=False, show_reflections=False): if not isinstance(data, dict): raise TypeError(f"Cannot interpret configuration data: {type(data)}") def float_format(v): return f"{round(v, SIGNIFICANT_DIGITS):g}" text = ( f"name: {data.get('name', '-n/a-')}" f"\ndate: {data.get('datetime', '-n/a-')}" f"\ngeometry: {data['geometry']}" ) title = "Table of Samples" table = pyRestTable.Table() table.labels = "# sample a b c alpha beta gamma #refl".split() for i, sname in enumerate(data["samples"], start=1): sample = data["samples"][sname] row = [i, sname] for v in sample["lattice"].values(): row.append(float_format(v)) row.append(len(sample["reflections"])) table.addRow(row) text += f"\n\n{title}\n{table}" if show_reflections: for sname, sample in data["samples"].items(): if len(sample["reflections"]) == 0: continue # nothing to report title = f"Table of Reflections for Sample: {sname}" table = pyRestTable.Table() refl = sample["reflections"][0] table.addLabel("#") table.labels += list(refl["reflection"]) table.labels += list(refl["position"]) table.addLabel("wavelength") table.addLabel("orient?") for i, refl in enumerate(sample["reflections"], start=1): row = [i] row += [float_format(v) for v in refl["reflection"].values()] row += [float_format(v) for v in refl["position"].values()] row.append(f"{round(refl['wavelength'], SIGNIFICANT_DIGITS):g}") row.append(str(refl["orientation_reflection"])) table.addRow(row) text += f"\n\n{title}\n{table}" if show_constraints and len(data["constraints"]) > 0: title = "Table of Axis Constraints" table = pyRestTable.Table() table.labels = "axis low_limit high_limit value fit?".split() for aname, constraint in data["constraints"].items(): row = [aname] for k in "low_limit high_limit value".split(): row.append(float_format(constraint[k])) row.append(f"{constraint['fit']}") table.addRow(row) text += f"\n\n{title}\n{table}" return text
[docs] def restore(self, data, clear=True, restore_constraints=True): """ Restore configuration from a recognized format (dict, JSON, YAML, file). Instead of guessing, recognize the kind of config data by its structure. PARAMETERS data *dict* or *str* *pathlib.Path* object: Structure (dict, json, or yaml) with diffractometer configuration or pathlib object referring to a file with one of these formats. clear *bool*: If ``True`` (default), remove any previous configuration of the diffractometer and reset it to default values before restoring the configuration. If ``False``, sample reflections will be append with all reflections included in the configuration data for that sample. Existing reflections will not be changed. The user may need to edit the list of reflections after ``restore(clear=False)``. restore_constraints *bool*: If ``True`` (default), restore any constraints provided. Note: Can't name this method "import", it's a reserved Python word. """ importer = None if not isinstance(clear, bool): raise TypeError(f"clear must be either True or False, received {clear}") if isinstance(data, pathlib.Path): if not data.exists(): raise FileNotFoundError(f"{data}") with open(data) as f: data = f.read() if isinstance(data, dict): importer = self.from_dict elif isinstance(data, str): if data.strip().startswith("{"): importer = self.from_json else: importer = self.from_yaml if importer is None: raise TypeError("Unrecognized configuration structure.") importer(data, clear=clear, restore_constraints=restore_constraints)
@property def canonical_axes_names(self): """Names of the real-space axes, defined by the back-end library.""" return self.diffractometer.calc._geometry.axis_names_get() @property def real_axes_names(self): """Names of the real-space axes, defined by the user.""" return list(self.diffractometer.RealPosition._fields) @property def reciprocal_axes_names(self): """Names of the reciprocal-space axes, defined by the back-end library.""" return list(self.diffractometer.PseudoPosition._fields) @property def model(self) -> DCConfiguration: """Return validated diffractometer configuration object.""" diffractometer = self.diffractometer # either an empty dict or maps renamed axes to canonical xref = dict(diffractometer.calc._axis_name_to_original) def canonical_name(axis): return xref.get(axis, axis) data = { "name": diffractometer.name, "geometry": diffractometer.calc._geometry.name_get(), "datetime": str(datetime.datetime.now()), "python_class": diffractometer.__class__.__name__, "engine": diffractometer.calc.engine.name, "mode": diffractometer.calc.engine.mode, "canonical_axes": self.canonical_axes_names, "real_axes": self.real_axes_names, "reciprocal_axes": self.reciprocal_axes_names, "energy_keV": diffractometer.calc.energy, # for X-ray instruments "wavelength_angstrom": diffractometer.calc.wavelength, "hklpy_version": diffractometer._hklpy_version_, "library": libhkl.__name__, "library_version": libhkl.VERSION, # fmt: off "constraints": { canonical_name(axis): { parm: getattr(constraint, parm) for parm in "low_limit high_limit value fit".split() } for axis, constraint in diffractometer._constraints_dict.items() }, # fmt: on "samples": { sname: { "name": sample.name, "lattice": sample.lattice._asdict(), "reflections": sample.reflections_details, "U": sample.U.tolist(), "UB": sample.UB.tolist(), } for sname, sample in diffractometer.calc._samples.items() }, } obj = deserialize(DCConfiguration, data) # also validates structure obj.validate(self) # check that values are valid return obj
[docs] def reset_diffractometer(self): """Reset the diffractometer to the default configuration.""" self.diffractometer.wavelength = DEFAULT_WAVELENGTH self.diffractometer.engine.mode = self.diffractometer.engine.modes[0] self.reset_diffractometer_constraints() self.reset_diffractometer_samples()
[docs] def reset_diffractometer_constraints(self): """Reset the diffractometer constraints to defaults.""" from .util import Constraint # fmt: off self.diffractometer._set_constraints( { k: Constraint(-180., 180., 0.0, True) for k in self.real_axes_names } )
# fmt: on
[docs] def reset_diffractometer_samples(self): """Reset the diffractometer sample dict to defaults.""" for k in list(self.diffractometer.calc._samples): self.diffractometer.calc._samples.pop(k) # fmt: off a0 = DEFAULT_WAVELENGTH # coincidentally self.diffractometer.calc.new_sample( "main", lattice=(a0, a0, a0, 90., 90., 90.) )
# fmt: on
[docs] def from_dict(self, data, clear=True, restore_constraints=True): """ Load diffractometer configuration from Python dictionary. PARAMETERS data *dict*: structure (dict) with diffractometer configuration clear *bool*: If ``True`` (default), remove any previous configuration of the diffractometer and reset it to default values before restoring the configuration. """ # note: deserialize first runs a structural validation model = deserialize(DCConfiguration, data) model.validate(self) # check that values are valid if clear: self.reset_diffractometer() # tell the model to update the diffractometer model.write(self.diffractometer, restore_constraints=restore_constraints)
[docs] def to_dict(self): """Report diffractometer configuration as Python dictionary.""" return serialize(DCConfiguration, self.model)
[docs] def from_json(self, data, clear=True, restore_constraints=True): """ Load diffractometer configuration from JSON text. PARAMETERS data *str* (JSON): structure (JSON string) with diffractometer configuration clear *bool*: If ``True`` (default), remove any previous configuration of the diffractometer and reset it to default values before restoring the configuration. """ self.from_dict(json.loads(data), clear=clear, restore_constraints=restore_constraints)
[docs] def to_json(self, indent=2): """Report diffractometer configuration as JSON text.""" return json.dumps(self.to_dict(), indent=indent)
[docs] def from_yaml(self, data, clear=True, restore_constraints=True): """ Load diffractometer configuration from YAML text. PARAMETERS data *str* (YAML): structure (YAML string) with diffractometer configuration clear *bool*: If ``True`` (default), remove any previous configuration of the diffractometer and reset it to default values before restoring the configuration. """ # fmt: off self.from_dict( yaml.load(data, Loader=yaml.Loader), clear=clear, restore_constraints=restore_constraints )
# fmt: on
[docs] def to_yaml(self, indent=2): """ Report diffractometer configuration as YAML text. Order of appearance may be important for some entries, such as the list of reflections. Use ``sort_keys=False`` here. Don't make ``sort_keys`` a keyword argument that could be changed. """ return yaml.dump(self.to_dict(), indent=indent, sort_keys=False)