class MDIChildPlot(PlotWidget): LINE_COLORS: List[str] = ['r', 'g', 'b', 'c', 'm', 'y', 'w'] AXES_NAMES: Dict[int, str] = {2: 'bottom', 3: 'left', 4: 'right'} child_number: int = 1 def __init__(self, **kwargs): super(MDIChildPlot, self).__init__(**kwargs) self.setAttribute(Qt.WA_DeleteOnClose) self.is_untitled: bool = True self.is_modified: bool = False self.cur_file: str = '' self.curves = [] self.plotItem2 = ViewBox() self.plotItem.showAxis('right') self.plotItem.scene().addItem(self.plotItem2) self.plotItem.getAxis('right').linkToView(self.plotItem2) self.plotItem2.setXLink(self.plotItem) self.plotItem.showButtons() self.plotItem.showGrid(x=True, y=True) # Handle view resizing def update_views(): # view has resized; update auxiliary views to match self.plotItem2.setGeometry(self.plotItem.vb.sceneBoundingRect()) # need to re-update linked axes since this was called # incorrectly while views had different shapes. # (probably this should be handled in ViewBox.resizeEvent) self.plotItem2.linkedViewChanged(self.plotItem.vb, self.plotItem2.XAxis) update_views() self.plotItem.vb.sigResized.connect(update_views) # add items to the context menu self.plotItem.vb.menu.addSeparator() self.delete_curve_menu: QMenu = QMenu('Delete') self.copy_curve_menu: QMenu = QMenu('Copy') self.paste_curve_menu: QMenu = QMenu('Paste') menus = {self.delete_curve_menu: [('Last Curve…', self.delete_last_curve), ('Curves No.…', self.delete_curves), ('All Curves…', self.delete_all_curves)], self.copy_curve_menu: [('Last Curve…', self.copy_last_curve), ('Curves No.…', self.copy_curves), ('All Curves…', self.copy_all_curves)], } for parent_menu, actions in menus.items(): for title, callback in actions: new_action: QAction = QAction(title, parent_menu) parent_menu.addAction(new_action) new_action.triggered.connect(callback) self.plotItem.vb.menu.addMenu(self.delete_curve_menu) self.plotItem.vb.menu.addMenu(self.copy_curve_menu) self.plotItem.vb.menu.addMenu(self.paste_curve_menu) # hide buggy menu items for undesired_menu_item_index in (5, 2, 1): self.plotItem.subMenus.pop(undesired_menu_item_index) self.plotItem.subMenus[1].actions()[0].defaultWidget().children()[1].hide() def new_file(self): self.is_untitled = True self.cur_file = f'Plot {MDIChildPlot.child_number:d}' MDIChildPlot.child_number += 1 self.setWindowTitle(self.cur_file + '[*]') self.setWindowModified(True) # self.sig.connect(self.document_was_modified) def load_irtecon_file(self, file_name: str): file = QFile(file_name) if not file.open(QFile.ReadOnly | QFile.Text): QMessageBox.warning(self, 'MDI', f'Cannot read file {file_name}:\n{file.errorString()}.') return False QApplication.setOverrideCursor(Qt.WaitCursor) in_str = QTextStream(file).readAll() file_data = IRTECONFile(in_str) self.plotItem.setTitle(file_data.sample_name) self.plotItem.addLegend() for index, curve in enumerate(file_data.curves): self.curves.append(self.plotItem.plot(curve.data[..., :2], name=curve.legend_key, pen=mkPen(self.LINE_COLORS[index % len(self.LINE_COLORS)]))) for ax in self.AXES_NAMES.values(): self.plotItem.hideAxis(ax) for ax in file_data.axes: if ax.axis in self.AXES_NAMES: self.plotItem.showAxis(self.AXES_NAMES[ax.axis]) self.plotItem.setLabel(self.AXES_NAMES[ax.axis], ax.name, ax.unit) QApplication.restoreOverrideCursor() self.set_current_file(file_name) # self.document().contentsChanged.connect(self.document_was_modified) return True def delete_last_curve(self): if not self.curves: return ret = QMessageBox.warning(self, 'MDI', 'Do you want to delete the last curve?', QMessageBox.Yes | QMessageBox.No) if ret == QMessageBox.Yes: self.plotItem.legend.removeItem(self.curves[-1]) self.curves[-1].clear() del self.curves[-1] self.is_modified = True self.setWindowTitle(self.user_friendly_current_file() + '[*]') self.setWindowModified(True) def delete_curves(self): def parse_range() -> List[int]: # https://stackoverflow.com/a/4248689/8554611 result = set() for part in ranges.split(','): x = part.split('-') result.update(list(range(int(x[0]), int(x[-1]) + 1))) return sorted(result) if not self.curves: return ranges, ok = QInputDialog.getText(self, 'Delete Curves', 'Curves No.:') if ok: for index in reversed(parse_range()): index -= 1 if index in range(len(self.curves)): self.plotItem.legend.removeItem(self.curves[index]) self.curves[index].clear() del self.curves[index] self.is_modified = True self.setWindowTitle(self.user_friendly_current_file() + '[*]') self.setWindowModified(True) def delete_all_curves(self): if not self.curves: return ret = QMessageBox.question(self, 'MDI', 'Do you want to delete all the curves?', QMessageBox.Yes | QMessageBox.No) if ret == QMessageBox.Yes: while self.curves: self.plotItem.legend.removeItem(self.curves[-1]) self.curves[-1].clear() del self.curves[-1] self.is_modified = True self.setWindowTitle(self.user_friendly_current_file() + '[*]') self.setWindowModified(True) def copy_last_curve(self): if not self.curves: return raise NotImplementedError def copy_curves(self, ranges: str): def parse_range() -> List[int]: # https://stackoverflow.com/a/4248689/8554611 result = set() for part in ranges.split(','): x = part.split('-') result.update(list(range(int(x[0]), int(x[-1]) + 1))) return sorted(result) if not self.curves: return parse_range() raise NotImplementedError def copy_all_curves(self): if not self.curves: return raise NotImplementedError def save(self): if self.is_untitled: return self.save_as() else: return self.save_file(self.cur_file) def save_as(self): file_name, _ = QFileDialog.getSaveFileName(self, 'Save As', self.cur_file) if not file_name: return False return self.save_file(file_name) def save_file(self, file_name: str): file = QFile(file_name) if not file.open(QFile.WriteOnly | QFile.Text): QMessageBox.warning(self, 'MDI', f'Cannot write file {file_name}:\n{file.errorString()}.') return False out_str = QTextStream(file) QApplication.setOverrideCursor(Qt.WaitCursor) out_str << self.toPlainText() QApplication.restoreOverrideCursor() self.set_current_file(file_name) return True def user_friendly_current_file(self): def stripped_name(full_file_name): return QFileInfo(full_file_name).fileName() return stripped_name(self.cur_file) def current_file(self): return self.cur_file def close_event(self, event): if self.maybe_save(): event.accept() else: event.ignore() def maybe_save(self): if self.document().isModified(): ret = QMessageBox.warning(self, 'MDI', f'"{self.user_friendly_current_file()}" has been modified.\n' 'Do you want to save your changes?', QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel) if ret == QMessageBox.Save: return self.save() elif ret == QMessageBox.Cancel: return False return True def set_current_file(self, file_name): self.cur_file = QFileInfo(file_name).canonicalFilePath() self.is_untitled = False self.is_modified = False self.setWindowModified(False) self.setWindowTitle(self.user_friendly_current_file())
class SiriusTimePlot(PyDMTimePlot): """PyDMTimePlot with some extra features.""" bufferReset = Signal() timeSpanChanged = Signal() def __init__(self, *args, show_tooltip=False, **kws): super().__init__(*args, **kws) self._filled_with_arch_data = dict() self._show_tooltip = show_tooltip self.vb2 = ViewBox() self.plotItem.scene().addItem(self.vb2) self.vb2.setXLink(self.plotItem) self.plotItem.getAxis('right').linkToView(self.vb2) self._updateViews() self.plotItem.vb.sigResized.connect(self._updateViews) self.carch = None # show auto adjust button self.plotItem.showButtons() # use pan mouse mode (3-button) self.plotItem.getViewBox().setMouseMode(ViewBox.PanMode) # connect sigMouseMoved self.plotItem.scene().sigMouseMoved.connect(self._handle_mouse_moved) # add new actions to menu rst_act = QAction("Clear buffers") rst_act.triggered.connect(self._resetBuffers) tsp_act = QAction("Change time span") tsp_act.triggered.connect(self._changeTimeSpan) self.plotItem.scene().contextMenu.extend([rst_act, tsp_act]) @Property(bool) def showToolTip(self): """ Whether to show or not tooltip with curve values. Returns ------- use : bool Tooltip enable status in use """ return self._show_tooltip @showToolTip.setter def showToolTip(self, new_show): """ Whether to show or not tooltip with curve values. Parameters ---------- new_show : bool The new tooltip enable status to use """ self._show_tooltip = new_show def addCurve(self, plot_item, axis='left', curve_color=None): """Reimplement to use right axis.""" if curve_color is None: curve_color = utilities.colors.default_colors[len( self._curves) % len(utilities.colors.default_colors)] plot_item.color_string = curve_color self._curves.append(plot_item) if axis == 'left': self.plotItem.addItem(plot_item) elif axis == 'right': if not self.plotItem.getAxis('right').isVisible(): self.plotItem.showAxis('right') self.vb2.addItem(plot_item) else: raise ValueError('Choose a valid axis!') # Connect channels for chan in plot_item.channels(): if chan: chan.connect() def addYChannel(self, y_channel=None, name=None, color=None, lineStyle=None, lineWidth=None, symbol=None, symbolSize=None, axis='left'): """Reimplement to use SiriusTimePlotItem and right axis.""" plot_opts = dict() plot_opts['symbol'] = symbol if symbolSize is not None: plot_opts['symbolSize'] = symbolSize if lineStyle is not None: plot_opts['lineStyle'] = lineStyle if lineWidth is not None: plot_opts['lineWidth'] = lineWidth # Add curve new_curve = SiriusTimePlotItem( self, y_channel, plot_by_timestamps=self._plot_by_timestamps, name=name, color=color, **plot_opts) new_curve.setUpdatesAsynchronously(self.updatesAsynchronously) new_curve.setBufferSize(self._bufferSize, initialize_buffer=True) self.update_timer.timeout.connect(new_curve.asyncUpdate) self.addCurve(new_curve, axis, curve_color=color) new_curve.data_changed.connect(self.set_needs_redraw) self.redraw_timer.start() return new_curve def updateXAxis(self, update_immediately=False): """Reimplement to show only existing range.""" if len(self._curves) == 0: return if self._plot_by_timestamps: if self._update_mode == PyDMTimePlot.SynchronousMode: maxrange = max([curve.max_x() for curve in self._curves]) else: maxrange = time.time() mini = Time.now().timestamp() for curve in self._curves: firstvalid = (curve.data_buffer[0] != 0).argmax() if curve.data_buffer[0, firstvalid] == 0: continue mini = min(mini, curve.data_buffer[0, firstvalid]) minrange = max(maxrange - self._time_span, mini) self.plotItem.setXRange(minrange, maxrange, padding=0.0, update=update_immediately) else: diff_time = self.starting_epoch_time - \ max([curve.max_x() for curve in self._curves]) if diff_time > DEFAULT_X_MIN: diff_time = DEFAULT_X_MIN self.getViewBox().setLimits(minXRange=diff_time) def _updateViews(self): self.vb2.setGeometry(self.plotItem.vb.sceneBoundingRect()) self.vb2.linkedViewChanged(self.plotItem.vb, self.vb2.XAxis) def _get_value_from_arch(self, pvname, t_init, t_end, process_type, process_bin_intvl): """Get values from archiver.""" if self.carch is None: self.carch = ClientArchiver() self.carch.timeout = 120 data = self.carch.getData(pvname, t_init, t_end, process_type, process_bin_intvl) if not data: return return data['timestamp'], data['value'] def fill_curve_with_archdata(self, curve, pvname, t_init, t_end, factor=None, process_type='', process_bin_intvl=None): """Fill curve with archiver data.""" data = self._get_value_from_arch(pvname, t_init, t_end, process_type, process_bin_intvl) if not data: return datax, datay = data self.fill_curve_buffer(curve, datax, datay, factor) self._filled_with_arch_data[pvname] = dict( curve=curve, factor=factor, process_type=process_type, process_bin_intvl=process_bin_intvl) def fill_curve_buffer(self, curve, datax, datay, factor=None): """Fill curve buffer.""" nrpts = len(datax) if not nrpts: return buff = _np.zeros((2, self.bufferSize), order='f', dtype=float) if nrpts > self.bufferSize: smpls2discard = nrpts - self.bufferSize datax = datax[smpls2discard:] datay = datay[smpls2discard:] nrpts = len(datax) firstsmpl2fill = self.bufferSize - nrpts buff[0, firstsmpl2fill:] = datax buff[1, firstsmpl2fill:] = datay if factor: buff[1, :] /= factor curve.data_buffer = buff curve.points_accumulated = nrpts curve._min_y_value = min(datay) curve._max_y_value = max(datay) curve.latest_value = datay[-1] def _resetBuffers(self): for curve in self._curves: curve.initialize_buffer() self.bufferReset.emit() def _changeTimeSpan(self): new_time_span, ok = QInputDialog.getInt( self, 'Input', 'Set new time span value [s]: ') if not ok: return if new_time_span > self.timeSpan: t_end = Time.now() t_init = t_end - new_time_span for pvname, info in self._filled_with_arch_data.items(): self.fill_curve_with_archdata(info['curve'], pvname, t_init.get_iso8601(), t_end.get_iso8601(), info['factor'], info['process_type'], info['process_bin_intvl']) self.timeSpan = new_time_span self.timeSpanChanged.emit() def _handle_mouse_moved(self, pos): """Show tooltip at mouse move.""" if not self._show_tooltip: return # create label tooltip, if needed if not hasattr(self, 'label_tooltip'): self.label_tooltip = QLabel(self, Qt.ToolTip) self.timer_tooltip = QTimer(self) self.timer_tooltip.timeout.connect(self.label_tooltip.hide) self.timer_tooltip.setInterval(1000) # find nearest curve point nearest = (self._curves[0], _np.inf, None, None) for idx, curve in enumerate(self._curves): if not curve.isVisible(): continue mappos = curve.mapFromScene(pos) posx, posy = mappos.x(), mappos.y() xData, yData = curve.curve.xData, curve.curve.yData if not xData.size: continue diffx = xData - posx idx = _np.argmin(_np.abs(diffx)) if diffx[idx] < 0.5: valx, valy = xData[idx], yData[idx] diffy = abs(valy - posy) if diffy < nearest[1]: nearest = (curve, diffy, valx, valy) # show tooltip curve, diffy, valx, valy = nearest ylimts = self.getViewBox().state['viewRange'][1] ydelta = ylimts[1] - ylimts[0] if diffy < 1e-2 * ydelta: txt = Time(timestamp=valx).get_iso8601() + '\n' txt += f'{curve.name()}: {valy:.3f}' font = QApplication.instance().font() font.setPointSize(font.pointSize() - 10) palette = QPalette() palette.setColor(QPalette.WindowText, curve.color) self.label_tooltip.setText(txt) self.label_tooltip.setFont(font) self.label_tooltip.setPalette(palette) self.label_tooltip.move(self.mapToGlobal(pos.toPoint())) self.label_tooltip.show() self.timer_tooltip.start() curve.scatter.setData(pos=[ (valx, valy), ], symbol='o', size=15, brush=mkBrush(curve.color)) curve.scatter.show()