From 2022b3c6ae12fdf3379897f16794c5160890a7a4 Mon Sep 17 00:00:00 2001 From: David Hammer <dhammer@mailbox.org> Date: Thu, 24 Feb 2022 15:04:31 +0100 Subject: [PATCH] Adding initial middlelayer versions of geometry device classes --- setup.py | 4 +- src/calng/mdl_geometry_base.py | 313 +++++++++++++++++++++++++++++++++ 2 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 src/calng/mdl_geometry_base.py diff --git a/setup.py b/setup.py index 01d3fadb..21caef8e 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,9 @@ setup(name='calng', ], 'karabo.middlelayer_device': [ - 'CalibrationManager = calng.CalibrationManager:CalibrationManager' + 'CalibrationManager = calng.CalibrationManager:CalibrationManager', + 'MdlAgipd1MGeometry = calng.mdl_geometry_base:MdlAgipd1MGeometry', + 'MdlJungfrauGeometry = calng.mdl_geometry_base:MdlJungfrauGeometry', ], }, package_data={'': ['kernels/*']}, diff --git a/src/calng/mdl_geometry_base.py b/src/calng/mdl_geometry_base.py new file mode 100644 index 00000000..c56125f7 --- /dev/null +++ b/src/calng/mdl_geometry_base.py @@ -0,0 +1,313 @@ +import contextlib +import logging +import pickle + +import extra_geom +from karabo.middlelayer import ( + AccessMode, + Assignment, + Bool, + Configurable, + Device, + Double, + EncodingType, + Hash, + Image, + ImageData, + Int32, + Node, + OutputChannel, + Slot, + State, + String, + VectorChar, + VectorHash, +) +import matplotlib.pyplot as plt +from matplotlib.backends.backend_agg import FigureCanvasAgg +import numpy as np + +from ._version import version as deviceVersion + + +class GeometrySchema(Configurable): + pickledGeometry = VectorChar( + displayedName="Pickled geometry", + assignment=Assignment.OPTIONAL, + defaultValue=[], + ) + + +class GeometryFileNode(Configurable): + filePath = String( + defaultValue="", + displayedName="File path", + description="Full path (including filename and suffix) to the desired geometry " + "file. Keep in mind that the default directory is $KARABO/var/data on device " + "server node, so it's probably wise to give absolute path.", + assignment=Assignment.OPTIONAL, + accessMode=AccessMode.RECONFIGURABLE, + ) + updateManualOnLoad = Bool( + defaultValue=True, + displayedName="Update manual settings", + description="If this flag is on, the manual settings on this device will be " + "updated according to the loaded geometry file. This is useful when you want " + "to load a file and then tweak the geometry a bit.", + assignment=Assignment.OPTIONAL, + accessMode=AccessMode.RECONFIGURABLE, + ) + + +class ManualGeometryBase(Device): + __version__ = deviceVersion + geometry_class = None # subclass must set + # subclass must add slot setManual + + geometryPreview = Image( + ImageData(np.empty(0, dtype=np.uint32)), + displayedName="Geometry preview", + encoding=EncodingType.RGBA, + ) + + geometryOutput = OutputChannel(GeometrySchema) + + geometryFile = Node( + GeometryFileNode, + displayedName="Geometry file", + description="Allows loading geometry from CrystFEL geometry file", + ) + + @Slot( + displayedName="Send geometry", + description="Send current geometry on output channel. This output channel is " + "typically used by assembler devices waiting for new geometries. Updating the " + "geometry will usually automatically cause update, but hit this slot in case " + "geometry is missing somewhere.", + allowedStates=[State.ACTIVE], + ) + async def sendGeometry(self): + self.geometryOutput.schema.pickledGeometry = self.pickled_geometry + await self.geometryOutput.writeData() + + @Slot( + displayedName="Update preview", + allowedStates=[State.ACTIVE], + ) + async def updatePreview(self): + axis = self.geometry.inspect() + axis.figure.tight_layout(pad=0) + axis.figure.set_facecolor("none") + # axis.figure.set_size_inches(6, 6) + # axis.figure.set_dpi(300) + canvas = FigureCanvasAgg(axis.figure) + canvas.draw() + image_buffer = np.frombuffer(canvas.buffer_rgba(), dtype=np.uint8).reshape( + canvas.get_width_height()[::-1] + (4,) + ) + self.geometryPreview = ImageData( + image_buffer, encoding=EncodingType.RGBA, bitsPerPixel=3 * 8 + ) + self._set_status("Preview updated") + + @Slot( + displayedName="Load from file", + allowedStates=[State.ACTIVE], + ) + async def loadFromFile(self): + with self.push_state(State.CHANGING): + geometry = None + + file_path = self.geometryFile.filePath.value + self._set_status(f"Loading geometry from {file_path}...") + + try: + geometry = self.geometry_class.from_crystfel_geom(file_path) + except FileNotFoundError: + self._set_status("Geometry file not found", level=logging.WARN) + except RuntimeError as e: + self._set_status( + f"Failed to load geometry file: {e}", level=logging.WARN + ) + except Exception as e: + self._set_status( + f"Misc. exception when loading geometry file: {e}", + level=logging.WARN, + ) + else: + await self._set_geometry(geometry) + self._set_status("Successfully loaded geometry from file") + if self.geometryFile.updateManualOnLoad.value: + self._set_status( + "Updating manual settings on device to reflect loaded geometry" + ) + self._update_manual_from_current() + return True + + return False + + def _update_manual_from_current(self): + # subclass should implement (neat when loading from CrystFEL geom) + raise NotImplementedError() + + async def _set_geometry(self, geometry, update_preview=True, send=True): + self.geometry = geometry + self.pickled_geometry = pickle.dumps(self.geometry) + if update_preview: + await self.updatePreview() + if send: + await self.sendGeometry() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + plt.switch_backend("agg") # plotting backend which works for preview hack + + async def onInitialization(self): + self.state = State.INIT + # TODO: try to load file if set + await self.setManual() + self.state = State.ACTIVE + + @contextlib.contextmanager + def push_state(self, state): + previous_state = self.state + self.state = state + try: + yield + finally: + self.state = previous_state + + def _set_status(self, text, level=logging.INFO): + """Add and log a status message. + + Suppresses throttling from the gui server. + """ + + self.status = text + self.logger.log(level, text) + + +def makeXYCoordinateNode(default_x, default_y): + class XYCoordinate(Configurable): + x = Double( + defaultValue=default_x, + accessMode=AccessMode.RECONFIGURABLE, + assignment=Assignment.OPTIONAL, + ) + y = Double( + defaultValue=default_y, + accessMode=AccessMode.RECONFIGURABLE, + assignment=Assignment.OPTIONAL, + ) + + return Node(XYCoordinate) + + +def makeQuadrantCornersNode(default_values): + assert len(default_values) == 4 + assert all(len(x) == 2 for x in default_values) + + class QuadrantCornersNode(Configurable): + Q1 = makeXYCoordinateNode(*default_values[0]) + Q2 = makeXYCoordinateNode(*default_values[1]) + Q3 = makeXYCoordinateNode(*default_values[2]) + Q4 = makeXYCoordinateNode(*default_values[3]) + + return Node(QuadrantCornersNode) + + +class ManualQuadrantsGeometryBase(ManualGeometryBase): + quadrantCorners = None # subclass must define (with nice defaults) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._quadrant_corners = [ + self.quadrantCorners.Q1, + self.quadrantCorners.Q2, + self.quadrantCorners.Q3, + self.quadrantCorners.Q4, + ] + + @Slot( + displayedName="Set from device", + allowedStates=[State.ACTIVE], + ) + async def setManual(self): + self._set_status("Updating geometry from manual configuration") + with self.push_state(State.CHANGING): + geometry = self.geometry_class.from_quad_positions( + [(Q.x.value, Q.y.value) for Q in self._quadrant_corners] + ) + await self._set_geometry(geometry) + + def _update_manual_from_current(self): + for corner, (x, y) in zip( + self._quadrant_corners, self.geometry.quad_positions() + ): + corner.x = x + corner.y = y + + +class MdlAgipd1MGeometry(ManualQuadrantsGeometryBase): + geometry_class = extra_geom.AGIPD_1MGeometry + quadrantCorners = makeQuadrantCornersNode( + [ + (-525, 625), + (-550, -10), + (520, -160), + (542.5, 475), + ] + ) + + +class ModuleListItem(Configurable): + posX = Double(defaultValue=0) + posY = Double(defaultValue=0) + orientX = Int32(defaultValue=1) + orientY = Int32(defaultValue=1) + + +class ManualModuleListGeometryBase(ManualGeometryBase): + moduleList = None # subclass must define (with nice defaults) + + @Slot( + displayedName="Set from device", + allowedStates=[State.ACTIVE], + ) + async def setManual(self): + self._set_status("Updating geometry from manual configuration") + with self.push_state(State.CHANGING): + self._geometry = self.geometry_class.from_module_positions( + [(x, y) for (x, y, _, _) in self.moduleList.value], + [ + (orient_x, orient_y) + for (_, _, orient_x, orient_y) in self.moduleList.value + ], + ) + self._pickled_geometry = pickle.dumps(self._geometry) + + def _update_manual_from_current(self): + self._set_status("Not yet able to update manual config from file") + + +class MdlJungfrauGeometry(ManualModuleListGeometryBase): + geometry_class = extra_geom.JUNGFRAUGeometry + moduleList = VectorHash( + displayedName="Modules", + rows=ModuleListItem, + defaultValue=[ + Hash("posX", x, "posY", y, "orientX", ox, "orientY", oy) + for (x, y, ox, oy) in [ + (95, 564, -1, -1), + (95, 17, -1, -1), + (95, -530, -1, -1), + (95, -1077, -1, -1), + (-1125, -1078, 1, 1), + (-1125, -531, 1, 1), + (-1125, 16, 1, 1), + (-1125, 563, 1, 1), + ] + ], + accessMode=AccessMode.RECONFIGURABLE, + assignment=Assignment.OPTIONAL, + ) -- GitLab