Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add aperture and new capabilities for linking controls. #19

Closed
wants to merge 12 commits into from
10 changes: 10 additions & 0 deletions CHANGES.rst
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
Changelog (nionswift-usim)
==========================

0.2.2 (unreleased)
------------------
- Add aperture that can be moved and "distorted" (i.e. dipole and quadropole effect simulation)
- Add functions to 'Instrument' that facilitate adding new inputs to existing controls
- Allow input weights for controls to be controls in addition to float
- Add option to attach a python expression as control input (only one expression per control can be set,
but it can be arbitrarily complex, as long as it can be evaluated by 'eval')
- Changed meaning of convergence angle to reflect its real meaning (in the simulator it only controls the size of
the aperture on the ronchigram camera, the effect on the scan is not simulated yet)

0.2.1 (2019-11-27)
------------------
- Minor changes to be compatible with nionswift-instrumentation.
Expand Down
2 changes: 1 addition & 1 deletion nionswift_plugin/usim/CameraSimulator.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def get_frame_data(self, readout_area: Geometry.IntRect, binning_shape: Geometry
raise NotImplementedError

def get_total_counts(self, exposure_s: float) -> float:
beam_current_pa = self.instrument.beam_current * 1E12
beam_current_pa = self.instrument.GetVal("BeamCurrent") * 1E12
e_per_pa = 6.242E18 / 1E12
return beam_current_pa * e_per_pa * exposure_s * self._counts_per_electron

Expand Down
2 changes: 1 addition & 1 deletion nionswift_plugin/usim/EELSCameraSimulator.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def plot_spectrum(feature, data: numpy.ndarray, multiplier: float, energy_calibr
class EELSCameraSimulator(CameraSimulator.CameraSimulator):
depends_on = ["is_slit_in", "probe_state", "probe_position", "live_probe_position", "is_blanked", "ZLPoffset",
"stage_position_m", "beam_shift_m", "features", "energy_offset_eV", "energy_per_channel_eV",
"beam_current"]
"BeamCurrent"]

def __init__(self, instrument, sensor_dimensions: Geometry.IntSize, counts_per_electron: int):
super().__init__(instrument, "eels", sensor_dimensions, counts_per_electron)
Expand Down
212 changes: 155 additions & 57 deletions nionswift_plugin/usim/InstrumentDevice.py
100644 → 100755

Large diffs are not rendered by default.

29 changes: 25 additions & 4 deletions nionswift_plugin/usim/InstrumentPanel.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,14 @@ def close(self):

class PositionWidget(Widgets.CompositeWidgetBase):

def __init__(self, ui, label: str, object, xy_property):
def __init__(self, ui, label: str, object, xy_property, unit="nm", multiplier=1E9):
super().__init__(ui.create_row_widget())

stage_x_field = ui.create_line_edit_widget()
stage_x_field.bind_text(Control2DBinding(object, xy_property, "x", Converter.PhysicalValueToStringConverter("nm", 1E9)))
stage_x_field.bind_text(Control2DBinding(object, xy_property, "x", Converter.PhysicalValueToStringConverter(unit, multiplier)))

stage_y_field = ui.create_line_edit_widget()
stage_y_field.bind_text(Control2DBinding(object, xy_property, "y", Converter.PhysicalValueToStringConverter("nm", 1E9)))
stage_y_field.bind_text(Control2DBinding(object, xy_property, "y", Converter.PhysicalValueToStringConverter(unit, multiplier)))

row = self.content_widget

Expand Down Expand Up @@ -105,7 +105,7 @@ def __init__(self, document_controller, instrument: InstrumentDevice.Instrument)
voltage_field.bind_text(Binding.PropertyBinding(instrument, "voltage", converter=Converter.PhysicalValueToStringConverter(units="keV", multiplier=1E-3)))

beam_current_field = ui.create_line_edit_widget()
beam_current_field.bind_text(Binding.PropertyBinding(instrument, "beam_current", converter=Converter.PhysicalValueToStringConverter(units="pA", multiplier=1E12)))
beam_current_field.bind_text(ControlBinding(instrument, "BeamCurrent", converter=Converter.PhysicalValueToStringConverter(units="pA", multiplier=1E12)))

stage_position_widget = PositionWidget(ui, _("Stage"), instrument, "stage_position_m")

Expand Down Expand Up @@ -133,6 +133,15 @@ def __init__(self, document_controller, instrument: InstrumentDevice.Instrument)
slit_in_checkbox = ui.create_check_box_widget(_("Slit In"))
slit_in_checkbox.bind_checked(Binding.PropertyBinding(instrument, "is_slit_in"))

voa_in_checkbox = ui.create_check_box_widget(_("VOA In"))
voa_in_checkbox.bind_checked(ControlBinding(instrument, "S_VOA"))

convergenve_angle_field = ui.create_line_edit_widget()
convergenve_angle_field.bind_text(ControlBinding(instrument, "ConvergenceAngle", converter=Converter.PhysicalValueToStringConverter(units="mrad", multiplier=1E3)))

c_aperture_widget = PositionWidget(ui, _("CAperture"), instrument, "CAperture", unit="mrad", multiplier=1E3)
aperture_round_widget = PositionWidget(ui, _("ApertureRound"), instrument, "ApertureRound", unit="", multiplier=1)

energy_offset_field = ui.create_line_edit_widget()
energy_offset_field.bind_text(Binding.PropertyBinding(instrument, "energy_offset_eV", converter=Converter.FloatToStringConverter()))

Expand All @@ -142,6 +151,8 @@ def __init__(self, document_controller, instrument: InstrumentDevice.Instrument)
beam_row = ui.create_row_widget()
beam_row.add_spacing(8)
beam_row.add(blanked_checkbox)
beam_row.add_spacing(8)
beam_row.add(voa_in_checkbox)
beam_row.add_stretch()

eels_row = ui.create_row_widget()
Expand Down Expand Up @@ -191,6 +202,13 @@ def __init__(self, document_controller, instrument: InstrumentDevice.Instrument)
beam_current_row.add(beam_current_field)
beam_current_row.add_stretch()

convergence_angle_row = ui.create_row_widget()
convergence_angle_row.add_spacing(8)
convergence_angle_row.add_spacing(8)
convergence_angle_row.add(ui.create_label_widget("Convergence Angle"))
convergence_angle_row.add(convergenve_angle_field)
convergence_angle_row.add_stretch()

column = self.content_widget

column.add(sample_row)
Expand All @@ -206,6 +224,9 @@ def __init__(self, document_controller, instrument: InstrumentDevice.Instrument)
column.add(c32_widget)
column.add(c34_widget)
column.add(beam_row)
column.add(convergence_angle_row)
column.add(c_aperture_widget)
column.add(aperture_round_widget)
column.add(eels_row)
column.add_stretch()

Expand Down
101 changes: 97 additions & 4 deletions nionswift_plugin/usim/RonchigramCameraSimulator.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# standard libraries
import typing
import copy
import math
import numpy
Expand Down Expand Up @@ -217,26 +218,114 @@ def get_chi(coefficient_name):
return numpy.zeros((height, width))


def ellipse_radius(polar_angle: typing.Union[float, numpy.ndarray], a: float, b: float, rotation: float) -> typing.Union[float, numpy.ndarray]:
"""
Returns the radius of a point lying on an ellipse with the given parameters. The ellipse is described in polar
coordinates here, which makes it easy to incorporate a rotation.

Parameters
-----------
polar_angle : float or numpy.ndarray
Polar angle of a point to which the corresponding radius should be calculated (rad).
a : float
Length of the major half-axis of the ellipse.
b : float
Length of the minor half-axis of the ellipse.
rotation : Rotation of the ellipse with respect to the x-axis (rad). Counter-clockwise is positive.

Returns
--------
radius : float or numpy.ndarray
Radius of a point lying on an ellipse with the given parameters.
"""

return a*b/numpy.sqrt((b*numpy.cos(polar_angle+rotation))**2+(a*numpy.sin(polar_angle+rotation))**2)


def draw_ellipse(image: numpy.ndarray, ellipse: typing.Tuple[float, float, float, float, float], *,
color: typing.Any=1.0) -> None:
"""
Draws an ellipse on a 2D-array.

Parameters
----------
image : array
The array on which the ellipse will be drawn. Note that the data will be modified in place.
ellipse : tuple
A tuple describing an ellipse with the same moments as the aperture. The values must be (in this order):
[0] The y-coordinate of the center.
[1] The x-coordinate of the center.
[2] The length of the major half-axis
[3] The length of the minor half-axis
[4] The rotation of the ellipse in rad.
color : optional
The color to which the pixels inside the given ellipse will be set. Note that `color` will be cast to the
type of `image` automatically. If this is not possible, an exception will be raised. The default is 1.0.

Returns
--------
None
"""
shape = image.shape
assert len(shape) == 2, 'Can only draw an ellipse on a 2D-array.'
#coords = np.mgrid[-shape[0]/2:shape[0]/2:shape[0]*1j, -shape[1]/2:shape[1]/2:shape[1]*1j]
top = max(int(ellipse[0] - ellipse[2]), 0)
left = max(int(ellipse[1] - ellipse[2]), 0)
bottom = min(int(ellipse[0] + ellipse[2]) + 1, shape[0])
right = min(int(ellipse[1] + ellipse[2]) + 1, shape[1])
coords = numpy.mgrid[top-ellipse[0]:bottom-ellipse[0], left-ellipse[1]:right-ellipse[1]]
#coords[0] -= ellipse[0]
#coords[1] -= ellipse[1]
radii = numpy.sqrt(numpy.sum(coords**2, axis=0))
polar_angles = numpy.arctan2(coords[0], coords[1])
ellipse_radii = ellipse_radius(polar_angles, *ellipse[2:])
image[top:bottom, left:right][radii<ellipse_radii] = color


class RonchigramCameraSimulator(CameraSimulator.CameraSimulator):
depends_on = ["C10Control", "C12Control", "C21Control", "C23Control", "C30Control", "C32Control", "C34Control",
"C34Control", "stage_position_m", "probe_state", "probe_position", "live_probe_position", "features",
"beam_shift_m", "is_blanked", "beam_current"]
"beam_shift_m", "is_blanked", "BeamCurrent", "CAperture", "ApertureRound", "S_VOA", "ConvergenceAngle"]

