diff --git a/src/calng/CalibrationManager.py b/src/calng/CalibrationManager.py index a8367b125a3381efe53c432a075311781eeb02f0..4213660758b6690a9ba4c8c46a9653c3c4040427 100644 --- a/src/calng/CalibrationManager.py +++ b/src/calng/CalibrationManager.py @@ -377,8 +377,8 @@ class CalibrationManager(DeviceClientBase, Device): geometryDevice = String( displayedName='Geometry device', - description='[NYI] Device ID for a geometry device defining the ' - 'detector layout and module positions.', + description='Device ID for a geometry device defining the detector ' + 'layout and module positions.', accessMode=AccessMode.INITONLY, assignment=Assignment.MANDATORY) @@ -1281,7 +1281,6 @@ class CalibrationManager(DeviceClientBase, Device): background(_activate_matcher(matcher_device_id)) # Instantiate preview layer assemblers. - geometry_device_id = self.geometryDevice.value for layer, output_pipeline, server in self.previewLayers.value: assembler_device_id = device_id_templates['assembler'].format( layer=layer) @@ -1295,8 +1294,7 @@ class CalibrationManager(DeviceClientBase, Device): f'@{device_id}:{output_pipeline}') for (virtual_id, device_id) in correct_device_id_by_module.items()] - config['geometryInput.connectedOutputChannels'] = [ - f'{geometry_device_id}:geometryOutput'] + config['geometryDevice'] = self.geometryDevice.value if not await self._instantiate_device( server, class_ids['assembler'], assembler_device_id, config diff --git a/src/calng/DetectorAssembler.py b/src/calng/DetectorAssembler.py index 7da3cc2efa5ba9174d71df31160e04032cab7b52..94418b1a621e101cff0925e27727835c8938d5cb 100644 --- a/src/calng/DetectorAssembler.py +++ b/src/calng/DetectorAssembler.py @@ -1,15 +1,13 @@ import enum import functools +import gzip import pickle import re -import threading -import time import numpy as np from karabo.bound import ( FLOAT_ELEMENT, IMAGEDATA_ELEMENT, - INPUT_CHANNEL, NDARRAY_ELEMENT, NODE_ELEMENT, KARABO_CLASSINFO, @@ -161,9 +159,15 @@ class DetectorAssembler(TrainMatcher.TrainMatcher): .reconfigurable() .commit(), - INPUT_CHANNEL(expected) - .key("geometryInput") - .displayedName("Geometry input") + STRING_ELEMENT(expected) + .key("geometryDevice") + .displayedName("Geometry device") + .description( + "The name of the device which will provide geometries. The device is " + "expected to provide a current geometry as a VectorChar (gzipped " + "pickled extra-geom geometry) element named pickledGeometry" + ) + .assignmentMandatory() .commit(), ) @@ -180,10 +184,25 @@ class DetectorAssembler(TrainMatcher.TrainMatcher): self._geometry = None self._stack_input_buffer = None - self.KARABO_ON_DATA("geometryInput", self.receive_geometry) self.KARABO_SLOT(self.requestScene) - self.ask_for_geometry() + geometry_device = self.get("geometryDevice") + client = self.remote() + try: + initial_geometry = client.get(geometry_device, "pickledGeometry") + except RuntimeError: + self.log.WARN( + f"Failed to get initial geometry, maybe geometry device is down" + ) + else: + self.log.INFO("Got geometry immediately after init :D") + self._receive_geometry( + geometry_device, + Hash("pickledGeometry", initial_geometry), + ) + + self.remote().registerDeviceMonitor(geometry_device, self._receive_geometry) + self.assembled_output = self.signalSlotable.getOutputChannel("assembledOutput") self.preview_output = self.signalSlotable.getOutputChannel("preview.output") self.start() @@ -194,10 +213,7 @@ class DetectorAssembler(TrainMatcher.TrainMatcher): if scene_name == "overview": payload = Hash("name", scene_name, "success", True) payload["data"] = scenes.detector_assembler_overview( - device_id=self.getInstanceId(), - geometry_device_id=self.get("geometryInput.connectedOutputChannels")[ - 0 - ].split(":")[0], + device_id=self.getInstanceId() ) self.reply( Hash( @@ -213,44 +229,18 @@ class DetectorAssembler(TrainMatcher.TrainMatcher): params["name"] = "scene" return super().requestScene(params) - def receive_geometry(self, data, metadata): - self.log.INFO("Received a new geometry") - self._geometry = pickle.loads(data.get("pickledGeometry")) + def _receive_geometry(self, device_id, config): + if not config.has("pickledGeometry"): + return + self.log.INFO(f"Found geometry on {device_id}") + zipped_geometry = config["pickledGeometry"] + if len(zipped_geometry) == 0: + self.log.INFO("New geometry empty, will ignore update.") + return + self._geometry = pickle.loads(gzip.decompress(zipped_geometry)) # TODO: allow multiple memory cells (extra geom notion of extra dimensions) self._stack_input_buffer = np.zeros(self._geometry.expected_data_shape) - def ask_for_geometry(self): - def runner(): - self.log.INFO("Will ask around for a geometry") - max_tries = 10 - for i in range(max_tries): - time.sleep(np.random.random() * 10) - if self._geometry is None: - geometry_device_list = list( - self.get("geometryInput.connectedOutputChannels") - ) - # first check if geometry device not even connected - missing_connections = set( - self.get("geometryInput.missingConnections") - ) - geometry_device_list = [ - channel - for channel in geometry_device_list - if channel not in missing_connections - ] - if not geometry_device_list: - self.log.INFO("No geometry device connected") - continue - geometry_device = geometry_device_list[0].split(":")[0] - self.log.INFO(f"Asking {geometry_device} for a geometry") - self.signalSlotable.call(geometry_device, "sendGeometry") - time.sleep(1) - - if self._geometry is not None: - return - self.log.INFO(f"Failed to get geometry in {max_tries} tries, need help") - threading.Thread(target=runner, daemon=True).start() - def on_matched_data(self, train_id, sources): if self._geometry is None: self.log.WARN("Have not received a geometry yet, will not send anything") @@ -344,8 +334,8 @@ class DetectorAssembler(TrainMatcher.TrainMatcher): self.info["timeOfFlight"] = ( Timestamp().toTimestamp() - earliest_source_timestamp ) * 1000 - self.info['sent'] += 1 - self.info['trainId'] = train_id + self.info["sent"] += 1 + self.info["trainId"] = train_id self.rate_out.update() def on_new_data(self, channel, data, meta): @@ -382,19 +372,22 @@ def downsample_2d(arr, factor, reduction_fun=np.nanmax): ( arr[:-1:2], arr[1::2], - ), axis=0 + ), + axis=0, ) arr = reduction_fun( ( arr[:, :-1:2], arr[:, 1::2], - ), axis=0 + ), + axis=0, ) return arr # forward-compatible unsafe_get proposed by @haufs if not hasattr(DetectorAssembler, "unsafe_get"): + def unsafe_get(self, key): """See base_correction.py""" return self._parameters.get(key) diff --git a/src/calng/base_geometry.py b/src/calng/base_geometry.py index 6ae36b7952f9342d76c37e7669330d78f4c42e18..f566e95e0a173c83631c414210e691d1f9b512b9 100644 --- a/src/calng/base_geometry.py +++ b/src/calng/base_geometry.py @@ -1,4 +1,5 @@ import contextlib +import gzip import logging import pickle @@ -19,13 +20,14 @@ from karabo.middlelayer import ( ImageData, Int32, Node, - OutputChannel, Slot, State, String, Unit, VectorChar, + VectorHash, VectorString, + get_instance_parent, slot, ) from matplotlib.backends.backend_agg import FigureCanvasAgg @@ -34,15 +36,7 @@ from ._version import version as deviceVersion from . import scenes -class GeometrySchema(Configurable): - pickledGeometry = VectorChar( - displayedName="Pickled geometry", - assignment=Assignment.OPTIONAL, - defaultValue=[], - ) - - -def makeXYCoordinateNode( +def make_x_y_coordinate_node( default_x, default_y, x_args=None, y_args=None, node_args=None ): class XYCoordinate(Configurable): @@ -62,8 +56,8 @@ def makeXYCoordinateNode( return Node(XYCoordinate, **({} if node_args is None else node_args)) -def makeXYOffsetNode(): - return makeXYCoordinateNode( +def make_x_y_offset_node(): + return make_x_y_coordinate_node( 0, 0, x_args={"unitSymbol": Unit.METER}, @@ -78,6 +72,87 @@ def makeXYOffsetNode(): ) +def get_my_device(me): + parent = me + while not isinstance(parent, Device): + new_parent = get_instance_parent(parent) + assert new_parent is not parent, "Circular parent reference" + parent = new_parent + return parent + + +# TODO: consider other history models (could be fun) +class TweakGeometryNode(Configurable): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._undo_stack = [] + self._redo_stack = [] + + def _reset(self): + # clear history when new geometry is set from manual / file + self._undo_stack.clear() + self._redo_stack.clear() + self.undoLength = 0 + self.redoLength = 0 + + @Slot(displayedName="Undo") + async def undo(self): + assert len(self._undo_stack) > 0 + parent = get_my_device(self) + self._redo_stack.append(parent.geometry) + await parent._set_geometry(self._undo_stack.pop()) + self.undoLength = len(self._undo_stack) + self.redoLength = len(self._redo_stack) + + @Slot(displayedName="Redo") + async def redo(self): + parent = get_my_device(self) + assert len(self._redo_stack) > 0 + self._undo_stack.append(parent.geometry) + await parent._set_geometry(self._redo_stack.pop()) + self.undoLength = len(self._undo_stack) + self.redoLength = len(self._redo_stack) + + offset = make_x_y_offset_node() + + @Slot(displayedName="Add offset") + async def add(self): + parent = get_my_device(self) + current_geometry = parent.geometry + new_geometry = current_geometry.offset( + (self.offset.x.value, self.offset.y.value) + ) + self._undo_stack.append(current_geometry) + self.undoLength = len(self._undo_stack) + await parent._set_geometry(new_geometry) + if self._redo_stack: + self._redo_stack.clear() + self.redoLength = 0 + + undoLength = Int32( + displayedName="Undo length", + defaultValue=0, + accessMode=AccessMode.READONLY, + ) + + redoLength = Int32( + displayedName="Redo length", + defaultValue=0, + accessMode=AccessMode.READONLY, + ) + + +class BaseManualGeometryConfigNode(Configurable): + offset = make_x_y_offset_node() + + @Slot( + displayedName="Set manual geometry", + allowedStates=[State.ACTIVE], + ) + async def setManual(self): + await get_my_device(self)._set_from_manual_config() + + class GeometryFileNode(Configurable): filePath = String( defaultValue="", @@ -88,7 +163,7 @@ class GeometryFileNode(Configurable): assignment=Assignment.OPTIONAL, accessMode=AccessMode.RECONFIGURABLE, ) - offset = makeXYOffsetNode() + offset = make_x_y_offset_node() updateManualOnLoad = Bool( defaultValue=True, displayedName="Update manual settings", @@ -100,11 +175,18 @@ class GeometryFileNode(Configurable): accessMode=AccessMode.RECONFIGURABLE, ) + @Slot( + displayedName="Load from file", + allowedStates=[State.ACTIVE], + ) + async def loadFromFile(self): + await get_my_device(self)._load_from_file() + class ManualGeometryBase(Device): __version__ = deviceVersion geometry_class = None # subclass must set - # subclass must add slot setManual + # subclass must add slot manualConfigsetManual availableScenes = VectorString( displayedName="Available scenes", @@ -117,31 +199,28 @@ class ManualGeometryBase(Device): daqPolicy=DaqPolicy.OMIT, ) + pickledGeometry = VectorChar( + displayedName="Pickled geometry", + defaultValue=[], + accessMode=AccessMode.READONLY, + ) + 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], + tweakGeometry = Node( + TweakGeometryNode, + displayedName="Tweak geometry", ) - async def sendGeometry(self): - self.geometryOutput.schema.pickledGeometry = self.pickled_geometry - await self.geometryOutput.writeData() @Slot( displayedName="Update preview", @@ -163,11 +242,7 @@ class ManualGeometryBase(Device): ) self._set_status("Preview updated") - @Slot( - displayedName="Load from file", - allowedStates=[State.ACTIVE], - ) - async def loadFromFile(self): + async def _load_from_file(self): with self.push_state(State.CHANGING): geometry = None @@ -189,7 +264,7 @@ class ManualGeometryBase(Device): ) else: geometry = geometry.offset( - (self.geometryFile.offset.x, self.geometryFile.offset.y) + (self.geometryFile.offset.x.value, self.geometryFile.offset.y.value) ) await self._set_geometry(geometry) self._set_status("Successfully loaded geometry from file") @@ -198,6 +273,7 @@ class ManualGeometryBase(Device): "Updating manual settings on device to reflect loaded geometry" ) self._update_manual_from_current() + self.tweakGeometry._clear() return True return False @@ -206,13 +282,11 @@ class ManualGeometryBase(Device): # subclass should implement (neat when loading from CrystFEL geom) raise NotImplementedError() - async def _set_geometry(self, geometry, update_preview=True, send=True): + async def _set_geometry(self, geometry, update_preview=True): self.geometry = geometry - self.pickled_geometry = pickle.dumps(self.geometry) + self.pickledGeometry = gzip.compress(pickle.dumps(self.geometry)) if update_preview: await self.updatePreview() - if send: - await self.sendGeometry() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -220,8 +294,10 @@ class ManualGeometryBase(Device): async def onInitialization(self): self.state = State.INIT - # TODO: try to load file if set - await self.setManual() + if self.geometryFile.filePath.value and await self._load_from_file(): + ... + else: + await self._set_from_manual_config() self.state = State.ACTIVE @contextlib.contextmanager @@ -243,16 +319,15 @@ class ManualGeometryBase(Device): self.logger.log(level, text) -def makeQuadrantCornersNode(default_values): +def make_quadrant_corners_node(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]) - offset = makeXYOffsetNode() + class QuadrantCornersNode(BaseManualGeometryConfigNode): + Q1 = make_x_y_coordinate_node(*default_values[0]) + Q2 = make_x_y_coordinate_node(*default_values[1]) + Q3 = make_x_y_coordinate_node(*default_values[2]) + Q4 = make_x_y_coordinate_node(*default_values[3]) return Node(QuadrantCornersNode) @@ -281,11 +356,7 @@ class ManualQuadrantsGeometryBase(ManualGeometryBase): return Hash("type", "deviceScene", "origin", self.deviceId, "payload", payload) - @Slot( - displayedName="Set from device", - allowedStates=[State.ACTIVE], - ) - async def setManual(self): + async def _set_from_manual_config(self): self._set_status("Updating geometry from manual configuration") with self.push_state(State.CHANGING): geometry = self.geometry_class.from_quad_positions( @@ -297,6 +368,7 @@ class ManualQuadrantsGeometryBase(ManualGeometryBase): ) ) await self._set_geometry(geometry) + self.tweakGeometry._clear() def _update_manual_from_current(self): # TODO: consider what to do about offset @@ -322,9 +394,21 @@ class ModuleListItem(Configurable): orientY = Int32(assignment=Assignment.OPTIONAL, defaultValue=1) +def make_manual_module_list_node(defaults): + class ManualModuleListNode(BaseManualGeometryConfigNode): + modules = VectorHash( + displayedName="Modules", + rows=ModuleListItem, + defaultValue=defaults, + accessMode=AccessMode.RECONFIGURABLE, + assignment=Assignment.OPTIONAL, + ) + + return Node(ManualModuleListNode) + + class ManualModuleListGeometryBase(ManualGeometryBase): moduleList = None # subclass must define (with nice defaults) - offset = makeXYOffsetNode() @slot def requestScene(self, params): @@ -342,14 +426,15 @@ class ManualModuleListGeometryBase(ManualGeometryBase): displayedName="Set from device", allowedStates=[State.ACTIVE], ) - async def setManual(self): + async def _set_from_manual_config(self): self._set_status("Updating geometry from manual configuration") with self.push_state(State.CHANGING): geometry = self.geometry_class.from_module_positions( - [(x, y) for (x, y, _, _) in self.moduleList.value], + [(x, y) for (x, y, _, _) in self.moduleList.modules.value], [ (orient_x, orient_y) - for (_, _, orient_x, orient_y) in self.moduleList.value + for (_, _, orient_x, orient_y) in self.moduleList.modules.value ], - ).offset((self.offset.x.value, self.offset.y.value)) + ).offset((self.moduleList.offset.x.value, self.moduleList.offset.y.value)) await self._set_geometry(geometry) + self.tweakGeometry._clear() diff --git a/src/calng/scenes.py b/src/calng/scenes.py index 5db57128e160cedf8df3a141cac0c47324573980..f528427206368eec128304b7fab43a5f319caa10 100644 --- a/src/calng/scenes.py +++ b/src/calng/scenes.py @@ -630,7 +630,7 @@ class ManualQuadrantGeometrySettings(VerticalLayout): ) self.children.append( DisplayCommandModel( - keys=[f"{device_id}.setManual"], + keys=[f"{device_id}.quadrantCorners.setManual"], width=6 * BASE_INC, height=BASE_INC, ), @@ -644,7 +644,7 @@ class ManualModulesGeometrySettings(VerticalLayout): super().__init__(padding=0) self.children.append( TableElementModel( - keys=[f"{device_id}.moduleList"], + keys=[f"{device_id}.moduleList.modules"], klass="EditableTableElement", width=14 * BASE_INC, height=10 * BASE_INC, @@ -654,12 +654,12 @@ class ManualModulesGeometrySettings(VerticalLayout): HorizontalLayout( LabelModel(text="Offset", width=3 * BASE_INC, height=BASE_INC), DoubleLineEditModel( - keys=[f"{device_id}.offset.x"], + keys=[f"{device_id}.moduleList.offset.x"], width=4 * BASE_INC, height=BASE_INC, ), DoubleLineEditModel( - keys=[f"{device_id}.offset.y"], + keys=[f"{device_id}.moduleList.offset.y"], width=4 * BASE_INC, height=BASE_INC, ), @@ -667,7 +667,7 @@ class ManualModulesGeometrySettings(VerticalLayout): ) self.children.append( DisplayCommandModel( - keys=[f"{device_id}.setManual"], + keys=[f"{device_id}.moduleList.setManual"], width=6 * BASE_INC, height=BASE_INC, ), @@ -692,28 +692,54 @@ class TweakCurrentGeometry(VerticalLayout): width=4 * BASE_INC, height=BASE_INC, ), - ) + ), + ) + self.children.append( + DisplayCommandModel( + keys=[f"{device_id}.tweakGeometry.add"], + width=6 * BASE_INC, + height=BASE_INC, + ), ) - - -@titled("Geometry preview") -@boxed -class GeometryPreview(VerticalLayout): - def __init__(self, device_id): - super().__init__(padding=0) self.children.append( HorizontalLayout( + DisplayLabelModel( + keys=[f"{device_id}.tweakGeometry.undoLength"], + width=2 * BASE_INC, + height=BASE_INC, + font_size=9, + ), DisplayCommandModel( - keys=[f"{device_id}.updatePreview"], - width=6 * BASE_INC, + keys=[f"{device_id}.tweakGeometry.undo"], + width=4 * BASE_INC, height=BASE_INC, ), + DisplayLabelModel( + keys=[f"{device_id}.tweakGeometry.redoLength"], + width=2 * BASE_INC, + height=BASE_INC, + font_size=9, + ), DisplayCommandModel( - keys=[f"{device_id}.sendGeometry"], - width=6 * BASE_INC, + keys=[f"{device_id}.tweakGeometry.redo"], + width=4 * BASE_INC, height=BASE_INC, ), - ) + ), + ) + + +@titled("Geometry preview") +@boxed +class GeometryPreview(VerticalLayout): + def __init__(self, device_id): + super().__init__(padding=0) + self.children.append( + DisplayCommandModel( + keys=[f"{device_id}.updatePreview"], + width=6 * BASE_INC, + height=BASE_INC, + ), ) self.children.append( WebCamGraphModel( @@ -776,7 +802,7 @@ class GeometryFromFileSettings(VerticalLayout): ) self.children.append( DisplayCommandModel( - keys=[f"{device_id}.loadFromFile"], + keys=[f"{device_id}.geometryFile.loadFromFile"], width=6 * BASE_INC, height=BASE_INC, ), @@ -989,26 +1015,16 @@ def correction_constant_dashboard( @scene_generator -def detector_assembler_overview(device_id, geometry_device_id): +def detector_assembler_overview(device_id): return VerticalLayout( HorizontalLayout( AssemblerDeviceStatus(device_id), titled("My geometry device")(boxed(VerticalLayout))( - DeviceSceneLinkModel( - text=f"Geometry device: {geometry_device_id}", - keys=[f"{geometry_device_id}.availableScenes"], - target="overview", - target_window=SceneTargetWindow.Dialog, - frame_width=1, - width=14 * BASE_INC, - height=BASE_INC, - ), - DisplayCommandModel( - keys=[f"{geometry_device_id}.sendGeometry"], - width=14 * BASE_INC, + DisplayLabelModel( + keys=[f"{device_id}.geometryDevice"], + width=6 * BASE_INC, height=BASE_INC, - ), - padding=0, + ) ), ), titled("Preview image")(boxed(dummy_wrap(DetectorGraphModel)))( @@ -1028,6 +1044,7 @@ def quadrant_geometry_overview(device_id): HorizontalLayout( ManualQuadrantGeometrySettings(device_id), GeometryFromFileSettings(device_id), + TweakCurrentGeometry(device_id), ), GeometryPreview(device_id), ) @@ -1039,6 +1056,7 @@ def modules_geometry_overview(device_id): HorizontalLayout( ManualModulesGeometrySettings(device_id), GeometryFromFileSettings(device_id), + TweakCurrentGeometry(device_id), ), GeometryPreview(device_id), )