diff --git a/concert/base.py b/concert/base.py index b629abd4f..eea946816 100644 --- a/concert/base.py +++ b/concert/base.py @@ -720,6 +720,19 @@ def _check_limit(self, value): format(self._parameter.unit)) +class SelectionValue(ParameterValue): + + """Descriptor for :class:`.Selection` class.""" + + def __init__(self, instance, selection): + super(SelectionValue, self).__init__(instance, selection) + + @property + def values(self): + """Selection values.""" + return tuple(self._parameter.iterable) + + class Parameterizable(object): """ @@ -810,6 +823,8 @@ def install_parameters(self, params): def _install_parameter(self, param): if isinstance(param, Quantity): value = QuantityValue(self, param) + elif isinstance(param, Selection): + value = SelectionValue(self, param) else: value = ParameterValue(self, param) diff --git a/concert/devices/dummy.py b/concert/devices/dummy.py index babd5ad12..693e58730 100644 --- a/concert/devices/dummy.py +++ b/concert/devices/dummy.py @@ -1,5 +1,5 @@ """Dummy""" -from concert.base import Parameter +from concert.base import Parameter, Selection from concert.devices.base import Device from concert.async import async @@ -26,3 +26,20 @@ def _set_value(self, value): def do_nothing(self): """Do nothing.""" pass + + +class SelectionDevice(Device): + + """A dummy device with a selection.""" + + selection = Selection(range(3)) + + def __init__(self): + super(SelectionDevice, self).__init__() + self._selection = 0 + + def _get_selection(self): + return self._selection + + def _set_selection(self, selection): + self._selection = selection diff --git a/concert/helpers.py b/concert/helpers.py index 062a62601..d53b70cb2 100644 --- a/concert/helpers.py +++ b/concert/helpers.py @@ -1,5 +1,4 @@ import inspect -import numpy as np class Command(object): @@ -188,25 +187,26 @@ def __init__(self, dimension, units=None): self.units = units -class Range(object): +class Region(object): - """A Range holds a :class:`~concert.base.Parameter` and a (minimum, maximum, intervals) range - assigned to it. + """A Region holds a :class:`~concert.base.Parameter` and *values* which are the x-values of a + scan. You can create the values e.g. by numpy's *linspace* function:: + + import numpy as np + # minimum=0, maximum=10, intervals=100 + values = np.linspace(0, 10, 100) * q.mm """ - def __init__(self, parameter, minimum, maximum, intervals=64): + def __init__(self, parameter, values): self.parameter = parameter - self.minimum = minimum - self.maximum = maximum - self.intervals = intervals + self.values = values def __iter__(self): - """Return the range.""" - return (x for x in np.linspace(self.minimum, self.maximum, self.intervals)) + """Return region's iterator over its *values*.""" + return iter(self.values) def __repr__(self): - return 'Range({})'.format(str(self)) + return 'Region({})'.format(str(self)) def __str__(self): - return '{}, {}, {}, {}'.format(self.parameter.name, self.minimum, self.maximum, - self.intervals) + return '{}: {}'.format(self.parameter.name, self.values) diff --git a/concert/processes.py b/concert/processes.py index ffb3c1f4a..681545d7f 100644 --- a/concert/processes.py +++ b/concert/processes.py @@ -22,38 +22,63 @@ def _pull_first(tuple_list): yield tup[0] -def scan(feedback, *ranges): - """A multidimensional scan. *feedback* is a callable which takes no arguments and it provides - feedback after some parameter is changed. *ranges* specify the scanned parameters, they are - instances of :class:`concert.helpers.Range` class. The fastest changing parameter is the last - one specified. One would use it like this:: - - scan(feedback, Range(motor['position'], 0 * q.mm, 10 * q.mm, 10), - Range(camera['frame_rate'], 1 / q.s, 100 / q.s, 100)) +def scan(feedback, regions, callbacks=None): + """A multidimensional scan. *feedback* is a callable which takes no arguments and provides + feedback after some parameter is changed. *regions* specifies the scanned parameter, it is + either a :class:`concert.helpers.Region` or a list of those for multidimensional scan. The + fastest changing parameter is the last one specified. *callbacks* is a dictionary in the form + {region: function}, where *function* is a callable with no arguments (just like *feedback*) and + is called every time the parameter in *region* is changed. One would use a scan for example like + this:: + + import numpy as np + from concert.async import resolve + from concert.helpers import Region + + def take_flat_field(): + # Do something here + pass + + exp_region = Region(camera['exposure_time'], np.linspace(1, 100, 100) * q.ms) + position_region = Region(motor['position'], np.linspace(0, 180, 1000) * q.deg) + callbacks = {exp_region: take_flat_field} + + # This is a 2D scan with position_region in the inner loop. It acquires a tomogram, changes + # the exposure time and continues like this until all exposure times are exhausted. + # Take_flat_field is called every time the exposure_time of the camera is changed + # (in this case after every tomogram) and you can use it to correct the acquired images. + for result in resolve(scan(camera.grab, [exp_region, position_region], callbacks=callbacks)): + # Do something real instead of just a print + print result From the execution order it is equivalent to (in reality there is more for making the code asynchronous):: - for position in np.linspace(0 * q.mm, 10 * q.mm, 10): - for frame_rate in np.linspace(1 / q.s, 100 / q.s, 100): + for exp_time in np.linspace(1, 100, 100) * q.ms: + for position in np.linspace(0, 180, 1000) * q.deg: yield feedback() """ changes = [] + if not isinstance(regions, (list, tuple, np.ndarray)): + regions = [regions] + + if callbacks is None: + callbacks = {} # Changes store the indices at which parameters change, e.g. for two parameters and interval # lengths 2 for first and 3 for second changes = [3, 1], i. e. first parameter is changed when # the flattened iteration index % 3 == 0, second is changed every iteration. # we do this because parameter setting might be expensive even if the value does not change current_mul = 1 - for i in range(len(ranges))[::-1]: + for i in range(len(regions))[::-1]: changes.append(current_mul) - current_mul *= ranges[i].intervals + current_mul *= len(regions[i].values) changes.reverse() def get_changed(index): """Returns a tuple of indices of changed parameters at given iteration *index*.""" - return [i for i in range(len(ranges)) if index % changes[i] == 0] + return [i for i in range(len(regions)) if index % changes[i] == 0] @async def get_value(index, tup, previous): @@ -67,28 +92,32 @@ def get_value(index, tup, previous): changed = get_changed(index) futures = [] for i in changed: - futures.append(ranges[i].parameter.set(tup[i])) + futures.append(regions[i].parameter.set(tup[i])) wait(futures) + for i in changed: + if regions[i] in callbacks: + callbacks[regions[i]]() + return tup + (feedback(),) future = None - for i, tup in enumerate(product(*ranges)): + for i, tup in enumerate(product(*regions)): future = get_value(i, tup, future) yield future -def scan_param_feedback(scan_param_range, feedback_param): +def scan_param_feedback(scan_param_regions, feedback_param, callbacks=None): """ - Convenience function to scan one parameter and measure another. + Convenience function to scan some parameters and measure another parameter. - Scan the *scan_param_range* object and measure *feedback_param*. + Scan the *scan_param_regions* parameters and measure *feedback_param*. """ def feedback(): return feedback_param.get().result() - return scan(feedback, scan_param_range) + return scan(feedback, scan_param_regions, callbacks=callbacks) def ascan(param_list, n_intervals, handler, initial_values=None): diff --git a/concert/tests/integration/test_scan.py b/concert/tests/integration/test_scan.py index 2951c120c..799985803 100644 --- a/concert/tests/integration/test_scan.py +++ b/concert/tests/integration/test_scan.py @@ -1,10 +1,11 @@ from itertools import product +import numpy as np from concert.quantities import q from concert.tests import assert_almost_equal, TestCase from concert.devices.motors.dummy import LinearMotor from concert.processes import scan, ascan, dscan, scan_param_feedback from concert.async import resolve -from concert.helpers import Range +from concert.helpers import Region def compare_sequences(first_sequence, second_sequence, assertion): @@ -46,14 +47,16 @@ def test_process(self): def feedback(): return self.motor.position - param_range = Range(self.motor['position'], 1 * q.mm, 10 * q.mm, 12) + values = np.linspace(1, 10, 12) * q.mm + param_range = Region(self.motor['position'], values) - x, y = zip(*resolve(scan(feedback, param_range))) + x, y = zip(*list(resolve(scan(feedback, param_range)))) compare_sequences(x, y, self.assertEqual) def test_scan_param_feedback(self): p = self.motor['position'] - scan_param = Range(p, 1 * q.mm, 10 * q.mm, 10) + values = np.linspace(1, 10, 10) * q.mm + scan_param = Region(p, values) x, y = zip(*resolve(scan_param_feedback(scan_param, p))) compare_sequences(x, y, self.assertEqual) @@ -61,13 +64,15 @@ def test_scan_param_feedback(self): def test_multiscan(self): """A 2D scan.""" other = LinearMotor() - range_0 = Range(self.motor['position'], 0 * q.mm, 10 * q.mm, 2) - range_1 = Range(other['position'], 5 * q.mm, 10 * q.mm, 3) + values_0 = np.linspace(0, 10, 2) * q.mm + values_1 = np.linspace(5, 10, 3) * q.mm + range_0 = Region(self.motor['position'], values_0) + range_1 = Region(other['position'], values_1) def feedback(): return self.motor.position, other.position - gen = resolve(scan(feedback, range_0, range_1)) + gen = resolve(scan(feedback, [range_0, range_1])) p_0, p_1, result = zip(*gen) result_x, result_y = zip(*result) @@ -82,3 +87,16 @@ def feedback(): # feedback result is a tuple in this case, test both parts compare_sequences(result_x, p_0_exp, assert_almost_equal) compare_sequences(result_y, p_1_exp, assert_almost_equal) + + def test_callback(self): + called = [] + motor = LinearMotor() + values = np.linspace(0, 2, 3) * q.mm + qrange = Region(motor['position'], values) + + def callback(): + called.append(motor.position.to(q.mm).magnitude) + + list(resolve(scan(lambda: None, qrange, callbacks={qrange: callback}))) + + np.testing.assert_almost_equal(called, range(3)) diff --git a/concert/tests/unit/test_measures.py b/concert/tests/unit/test_measures.py index 4f39288f3..043eb75b6 100644 --- a/concert/tests/unit/test_measures.py +++ b/concert/tests/unit/test_measures.py @@ -6,7 +6,7 @@ from concert.tests.util.rotationaxis import SimulationCamera from concert.processes import scan from concert.measures import rotation_axis -from concert.helpers import Range +from concert.helpers import Region class TestRotationAxisMeasure(TestCase): @@ -29,8 +29,8 @@ def setUp(self): def make_images(self, x_angle, z_angle, intervals=10): self.x_motor.position = z_angle self.z_motor.position = x_angle - prange = Range(self.y_motor["position"], minimum=0 * q.rad, maximum=2 * np.pi * q.rad, - intervals=intervals) + values = np.linspace(0, 2 * np.pi, intervals) * q.rad + prange = Region(self.y_motor["position"], values) result = [f.result()[1] for f in scan(self.image_source.grab, prange)] return result diff --git a/concert/tests/unit/test_parameter.py b/concert/tests/unit/test_parameter.py index 46a2432c2..4aa9432ff 100644 --- a/concert/tests/unit/test_parameter.py +++ b/concert/tests/unit/test_parameter.py @@ -1,10 +1,11 @@ import time +import numpy as np from concert.quantities import q from concert.tests import TestCase -from concert.base import (Parameterizable, Parameter, Quantity, State, Selection, - transition, check, - SoftLimitError, LockError, ParameterError, - UnitError, WriteAccessError) +from concert.base import (Parameterizable, Parameter, Quantity, State, transition, check, + SoftLimitError, LockError, ParameterError, UnitError, + WriteAccessError) +from concert.devices.dummy import SelectionDevice from concert.async import WaitError @@ -63,17 +64,6 @@ def _get_foo(self): return self._value -class SelectionDevice(Parameterizable): - - something = Selection([1, 2, 3]) - - def _get_something(self): - return 1 - - def _set_something(self, value): - pass - - class TestDescriptor(TestCase): def setUp(self): @@ -272,8 +262,11 @@ def setUp(self): def test_correct_access(self): for i in range(3): - self.device.something = i + 1 + self.device.selection = i def test_wrong_access(self): with self.assertRaises(WriteAccessError): - self.device.something = 4 + self.device.selection = 4 + + def test_iterable_access(self): + np.testing.assert_equal(self.device['selection'].values, range(3)) diff --git a/docs/user/topics/processes.rst b/docs/user/topics/processes.rst index e7d1ee91c..82a99d4e1 100644 --- a/docs/user/topics/processes.rst +++ b/docs/user/topics/processes.rst @@ -10,7 +10,7 @@ For instance, to set 10 motor positions between 5 and 12 millimeter and acquire the flow rate of a pump could be written like:: from concert.processes import scan - from concert.helpers import Range + from concert.helpers import Region # Assume motor and pump are already defined @@ -18,20 +18,22 @@ the flow rate of a pump could be written like:: return pump.flow_rate # A parameter object encapsulated with its scanning positions - param_range = Range(motor['position'], 5*q.mm, 12*q.mm, 10) - - generator = scan(get_flow_rate, param_range) - -The parameter is first wrapped into a :class:`concert.helpers.Range` object -which holds the parameter and the scanning range. :func:`.scan` is -multidimensional, i.e. you can scan as many parameters as you need, from 1D -scans to complicated multidimensional scans. :func:`.scan` returns a generator -which yields futures. This way the scan is asynchronous and you can continuously -see its progress by resolving the yielded futures. Each future then returns the -result of one iteration as tuples, which depends on how many parameters scan -gets on input (scan dimensionality). The general signature of future results is -*(x_0, x_1, ..., x_n, y)*, where *x_i* are the scanned parameter values and *y* -is the result of *feedback*. For resolving the futures you would use + region = Region(motor['position'], np.linspace(5, 12, 10) * q.mm) + + generator = scan(get_flow_rate, region) + +The parameter is first wrapped into a :class:`concert.helpers.Region` object +which holds the parameter and the scanning region for parameters. :func:`.scan` +is multidimensional, i.e. you can scan as many parameters as you need, from 1D +scans to complicated multidimensional scans. If you want to scan just one +parameter, pass the region instance, if you want to scan more, pass a list or +tuple of region instances. :func:`.scan` returns a generator which yields +futures. This way the scan is asynchronous and you can continuously see its +progress by resolving the yielded futures. Each future then returns the result +of one iteration as tuples, which depends on how many parameters scan gets on +input (scan dimensionality). The general signature of future results is *(x_0, +x_1, ..., x_n, y)*, where *x_i* are the scanned parameter values and *y* is the +result of *feedback*. For resolving the futures you would use :func:`concert.async.resolve` like this:: from concert.async import resolve @@ -50,13 +52,39 @@ To continuously plot the values obtained by a 1D scan by a inject(resolve(generator), viewer()) -A two-dimensional scan with *range_2* parameter in the inner (fastest changing) +A two-dimensional scan with *region_2* parameter in the inner (fastest changing) loop could look as follows:: - range_1 = Range(motor_1['position'], 5*q.mm, 12*q.mm, 10) - range_2 = Range(motor_2['position'], 0*q.mm, 10*q.mm, 5) + region_1 = Region(motor_1['position'], np.linspace(5, 12, 10) * q.mm) + region_2 = Region(motor_2['position'], np.linspace(0, 10, 5) * q.mm) - generator = scan(get_flow_rate, range_1, range_2) + generator = scan(get_flow_rate, [region_1, region_2]) + +You can set callbacks which are called when some parameter is changed during a +scan. This can be useful when you e.g. want to acquire a flat field when the +scan takes a long time. For example, to acquire tomograms with different +exposure times and flat field images you can do:: + + + import numpy as np + from concert.async import resolve + from concert.helpers import Region + + def take_flat_field(): + # Do something here + pass + + exp_region = Region(camera['exposure_time'], np.linspace(1, 100, 100) * q.ms) + position_region = Region(motor['position'], np.linspace(0, 180, 1000) * q.deg) + callbacks = {exp_region: take_flat_field} + + # This is a 2D scan with position_region in the inner loop. It acquires a tomogram, changes + # the exposure time and continues like this until all exposure times are exhausted. + # Take_flat_field is called every time the exposure_time of the camera is changed + # (in this case after every tomogram) and you can use it to correct the acquired images. + for result in resolve(scan(camera.grab, [exp_region, position_region], callbacks=callbacks)): + # Do something real instead of just a print + print result :func:`.ascan` and :func:`.dscan` are used to scan multiple parameters