From 4dfa248d64583ae96801ffdbdd2a8e8d32349f3f Mon Sep 17 00:00:00 2001 From: David Hammer <dhammer@mailbox.org> Date: Thu, 24 Feb 2022 09:39:25 +0100 Subject: [PATCH] Geometry device: overhaul + allow loading CrystFEL --- src/calng/DetectorAssembler.py | 2 +- src/calng/base_geometry.py | 246 ++++++++++++++++++++++++++++----- src/calng/scenes.py | 2 +- 3 files changed, 212 insertions(+), 38 deletions(-) diff --git a/src/calng/DetectorAssembler.py b/src/calng/DetectorAssembler.py index c288d0c0..7da3cc2e 100644 --- a/src/calng/DetectorAssembler.py +++ b/src/calng/DetectorAssembler.py @@ -243,7 +243,7 @@ class DetectorAssembler(TrainMatcher.TrainMatcher): continue geometry_device = geometry_device_list[0].split(":")[0] self.log.INFO(f"Asking {geometry_device} for a geometry") - self.signalSlotable.call(geometry_device, "pleaseSendYourGeometry") + self.signalSlotable.call(geometry_device, "sendGeometry") time.sleep(1) if self._geometry is not None: diff --git a/src/calng/base_geometry.py b/src/calng/base_geometry.py index ee3748fc..1e5f12d2 100644 --- a/src/calng/base_geometry.py +++ b/src/calng/base_geometry.py @@ -1,15 +1,19 @@ +import contextlib import pickle import matplotlib.pyplot as plt import numpy as np from karabo.bound import ( + BOOL_ELEMENT, DOUBLE_ELEMENT, IMAGEDATA_ELEMENT, INT32_ELEMENT, KARABO_CLASSINFO, NODE_ELEMENT, OUTPUT_CHANNEL, + OVERWRITE_ELEMENT, SLOT_ELEMENT, + STRING_ELEMENT, TABLE_ELEMENT, VECTOR_CHAR_ELEMENT, VECTOR_STRING_ELEMENT, @@ -73,8 +77,23 @@ ModuleColumn = Schema() @KARABO_CLASSINFO("ManualGeometryBase", deviceVersion) class ManualGeometryBase(PythonDevice): + geometry_class = None # concrete device subclass must set this + @staticmethod def expectedParameters(expected): + # Karabo things + ( + OVERWRITE_ELEMENT(expected) + .key("state") + .setNewDefaultValue(State.INIT) + .commit(), + + OVERWRITE_ELEMENT(expected) + .key("doNotCompressEvents") + .setNewDefaultValue(True) + .commit(), + ) + # "mandatory" for geometry serving device ( OUTPUT_CHANNEL(expected) @@ -82,7 +101,23 @@ class ManualGeometryBase(PythonDevice): .dataSchema(geometry_schema) .commit(), - SLOT_ELEMENT(expected).key("pleaseSendYourGeometry").commit(), + SLOT_ELEMENT(expected) + .key("sendGeometry") + .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.ON) + .commit(), + + SLOT_ELEMENT(expected) + .key("updatePreview") + .displayedName("Update preview") + .allowedStates(State.ON) + .commit(), OUTPUT_CHANNEL(expected) .key("previewOutput") @@ -92,6 +127,53 @@ class ManualGeometryBase(PythonDevice): IMAGEDATA_ELEMENT(expected).key("layoutPreview").commit(), ) + # options to load from file + # future plan: pick option from manual / file / encoder or other devices + ( + NODE_ELEMENT(expected) + .key("geometryFile") + .displayedName("Geometry file") + .description("Allows loading geometry from CrystFEL geometry file") + .commit(), + + STRING_ELEMENT(expected) + .key("geometryFile.filePath") + .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." + ) + .assignmentOptional() + .defaultValue("") + .reconfigurable() + .commit(), + + SLOT_ELEMENT(expected) + .key("geometryFile.load") + .displayedName("Load file") + .description( + "Trigger loading from file. Automatically called after starting device " + "if geometryFile.filePath is set. Should be manually called after " + "changing file path. In case loading the file fails, manual settings " + "will be used." + ) + .allowedStates(State.ON) + .commit(), + + BOOL_ELEMENT(expected) + .key("geometryFile.updateManualOnLoad") + .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." + ) + .assignmentOptional() + .defaultValue(True) + .commit(), + ) + # scenes are fun ( VECTOR_STRING_ELEMENT(expected) @@ -102,21 +184,75 @@ class ManualGeometryBase(PythonDevice): .commit(), ) - def update_geom(self): - raise NotImplementedError() - def __init__(self, config): super().__init__(config) - self.KARABO_SLOT(self.pleaseSendYourGeometry) + # slots go in __init__ + self.KARABO_SLOT(self.sendGeometry) + self.KARABO_SLOT(self.updatePreview) self.KARABO_SLOT(self.requestScene) - self.update_geom() - plt.switch_backend("agg") + self.KARABO_SLOT(self.load_geometry_from_file, slotName="geometryFile_load") + + plt.switch_backend("agg") # plotting backend which works for preview hack + + # these will be set by load_geometry_from_file or get_manual_geometry + self.geometry = None + self.pickled_geometry = None self.registerInitialFunction(self._initialization) def _initialization(self): + if self.get("geometryFile.filePath"): + self.log_status_info("geometryFile.filePath set, will try to load geometry") + self.load_geometry_from_file() + if self.geometry is None: + # no file path or loading failed + self.get_manual_geometry() + self.updateState(State.ON) - self.pleaseSendYourGeometry() + + def _set_geometry(self, geometry, update_preview=True, send=True): + self.geometry = geometry + self.pickled_geometry = pickle.dumps(self.geometry) + if update_preview: + self.updatePreview() + if send: + self.sendGeometry() + + def get_manual_geometry(self): + # subclass must implement this + raise NotImplementedError() + + def _update_manual_from_current(self): + # subclass should implement this + # TODO: figure out for JUNGFRAUGeometry + raise NotImplementedError() + + def load_geometry_from_file(self): + with self.push_state(State.CHANGING): + geometry = None + + file_path = self.get("geometryFile.filePath") + self.log_status_info(f"Loading geometry from {file_path}...") + + try: + geometry = self.geometry_class.from_crystfel_geom(file_path) + except FileNotFoundError: + self.log_status_warn("Geometry file not found") + except RuntimeError as e: + self.log_status_warn(f"Failed to load geometry file: {e}") + except Exception as e: + self.log_status_warn(f"Misc. exception when loading geometry file: {e}") + else: + self._set_geometry(geometry) + self.log_status_info("Successfully loaded geometry from file") + if self.get("geometryFile.updateManualOnLoad"): + self.log_status_info( + "Updating manual settings on device to reflect loaded geometry" + ) + self._update_manual_from_current() + return True + + return False def requestScene(self, params): payload = Hash() @@ -135,10 +271,13 @@ class ManualGeometryBase(PythonDevice): response["payload"] = payload self.reply(response) - def pleaseSendYourGeometry(self): - self.update_geom() - self.writeChannel("geometryOutput", Hash("pickledGeometry", self.pickled)) - axis = self.geom.inspect() + def sendGeometry(self): + self.writeChannel( + "geometryOutput", Hash("pickledGeometry", self.pickled_geometry) + ) + + 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) @@ -152,6 +291,7 @@ class ManualGeometryBase(PythonDevice): "layoutPreview", ImageData(image_buffer, encoding=Encoding.RGBA, bitsPerPixel=3 * 8), ) + self.log_status_info("Preview updated") def preReconfigure(self, config): self._prereconfigure_update_hash = config @@ -159,6 +299,23 @@ class ManualGeometryBase(PythonDevice): def postReconfigure(self): del self._prereconfigure_update_hash + def log_status_info(self, msg): + self.log.INFO(msg) + self.set("status", msg) + + def log_status_warn(self, msg): + self.log.WARN(msg) + self.set("status", msg) + + @contextlib.contextmanager + def push_state(self, state): + previous_state = self.get("state") + self.updateState(state) + try: + yield + finally: + self.updateState(previous_state) + @KARABO_CLASSINFO("ManualQuadrantsGeometryBase", deviceVersion) class ManualQuadrantsGeometryBase(ManualGeometryBase): @@ -189,19 +346,29 @@ class ManualQuadrantsGeometryBase(ManualGeometryBase): path.startswith("quadrantCorners") for path in self._prereconfigure_update_hash.getPaths() ): - self.update_geom() + self.get_manual_geometry() super().postReconfigure() - def update_geom(self): - self.quadrant_corners = tuple( - (self.get(f"quadrantCorners.Q{q}.x"), self.get(f"quadrantCorners.Q{q}.y")) - for q in range(1, 5) - ) - self.geom = self.geometry_class.from_quad_positions(self.quadrant_corners) - self.pickled = pickle.dumps(self.geom) - # TODO: send to anyone who asks? make slot for that? send on connect? - self.writeChannel("geometryOutput", Hash("pickledGeometry", self.pickled)) + def get_manual_geometry(self): + self.log_status_info("Updating geometry from manual configuration") + with self.push_state(State.CHANGING): + self.quadrant_corners = tuple( + ( + self.get(f"quadrantCorners.Q{q}.x"), + self.get(f"quadrantCorners.Q{q}.y"), + ) + for q in range(1, 5) + ) + geometry = self.geometry_class.from_quad_positions(self.quadrant_corners) + self._set_geometry(geometry) + + def _update_manual_from_current(self): + update = Hash() + for (x, y), quadrant in zip(self.geometry.quad_positions(), range(1, 5)): + update.set(f"quadrantCorners.Q{quadrant}.x", x) + update.set(f"quadrantCorners.Q{quadrant}.y", y) + self.set(update) @KARABO_CLASSINFO("ManualModulesGeometryBase", deviceVersion) @@ -216,24 +383,31 @@ class ManualModulesGeometryBase(ManualGeometryBase): .defaultValue([]) .reconfigurable() .commit(), + + OVERWRITE_ELEMENT(expected) + .key("geometryFile.updateManualOnLoad") + .setNewDefaultValue(False) + .commit(), ) def postReconfigure(self): if self._prereconfigure_update_hash.has("modules"): - self.update_geom() + self.get_manual_geometry() super().postReconfigure() - def update_geom(self): - modules = self.get("modules") - module_pos = [(module.get("posX"), module.get("posY")) for module in modules] - orientations = [ - (module.get("orientationX"), module.get("orientationY")) - for module in modules - ] - self.geom = self.geometry_class.from_module_positions( - module_pos, orientations=orientations - ) - self.pickled = pickle.dumps(self.geom) - # TODO: send to anyone who asks? make slot for that? - self.writeChannel("geometryOutput", Hash("pickledGeometry", self.pickled)) + def get_manual_geometry(self): + self.log_status_info("Updating geometry from manual configuration") + with self.push_state(State.CHANGING): + modules = self.get("modules") + module_pos = [ + (module.get("posX"), module.get("posY")) for module in modules + ] + orientations = [ + (module.get("orientationX"), module.get("orientationY")) + for module in modules + ] + geometry = self.geometry_class.from_module_positions( + module_pos, orientations=orientations + ) + self._set_geometry(geometry) diff --git a/src/calng/scenes.py b/src/calng/scenes.py index e2de2737..e830bb7e 100644 --- a/src/calng/scenes.py +++ b/src/calng/scenes.py @@ -810,7 +810,7 @@ def detector_assembler_overview(device_id, geometry_device_id): height=BASE_INC, ), DisplayCommandModel( - keys=[f"{geometry_device_id}.pleaseSendYourGeometry"], + keys=[f"{geometry_device_id}.sendGeometry"], width=14 * BASE_INC, height=BASE_INC, ), -- GitLab