def __init__(self, instrument, ronchigram_shape: Geometry.IntSize, counts_per_electron: int, convergence_angle: float):
def __init__(self, instrument, ronchigram_shape: Geometry.IntSize, counts_per_electron: int, stage_size_nm: float):
super().__init__(instrument, "ronchigram", ronchigram_shape, counts_per_electron)
self.__last_sample = None
self.__cached_frame = None
max_defocus = instrument.max_defocus
tv_pixel_angle = math.asin(instrument.stage_size_nm / (max_defocus * 1E9)) / ronchigram_shape.height
self.__tv_pixel_angle = tv_pixel_angle
self.__convergence_angle_rad = convergence_angle
self.__stage_size_nm = stage_size_nm
self.__max_defocus = max_defocus
self.__data_scale = 1.0
self.__aperture_ellipse = None
self.__aperture_mask = None
theta = tv_pixel_angle * ronchigram_shape.height / 2 # half angle on camera
defocus_m = instrument.defocus_m
self.__aberrations_controller = AberrationsController(ronchigram_shape.height, ronchigram_shape.width, theta, max_defocus, defocus_m)
self.noise = Noise.PoissonNoise()

def _draw_aperture(self, frame_data: numpy.ndarray, binning_shape: Geometry.IntSize, enlarge_by: float=0.0):
# TODO handle asymmetric binning
binning = binning_shape[0]
position = self.instrument.GetVal2D("CAperture")
aperture_round = self.instrument.GetVal2D("ApertureRound")
shape = frame_data.shape
ellipse_center = 0.5*shape[0] + position.y / self.__tv_pixel_angle / binning, 0.5*shape[1] + position.x / self.__tv_pixel_angle / binning
excentricity = math.sqrt(aperture_round[0]**2 + aperture_round[1]**2)
# adapt excentricity so that control behaves linearly and is defined for all values
# this is the modified inverse function of the calculation of the major half-axis a
excentricity = math.sqrt(1-1/(1+abs(excentricity))**4)
direction = numpy.arctan2(aperture_round[0], aperture_round[1])
# Calculate a and b (the ellipse half-axes) from excentricity. Keep ellipse area constant
convergence_angle = self.instrument.GetVal("ConvergenceAngle") * (1 + enlarge_by)
convergence_angle_pixels = convergence_angle / self.__tv_pixel_angle / binning
a = math.sqrt(convergence_angle_pixels**2 / math.sqrt(1 - excentricity**2))
b = convergence_angle_pixels**2 / a
self.__aperture_ellipse = ellipse_center + (a, b, direction)
aperture_mask = numpy.zeros_like(frame_data)
draw_ellipse(aperture_mask, self.__aperture_ellipse)
frame_data *= aperture_mask

