diff --git a/.coveragerc b/.coveragerc index 604c9cf5..fcb507d1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,5 +1,5 @@ [run] -source=ophyd +source=hkl [report] omit= diff --git a/docs/source/conf.py b/docs/source/conf.py index e4a03a14..878404aa 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -26,6 +26,7 @@ import hkl.calc import hkl.diffract import hkl.geometries +import hkl.user # -- General configuration ----------------------------------------------------- diff --git a/docs/source/index.rst b/docs/source/index.rst index d72a08ec..a4a74c3f 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -41,6 +41,7 @@ Contents: engine geometries sample + user util examples/* release_notes diff --git a/docs/source/user.rst b/docs/source/user.rst new file mode 100644 index 00000000..8367aeb4 --- /dev/null +++ b/docs/source/user.rst @@ -0,0 +1,86 @@ +.. _user: + +user +---- + +Make it easier for users (especially SPEC users) to learn and remember +the new tools in Bluesky's *hklpy* package. + +Quick Reference Table + +============== ======================================= +*SPEC* *hklpy* +============== ======================================= +-- :func:`~calc_UB` +-- :func:`~new_sample` +-- :func:`~select_diffractometer` +``br`` TODO: +``cal`` :func:`cahkl` +``cuts`` TODO: constraints +``cz`` TODO: +``freeze`` TODO: constraints +``g_sect`` TODO: constraints +``mz`` TODO: +``or_swap`` TODO: +``or0`` :func:`~setor` +``or1`` :func:`~setor` +``pa`` :func:`~pa` +``pl`` TODO: +``reflex_beg`` TODO: +``reflex_end`` TODO: +``reflex`` TODO: +``setaz`` TODO: +``setlat`` :func:`~update_sample` +``setmode`` TODO: modes +``setsector`` TODO: +``sz`` TODO: +``unfreeze`` TODO: constraints +``wh`` :func:`~wh` +============== ======================================= + +.. automodule:: hkl.user + :members: + +---- + +.. _user.examples: + + +EXAMPLES:: + + # work with our 4-circle simulator + select_diffractometer(fourc) + + # sample is the silicon standard + a0 = 5.4310196; new_sample("silicon standard", a0, a0, a0, 90, 90, 90) + + list_samples() + + # define the first orientation reflection, specify each motor position + # motor values given in "diffractometer order":: + # print(_geom_.calc.physical_axis_names) + r1 = setor(4, 0, 0, -145.451, 0, 0, 69.0966, wavelength=1.54) + + # move to the position of the second reflection: (040) + %mov fourc.omega -145.451 fourc.chi 90 fourc.phi 0 fourc.tth 69.0966 + + # define the second orientation reflection, use current motor positions + r2 = setor(0, 4, 0) + + calc_UB(r1, r2) + + # calculate reflection, record motor positions before and after + p_before = fourc.real_position + fourc.forward(4, 0, 0) + p_after = fourc.real_position + + # show if the motors moved + if p_before != p_after: + print("fourc MOVED!") + else: + print("fourc did not move.") + + # cubic sample: show r2, the (040) + fourc.inverse(-145.5, 90, 0, 69) + # verify that the (0 -4 0) is half a rotation away in chi + fourc.inverse(-145.5, -90, 0, 69) diff --git a/hkl/diffract.py b/hkl/diffract.py index 3950d3ee..86bfe432 100644 --- a/hkl/diffract.py +++ b/hkl/diffract.py @@ -458,11 +458,23 @@ def _set_constraints(self, constraints): self.calc[axis].value = constraint.value self.calc[axis].fit = constraint.fit - def forward_solutions_table(self, reflections, full=False): + def forward_solutions_table(self, reflections, full=False, digits=5): """ - Return table of computed solutions for each (hkl) in the supplied reflections list. - - The solutions are calculated using the current UB matrix & constraints + Return table of computed solutions for each supplied (hkl) reflection. + + The solutions are calculated using the current UB matrix & constraints. + + Parameters + ---------- + reflections : list of (h, k, l) reflections + Each reflection is a tuple of 3 numbers, + (h, k, l) of the reflection. + full : bool + If ``True``, show all solutions. If ``False``, + only show the default solution. + digits : int + Number of digits to roundoff each position + value. Default is 5. """ _table = pyRestTable.Table() motors = self.real_positioners._fields @@ -479,7 +491,7 @@ def forward_solutions_table(self, reflections, full=False): else: for i, s in enumerate(solutions): row = [reflection, i] - row += [f"{getattr(s, m):.5f}" for m in motors] + row += [round(getattr(s, m), digits) for m in motors] _table.addRow(row) if not full: break # only show the first (default) solution @@ -487,7 +499,7 @@ def forward_solutions_table(self, reflections, full=False): def pa(self, all_samples=False, printing=True): """ - Report the diffractometer settings. + Report (all) the diffractometer settings. EXAMPLE:: @@ -632,7 +644,7 @@ def Package(**kwargs): def wh(self, printing=True): """ - report where is the diffractometer + Report (brief) where is the diffractometer. EXAMPLE:: diff --git a/hkl/tests/test_diffract.py b/hkl/tests/test_diffract.py index ff2c327c..a0ce7307 100644 --- a/hkl/tests/test_diffract.py +++ b/hkl/tests/test_diffract.py @@ -167,13 +167,13 @@ def test_forward_solutions_table(fourc): ) received = str(tbl).splitlines() expected = [ - "=========== ======== ======== ======== ======== =========", - "(hkl) solution omega chi phi tth ", - "=========== ======== ======== ======== ======== =========", - "[1, 1, 0] 0 45.00000 45.00000 90.00000 90.00000 ", - "[1, 1, 1] 0 60.00000 35.26439 45.00000 120.00000", - "[100, 1, 1] none ", - "=========== ======== ======== ======== ======== =========", + "=========== ======== ===== ======== ==== =====", + "(hkl) solution omega chi phi tth ", + "=========== ======== ===== ======== ==== =====", + "[1, 1, 0] 0 45.0 45.0 90.0 90.0 ", + "[1, 1, 1] 0 60.0 35.26439 45.0 120.0", + "[100, 1, 1] none ", + "=========== ======== ===== ======== ==== =====", ] for r, e in zip(received, expected): assert r == e diff --git a/hkl/tests/test_user.py b/hkl/tests/test_user.py new file mode 100644 index 00000000..37f4b192 --- /dev/null +++ b/hkl/tests/test_user.py @@ -0,0 +1,346 @@ +import pytest +import numpy.testing + +import gi + +gi.require_version("Hkl", "5.0") +# NOTE: MUST call gi.require_version() BEFORE import hkl +from hkl.geometries import SimulatedE4CV +import hkl.user + + +class Fourc(SimulatedE4CV): + ... + + +@pytest.fixture(scope="function") +def fourc(): + fourc = Fourc("", name="fourc") + fourc.wait_for_connection() + fourc._update_calc_energy() + return fourc + + +def test_select_diffractometer(capsys, fourc): + # This test function must be first or the next assertion will fail. + assert hkl.user._geom_ is None + hkl.user.select_diffractometer(fourc) + assert hkl.user._geom_ is not None + assert hkl.user._geom_ == fourc + capsys.readouterr() # flush the output buffers + + hkl.user.show_selected_diffractometer() + out, err = capsys.readouterr() + assert str(err) == "" + assert str(out).strip() == "fourc" + + +def test_cahkl(fourc): + hkl.user.select_diffractometer(fourc) + fourc.calc["tth"].limits = (0, 180) + + # use the default "main" sample and UB matrix + response = hkl.user.cahkl(1, 0, 0) + expected = (30, 0, 90, 60) + assert round(response[0]) == expected[0] + assert round(response[1]) == expected[1] + assert round(response[2]) == expected[2] + assert round(response[3]) == expected[3] + + +def test_cahkl_table(capsys, fourc): + hkl.user.select_diffractometer(fourc) + fourc.calc["tth"].limits = (0, 180) + + # use the default "main" sample and UB matrix + rlist = [(1, 0, 0), (0, 1, 0)] + hkl.user.cahkl_table(rlist, digits=0) + out, err = capsys.readouterr() + expected = """ + ========= ======== ===== === === === + (hkl) solution omega chi phi tth + ========= ======== ===== === === === + (1, 0, 0) 0 30 0 90 60 + (0, 1, 0) 0 30 90 0 60 + ========= ======== ===== === === === + """.strip().splitlines() + assert err == "" + for el, rl in list(zip(expected[3:5], str(out).strip().splitlines()[3:5])): + # just compare the position values + for e, r in list(zip(el.split()[-4:], rl.split()[-4:])): + assert float(r) == float(e) + + +def test_calc_UB(fourc): + hkl.user.select_diffractometer(fourc) + a0 = 5.4310196 + hkl.user.new_sample("silicon standard", a0, a0, a0, 90, 90, 90) + r1 = hkl.user.setor(4, 0, 0, tth=69.0966, omega=-145.451, chi=0, phi=0, wavelength=1.54) + fourc.omega.move(-145.451) + fourc.chi.move(90) + fourc.phi.move(0) + fourc.tth.move(69.0966) + r2 = hkl.user.setor(0, 4, 0) + + ub = hkl.user.calc_UB(r1, r2) + if ub is None: + hkl.user.calc_UB(r1, r2) + ub = fourc.calc.sample.UB + assert isinstance(ub, numpy.ndarray) + assert isinstance(fourc.UB.get(), numpy.ndarray) + + +def test_list_samples(capsys, fourc): + hkl.user.select_diffractometer(fourc) + a0 = 5.431 + hkl.user.new_sample("silicon", a0, a0, a0, 90, 90, 90) + capsys.readouterr() # flush the output buffers + + hkl.user.list_samples(verbose=False) + out, err = capsys.readouterr() + assert err == "" + expected = """ + silicon (*): [5.431, 5.431, 5.431, 90.0, 90.0, 90.0] + main: [1.54, 1.54, 1.54, 90.0, 90.0, 90.0] + """.strip().splitlines() + for e, r in list(zip(expected, str(out).strip().splitlines())): + assert r.strip() == e.strip() + + hkl.user.list_samples() + out, err = capsys.readouterr() + assert err == "" + expected = """ + Sample: silicon (*) + + ======= ======================================= + key value + ======= ======================================= + name silicon + lattice [5.431, 5.431, 5.431, 90.0, 90.0, 90.0] + U [[1. 0. 0.] + [0. 1. 0.] + [0. 0. 1.]] + UB [[ 1.15691 -0. -0. ] + [ 0. 1.15691 -0. ] + [ 0. 0. 1.15691]] + ======= ======================================= + + + Sample: main + + ======= ==================================== + key value + ======= ==================================== + name main + lattice [1.54, 1.54, 1.54, 90.0, 90.0, 90.0] + U [[1. 0. 0.] + [0. 1. 0.] + [0. 0. 1.]] + UB [[ 4.07999 -0. -0. ] + [ 0. 4.07999 -0. ] + [ 0. 0. 4.07999]] + ======= ==================================== + """.strip().splitlines() + for e, r in list(zip(expected, str(out).strip().splitlines())): + assert r.strip() == e.strip() + + +def test_new_sample(fourc): + hkl.user.select_diffractometer(fourc) + + # sample is the silicon standard + a0 = 5.4310196 + hkl.user.new_sample("silicon standard", a0, a0, a0, 90, 90, 90) + assert fourc.calc.sample.name == "silicon standard" + lattice = fourc.calc.sample.lattice + assert round(lattice.a, 7) == a0 + assert round(lattice.b, 7) == a0 + assert round(lattice.c, 7) == a0 + assert round(lattice.alpha, 7) == 90 + assert round(lattice.beta, 7) == 90 + assert round(lattice.gamma, 7) == 90 + + +def test_set_energy(fourc): + hkl.user.select_diffractometer(fourc) + numpy.testing.assert_approx_equal(fourc.energy.get(), 8) + assert fourc.energy_offset.get() == 0 + assert fourc.energy_units.get() == "keV" + numpy.testing.assert_approx_equal(fourc.calc.energy, 8) + + hkl.user.set_energy(8.1) + numpy.testing.assert_approx_equal(fourc.energy.get(), 8.1) + assert fourc.energy_offset.get() == 0 + assert fourc.energy_units.get() == "keV" + numpy.testing.assert_approx_equal(fourc.calc.energy, 8.1) + + hkl.user.set_energy(7500, units="eV") + numpy.testing.assert_approx_equal(fourc.energy.get(), 7500) + assert fourc.energy_offset.get() == 0 + assert fourc.energy_units.get() == "eV" + numpy.testing.assert_approx_equal(fourc.calc.energy, 7.5) + + hkl.user.set_energy(7100, units="eV", offset=25) + numpy.testing.assert_approx_equal(fourc.energy.get(), 7100) + assert fourc.energy_offset.get() == 25 + assert fourc.energy_units.get() == "eV" + numpy.testing.assert_approx_equal(fourc.calc.energy, 7.125) + + # Now, do not set offset. It will use the previous value. + hkl.user.set_energy(2500, units="eV") + numpy.testing.assert_approx_equal(fourc.energy.get(), 2500) + assert fourc.energy_offset.get() == 25 + assert fourc.energy_units.get() == "eV" + numpy.testing.assert_approx_equal(fourc.calc.energy, 2.525) + + +def test_setor(fourc): + hkl.user.select_diffractometer(fourc) + a0 = 5.4310196 + hkl.user.new_sample("silicon standard", a0, a0, a0, 90, 90, 90) + + assert len(fourc.calc.sample.reflections) == 0 + hkl.user.setor(4, 0, 0, -145.451, 0, 0, 69.0966, wavelength=1.54) + assert len(fourc.calc.sample.reflections) == 1 + assert fourc.calc.sample.reflections == [(4, 0, 0)] + + fourc.omega.move(-145.451) + fourc.chi.move(90) + fourc.phi.move(0) + fourc.tth.move(69.0966) + hkl.user.setor(0, 4, 0) + assert len(fourc.calc.sample.reflections) == 2 + assert fourc.calc.sample.reflections == [(4, 0, 0), (0, 4, 0)] + + +def test_show_sample(capsys, fourc): + hkl.user.select_diffractometer(fourc) + a0 = 5.431 + hkl.user.new_sample("silicon", a0, a0, a0, 90, 90, 90) + capsys.readouterr() # flush the output buffers + + hkl.user.show_sample(verbose=False) + out, err = capsys.readouterr() + assert str(out).strip() == ("silicon (*):" " [5.431, 5.431, 5.431, 90.0, 90.0, 90.0]") + assert err == "" + + hkl.user.show_sample() + out, err = capsys.readouterr() + assert err == "" + expected = """ + Sample: silicon (*) + + ======= ======================================= + key value + ======= ======================================= + name silicon + lattice [5.431, 5.431, 5.431, 90.0, 90.0, 90.0] + U [[1. 0. 0.] + [0. 1. 0.] + [0. 0. 1.]] + UB [[ 1.15691 -0. -0. ] + [ 0. 1.15691 -0. ] + [ 0. 0. 1.15691]] + ======= ======================================= + """.strip().splitlines() + for e, r in list(zip(expected, str(out).strip().splitlines())): + assert r.strip() == e.strip() + + +def test_update_sample(capsys, fourc): + hkl.user.select_diffractometer(fourc) + + hkl.user.update_sample(2, 2, 2, 90, 90, 90) + out, err = capsys.readouterr() + assert err == "" + expected = """ + main (*): [2.0, 2.0, 2.0, 90.0, 90.0, 90.0] + """.strip().splitlines() + for e, r in list(zip(expected, str(out).strip().splitlines())): + assert r.strip() == e.strip() + + +def test_pa(fourc, capsys): + hkl.user.select_diffractometer(fourc) + + tbl = hkl.user.pa() + assert tbl is None + out, err = capsys.readouterr() + assert len(out) > 0 + assert err == "" + out = [v.rstrip() for v in out.strip().splitlines()] + expected = [ + "===================== ====================================================================", + "term value", + "===================== ====================================================================", + "diffractometer fourc", + "geometry E4CV", + "class Fourc", + "energy (keV) 8.00000", + "wavelength (angstrom) 1.54980", + "calc engine hkl", + "mode bissector", + "positions ===== =======", + " name value", + " ===== =======", + " omega 0.00000", + " chi 0.00000", + " phi 0.00000", + " tth 0.00000", + " ===== =======", + "constraints ===== ========= ========== ===== ====", + " axis low_limit high_limit value fit", + " ===== ========= ========== ===== ====", + " omega -180.0 180.0 0.0 True", + " chi -180.0 180.0 0.0 True", + " phi -180.0 180.0 0.0 True", + " tth -180.0 180.0 0.0 True", + " ===== ========= ========== ===== ====", + "sample: main ================ ===================================================", + " term value", + " ================ ===================================================", + " unit cell edges a=1.54, b=1.54, c=1.54", + " unit cell angles alpha=90.0, beta=90.0, gamma=90.0", + " [U] [[1. 0. 0.]", + " [0. 1. 0.]", + " [0. 0. 1.]]", + " [UB] [[ 4.07999046e+00 -2.49827363e-16 -2.49827363e-16]", + " [ 0.00000000e+00 4.07999046e+00 -2.49827363e-16]", + " [ 0.00000000e+00 0.00000000e+00 4.07999046e+00]]", + " ================ ===================================================", + "===================== ====================================================================", + ] + assert len(out) == len(expected) + assert out == expected + + +def test_wh(fourc, capsys): + hkl.user.select_diffractometer(fourc) + + tbl = hkl.user.wh() + assert tbl is None + out, err = capsys.readouterr() + assert len(out) > 0 + assert err == "" + out = [v.rstrip() for v in out.strip().splitlines()] + expected = [ + "===================== ========= =========", + "term value axis_type", + "===================== ========= =========", + "diffractometer fourc", + "sample name main", + "energy (keV) 8.00000", + "wavelength (angstrom) 1.54980", + "calc engine hkl", + "mode bissector", + "h 0.0 pseudo", + "k 0.0 pseudo", + "l 0.0 pseudo", + "omega 0 real", + "chi 0 real", + "phi 0 real", + "tth 0 real", + "===================== ========= =========", + ] + assert len(out) == len(expected) + assert out == expected diff --git a/hkl/user.py b/hkl/user.py new file mode 100644 index 00000000..4d46f997 --- /dev/null +++ b/hkl/user.py @@ -0,0 +1,262 @@ +""" +Provide a simplified UI for hklpy diffractometer users. + +The user must define a diffractometer instance, then +register that instance here calling `select_diffractometer(instance)`. + +FUNCTIONS + +.. autosummary:: + ~cahkl + ~cahkl_table + ~calc_UB + ~list_samples + ~new_sample + ~select_diffractometer + ~set_energy + ~setor + ~show_sample + ~show_selected_diffractometer + ~update_sample + ~wh + ~pa +""" + +__all__ = """ + cahkl + cahkl_table + calc_UB + change_sample + list_samples + new_sample + pa + select_diffractometer + set_energy + setor + show_sample + show_selected_diffractometer + update_sample + wh +""".split() + +import logging +import gi + +gi.require_version("Hkl", "5.0") +logger = logging.getLogger(__name__) + +from hkl.diffract import Diffractometer +from hkl.util import Lattice +import numpy +import pyRestTable + + +_geom_ = None # selected diffractometer geometry + + +def _check_geom_selected(*args, **kwargs): + """Raise ValueError if no diffractometer geometry is selected.""" + if _geom_ is None: + raise ValueError( + "No diffractometer selected." + " Call 'select_diffractometer(diffr)' where" + " 'diffr' is a diffractometer instance." + ) + + +def cahkl(h, k, l): + """ + Calculate motor positions for one reflection. + + Returns a namedtuple. + Does not move motors. + """ + _check_geom_selected() + # TODO: make certain this will not move the motors! + return _geom_.forward(h, k, l) + + +def cahkl_table(reflections, digits=5): + """ + Print a table with motor positions for each reflection given. + + Parameters + ---------- + reflections : list(tuple(number,number,number)) + This is a list of reflections where + each reflection is a tuple of 3 numbers + specifying (h, k, l) of the reflection + to compute the ``forward()`` computation. + + Example: ``[(1,0,0), (1,1,1)]`` + digits : int + Number of digits to roundoff each position + value. Default is 5. + """ + _check_geom_selected() + print(_geom_.forward_solutions_table(reflections, digits=digits)) + + +# def calc_energy(): +# # TODO: should this be added? +# raise NotImplementedError + + +def calc_UB(r1, r2, wavelength=None): + """Compute the UB matrix with two reflections.""" + _check_geom_selected() + _geom_.calc.sample.compute_UB(r1, r2) + print(_geom_.calc.sample.UB) + + +def change_sample(sample): + """Pick a known sample to be the current selection.""" + _check_geom_selected() + if sample not in _geom_.calc._samples: + # fmt: off + raise KeyError( + f"Sample '{sample}' is unknown." + f" Known samples: {list(_geom_.calc._samples.keys())}" + ) + # fmt: on + _geom_.calc.sample = sample + show_sample(sample) + + +def list_samples(verbose=True): + """List all defined crystal samples.""" + _check_geom_selected() + # always show the default sample first + current_name = _geom_.calc.sample_name + show_sample(current_name, verbose=verbose) + + # now, show any other samples + for sample in _geom_.calc._samples.keys(): + if sample != current_name: + if verbose: + print("") + show_sample(sample, verbose=verbose) + + +def new_sample(nm, a, b, c, alpha, beta, gamma): + """Define a new crystal sample.""" + _check_geom_selected() + if nm in _geom_.calc._samples: + logger.warning( + ( + "Sample '%s' is already defined." + " Use 'update_sample()' to change lattice parameters" + " on the *current* sample." + ), + nm, + ) + else: + lattice = Lattice(a=a, b=b, c=c, alpha=alpha, beta=beta, gamma=gamma) + _geom_.calc.new_sample(nm, lattice=lattice) + show_sample() + + +def select_diffractometer(instrument=None): + """Name the diffractometer to be used.""" + global _geom_ + if instrument is None or isinstance(instrument, Diffractometer): + _geom_ = instrument + else: + raise TypeError(f"{instrument} must be a 'Diffractometer' subclass") + + +def set_energy(value, units=None, offset=None): + """ + Set the energy (thus wavelength) to be used. + """ + _check_geom_selected() + if units is not None: + _geom_.energy_units.put(units) + if offset is not None: + _geom_.energy_offset.put(offset) + _geom_.energy.put(value) + + +def setor(h, k, l, *args, wavelength=None, **kwargs): + """Define a crystal reflection and its motor positions.""" + _check_geom_selected() + if len(args) == 0: + if len(kwargs) == 0: + pos = _geom_.real_position + else: + # fmt: off + pos = [ + kwargs[m] + for m in _geom_.calc.physical_axis_names + if m in kwargs + ] + # fmt: on + else: + pos = args + # TODO: How does libhkl get the wavelength on a reflection? + if wavelength not in (None, 0): + _geom_.calc.wavelength = wavelength + refl = _geom_.calc.sample.add_reflection(h, k, l, position=pos) + return refl + + +def show_sample(sample_name=None, verbose=True): + """Print the default sample name and crystal lattice.""" + _check_geom_selected() + sample_name = sample_name or _geom_.calc.sample_name + sample = _geom_.calc._samples[sample_name] + + title = sample_name + if sample_name == _geom_.calc.sample.name: + title += " (*)" + + # Print Lattice more simply (than as a namedtuple). + lattice = [getattr(sample.lattice, parm) for parm in sample.lattice._fields] + if verbose: + tbl = pyRestTable.Table() + tbl.addLabel("key") + tbl.addLabel("value") + tbl.addRow(("name", sample_name)) + tbl.addRow(("lattice", lattice)) + for i, r in enumerate(sample.reflections): + tbl.addRow((f"reflection {i+1}", r)) + tbl.addRow(("U", numpy.round(sample.U, 5))) + tbl.addRow(("UB", numpy.round(sample.UB, 5))) + + print(f"Sample: {title}\n") + print(tbl) + else: + print(f"{title}: {lattice}") + + +def show_selected_diffractometer(instrument=None): + """Print the name of the selected diffractometer.""" + if _geom_ is None: + print("No diffractometer selected.") + print(_geom_.name) + + +def update_sample(a, b, c, alpha, beta, gamma): + """Update current sample lattice.""" + _check_geom_selected() + _geom_.calc.sample.lattice = ( + a, + b, + c, + alpha, + beta, + gamma, + ) # define the current sample + show_sample(_geom_.calc.sample.name, verbose=False) + + +def pa(): + """Report (all) the diffractometer settings.""" + _check_geom_selected() + _geom_.pa() + + +def wh(): + """Report (brief) where is the diffractometer.""" + _check_geom_selected() + _geom_.wh()