def get_frame_data(self, readout_area: Geometry.IntRect, binning_shape: Geometry.IntSize, exposure_s: float, scan_context, parked_probe_position) -> DataAndMetadata.DataAndMetadata:
# check if one of the arguments has changed since last call
new_frame_settings = [readout_area, binning_shape, exposure_s, copy.deepcopy(scan_context)]
Expand All @@ -251,7 +340,7 @@ def get_frame_data(self, readout_area: Geometry.IntRect, binning_shape: Geometry
height = readout_area.height
width = readout_area.width
offset_m = self.instrument.stage_position_m
full_fov_nm = abs(self.__max_defocus) * math.sin(self.__convergence_angle_rad) * 1E9
full_fov_nm = self.__stage_size_nm
fov_size_nm = Geometry.FloatSize(full_fov_nm * height / self._sensor_dimensions.height, full_fov_nm * width / self._sensor_dimensions.width)
center_nm = Geometry.FloatPoint(full_fov_nm * (readout_area.center.y / self._sensor_dimensions.height- 0.5), full_fov_nm * (readout_area.center.x / self._sensor_dimensions.width - 0.5))
size = Geometry.IntSize(height, width)
Expand Down Expand Up @@ -299,6 +388,10 @@ def get_frame_data(self, readout_area: Geometry.IntRect, binning_shape: Geometry
aberrations["c34a"] = self.instrument.GetVal2D("C34Control").x
aberrations["c34b"] = self.instrument.GetVal2D("C34Control").y
data = self.__aberrations_controller.apply(aberrations, data)
if self.instrument.GetVal("S_VOA") > 0:
self._draw_aperture(data, binning_shape)
elif self.instrument.GetVal("S_MOA") > 0:
self._draw_aperture(data, binning_shape, enlarge_by=0.1)

intensity_calibration = Calibration.Calibration(units="counts")
dimensional_calibrations = self.get_dimensional_calibrations(readout_area, binning_shape)
Expand Down
80 changes: 79 additions & 1 deletion nionswift_plugin/usim/test/InstrumentDevice_test.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def test_ronchigram_handles_dependencies_properly(self):
instrument.SetValDelta("ZLPoffset", 1)
self.assertFalse(camera._needs_recalculation)
camera._needs_recalculation = False
instrument.beam_current += 1
instrument.SetValDelta("BeamCurrent", 1)
self.assertTrue(camera._needs_recalculation)

def test_powerlaw(self):
Expand Down Expand Up @@ -104,6 +104,7 @@ def test_eels_data_is_consistent_when_energy_offset_changes_with_negative_zlp_of

def test_eels_data_thickness_is_consistent(self):
instrument = InstrumentDevice.Instrument("usim_stem_controller")
instrument.sample_index = 0
# set up the scan context; these are here temporarily until the scan context architecture is fully implemented
instrument._update_scan_context(Geometry.FloatPoint(), Geometry.FloatSize.make((256, 256)), 0.0)
instrument._set_scan_context_probe_position(instrument.scan_context, Geometry.FloatPoint(0.5, 0.5))
Expand Down Expand Up @@ -393,6 +394,83 @@ def test_accessing_non_exisiting_axis_fails(self):
with self.assertRaises(AttributeError):
getattr(instrument.get_control("C12"), "ne")

def test_get_drive_strength_with_arrow_syntax(self):
instrument = InstrumentDevice.Instrument("usim_stem_controller")
success, value = instrument.TryGetVal("CApertureOffset.x->CAperture.x")
self.assertTrue(success)
self.assertAlmostEqual(value, 1.0)

def test_set_drive_strength_with_arrow_syntax(self):
instrument = InstrumentDevice.Instrument("usim_stem_controller")
success = instrument.SetVal("CApertureOffset.x->CAperture.x", 0.5)
self.assertTrue(success)
success = instrument.SetVal("CApertureOffset.y->CAperture.y", 0.2)
self.assertTrue(success)
value = instrument.GetVal("CApertureOffset.x->CAperture.x")
self.assertAlmostEqual(value, 0.5)
value = instrument.GetVal("CApertureOffset.y->CAperture.y")
self.assertAlmostEqual(value, 0.2)

def test_get_drive_strength_fails_for_non_existing_drive(self):
instrument = InstrumentDevice.Instrument("usim_stem_controller")
success, value = instrument.TryGetVal("CApertureOffset.y->CAperture.x")
self.assertFalse(success)

def test_use_control_as_input_weight_works(self):
instrument = InstrumentDevice.Instrument("usim_stem_controller")
weight_control = instrument.create_control("weight_control")
instrument.add_control(weight_control)
input_control = instrument.create_control("input_control")
instrument.add_control(input_control)
test_control = instrument.create_control("test_control", weighted_inputs=[(input_control, weight_control)])
instrument.add_control(test_control)
self.assertAlmostEqual(instrument.GetVal("test_control"), 0)
self.assertTrue(instrument.SetVal("input_control", 1.0))
self.assertAlmostEqual(instrument.GetVal("test_control"), 0)
self.assertTrue(instrument.SetVal("weight_control", 1.0))
self.assertAlmostEqual(instrument.GetVal("test_control"), 1.0)

def test_expression(self):
instrument = InstrumentDevice.Instrument("usim_stem_controller")
weight_control = instrument.create_control("weight_control")
instrument.add_control(weight_control)
input_control = instrument.create_control("input_control")
instrument.add_control(input_control)
other_control = instrument.create_control("other_control")
instrument.add_control(other_control)
test_control = instrument.create_control("test_control")
instrument.add_control(test_control)
test_control.set_expression("input_control*weight_control/2 + x",
variables={"input_control": "input_control",
"weight_control": weight_control,
"x": "other_control"},
instrument=instrument)
self.assertAlmostEqual(instrument.GetVal("test_control"), 0)
self.assertTrue(instrument.SetVal("input_control", 1.0))
self.assertAlmostEqual(instrument.GetVal("test_control"), 0)
self.assertTrue(instrument.SetVal("weight_control", 2.0))
self.assertAlmostEqual(instrument.GetVal("test_control"), 1.0)

def test_using_control_in_its_own_expression_raises_value_error(self):
instrument = InstrumentDevice.Instrument("usim_stem_controller")
test_control = instrument.create_control("test_control")
instrument.add_control(test_control)
test_control.add_dependent(test_control)
with self.assertRaises(ValueError):
test_control.set_expression('test_control', variables={'test_control': test_control})

def test_add_input_for_existing_control(self):
instrument = InstrumentDevice.Instrument("usim_stem_controller")
test_control = instrument.create_control("test_control")
other_control = instrument.create_control("other_control")
weight_control = instrument.create_control("weight_control")
instrument.add_control(test_control)
instrument.add_control(other_control)
instrument.add_control(weight_control)
instrument.add_control_inputs('test_control', [(other_control, weight_control)])
# Add it a second time to test add existing control
instrument.add_control_inputs('test_control', [(other_control, weight_control)])


if __name__ == '__main__':
unittest.main()