class NumberDisplayModel(NodeDataModel): name = "NumberDisplay" data_type = DecimalData.data_type caption_visible = False num_ports = { PortType.input: 1, PortType.output: 0, } port_caption = {'input': {0: 'Number'}} def __init__(self, style=None, parent=None): super().__init__(style=style, parent=parent) self._number = None self._label = QLabel() self._label.setMargin(3) self._validation_state = NodeValidationState.warning self._validation_message = 'Uninitialized' def set_in_data(self, data: NodeData, port: Port): ''' New data propagated to the input Parameters ---------- data : NodeData int : int ''' self._number = data number_ok = (self._number is not None and self._number.data_type.id in ('decimal', 'integer')) if number_ok: self._validation_state = NodeValidationState.valid self._validation_message = '' self._label.setText(self._number.number_as_text()) else: self._validation_state = NodeValidationState.warning self._validation_message = "Missing or incorrect inputs" self._label.clear() self._label.adjustSize() def embedded_widget(self) -> QWidget: 'The number display has a label' return self._label
class MainWindow(QMainWindow): """MNELAB main window.""" def __init__(self, model): """Initialize MNELAB main window. Parameters ---------- model : mnelab.model.Model instance The main window needs to connect to a model containing all data sets. This decouples the GUI from the data (model/view). """ super().__init__() self.model = model # data model self.setWindowTitle("MNELAB") # restore settings settings = read_settings() self.recent = settings["recent"] # list of recent files if settings["geometry"]: self.restoreGeometry(settings["geometry"]) else: self.setGeometry(300, 300, 1000, 750) # default window size self.move(QApplication.screens()[0].geometry().center() - self.rect().center()) if settings["state"]: self.restoreState(settings["state"]) self.actions = {} # contains all actions # initialize menus file_menu = self.menuBar().addMenu("&File") icon = QIcon(image_path("open_file.svg")) self.actions["open_file"] = file_menu.addAction( icon, "&Open...", self.open_data, QKeySequence.Open) self.recent_menu = file_menu.addMenu("Open recent") self.recent_menu.aboutToShow.connect(self._update_recent_menu) self.recent_menu.triggered.connect(self._load_recent) if not self.recent: self.recent_menu.setEnabled(False) self.actions["close_file"] = file_menu.addAction( "&Close", self.model.remove_data, QKeySequence.Close) self.actions["close_all"] = file_menu.addAction( "Close all", self.close_all) file_menu.addSeparator() icon = QIcon(image_path("meta_info.svg")) self.actions["meta_info"] = file_menu.addAction(icon, "Show information...", self.meta_info) file_menu.addSeparator() self.actions["import_bads"] = file_menu.addAction( "Import bad channels...", lambda: self.import_file(model.import_bads, "Import bad channels", "*.csv")) self.actions["import_events"] = file_menu.addAction( "Import events...", lambda: self.import_file(model.import_events, "Import events", "*.csv")) self.actions["import_annotations"] = file_menu.addAction( "Import annotations...", lambda: self.import_file(model.import_annotations, "Import annotations", "*.csv")) self.actions["import_ica"] = file_menu.addAction( "Import &ICA...", lambda: self.open_file(model.import_ica, "Import ICA", "*.fif *.fif.gz")) file_menu.addSeparator() self.export_menu = file_menu.addMenu("Export data") for name, ext in EXPORT_FORMATS.items(): self.actions["export_data_" + ext] = self.export_menu.addAction( f"{name} ({ext[1:].upper()})...", partial(self.export_file, model.export_data, "Export data", ext)) self.actions["export_bads"] = file_menu.addAction( "Export &bad channels...", lambda: self.export_file(model.export_bads, "Export bad channels", "*.csv")) self.actions["export_events"] = file_menu.addAction( "Export &events...", lambda: self.export_file(model.export_events, "Export events", "*.csv")) self.actions["export_annotations"] = file_menu.addAction( "Export &annotations...", lambda: self.export_file(model.export_annotations, "Export annotations", "*.csv")) self.actions["export_ica"] = file_menu.addAction( "Export ICA...", lambda: self.export_file(model.export_ica, "Export ICA", "*.fif *.fif.gz")) file_menu.addSeparator() self.actions["quit"] = file_menu.addAction("&Quit", self.close, QKeySequence.Quit) edit_menu = self.menuBar().addMenu("&Edit") self.actions["pick_chans"] = edit_menu.addAction( "P&ick channels...", self.pick_channels) icon = QIcon(image_path("chan_props.svg")) self.actions["chan_props"] = edit_menu.addAction( icon, "Channel &properties...", self.channel_properties) self.actions["set_montage"] = edit_menu.addAction("Set &montage...", self.set_montage) edit_menu.addSeparator() self.actions["set_ref"] = edit_menu.addAction("Set &reference...", self.set_reference) edit_menu.addSeparator() self.actions["annotations"] = edit_menu.addAction( "&Annotations...", self.edit_annotations) self.actions["events"] = edit_menu.addAction("&Events...", self.edit_events) edit_menu.addSeparator() self.actions["crop"] = edit_menu.addAction("&Crop data...", self.crop) self.actions["append_data"] = edit_menu.addAction( "Appen&d data...", self.append_data) plot_menu = self.menuBar().addMenu("&Plot") icon = QIcon(image_path("plot_data.svg")) self.actions["plot_data"] = plot_menu.addAction(icon, "&Data...", self.plot_data) icon = QIcon(image_path("plot_psd.svg")) self.actions["plot_psd"] = plot_menu.addAction( icon, "&Power spectral density...", self.plot_psd) icon = QIcon(image_path("plot_montage.svg")) self.actions["plot_montage"] = plot_menu.addAction(icon, "&Montage...", self.plot_montage) plot_menu.addSeparator() self.actions["plot_ica_components"] = plot_menu.addAction( "ICA &components...", self.plot_ica_components) self.actions["plot_ica_sources"] = plot_menu.addAction( "ICA &sources...", self.plot_ica_sources) tools_menu = self.menuBar().addMenu("&Tools") icon = QIcon(image_path("filter.svg")) self.actions["filter"] = tools_menu.addAction(icon, "&Filter data...", self.filter_data) icon = QIcon(image_path("find_events.svg")) self.actions["find_events"] = tools_menu.addAction(icon, "Find &events...", self.find_events) self.actions["events_from_annotations"] = tools_menu.addAction( "Create events from annotations", self.events_from_annotations ) tools_menu.addSeparator() icon = QIcon(image_path("run_ica.svg")) self.actions["run_ica"] = tools_menu.addAction(icon, "Run &ICA...", self.run_ica) self.actions["apply_ica"] = tools_menu.addAction("Apply &ICA", self.apply_ica) tools_menu.addSeparator() self.actions["interpolate_bads"] = tools_menu.addAction( "Interpolate bad channels...", self.interpolate_bads) tools_menu.addSeparator() icon = QIcon(image_path("epoch_data.svg")) self.actions["epoch_data"] = tools_menu.addAction( icon, "Create Epochs...", self.epoch_data) view_menu = self.menuBar().addMenu("&View") self.actions["history"] = view_menu.addAction("&History...", self.show_history) self.actions["toolbar"] = view_menu.addAction("&Toolbar", self._toggle_toolbar) self.actions["toolbar"].setCheckable(True) self.actions["statusbar"] = view_menu.addAction("&Statusbar", self._toggle_statusbar) self.actions["statusbar"].setCheckable(True) help_menu = self.menuBar().addMenu("&Help") self.actions["about"] = help_menu.addAction("&About", self.show_about) self.actions["about_qt"] = help_menu.addAction("About &Qt", self.show_about_qt) # actions that are always enabled self.always_enabled = ["open_file", "about", "about_qt", "quit", "toolbar", "statusbar"] # set up toolbar self.toolbar = self.addToolBar("toolbar") self.toolbar.setObjectName("toolbar") self.toolbar.addAction(self.actions["open_file"]) self.toolbar.addAction(self.actions["meta_info"]) self.toolbar.addSeparator() self.toolbar.addAction(self.actions["chan_props"]) self.toolbar.addSeparator() self.toolbar.addAction(self.actions["plot_data"]) self.toolbar.addAction(self.actions["plot_psd"]) self.toolbar.addAction(self.actions["plot_montage"]) self.toolbar.addSeparator() self.toolbar.addAction(self.actions["filter"]) self.toolbar.addAction(self.actions["find_events"]) self.toolbar.addAction(self.actions["epoch_data"]) self.toolbar.addAction(self.actions["run_ica"]) self.setUnifiedTitleAndToolBarOnMac(True) if settings["toolbar"]: self.toolbar.show() self.actions["toolbar"].setChecked(True) else: self.toolbar.hide() self.actions["toolbar"].setChecked(False) # set up data model for sidebar (list of open files) self.names = QStringListModel() self.names.dataChanged.connect(self._update_names) splitter = QSplitter() self.sidebar = QListView() self.sidebar.setFrameStyle(QFrame.NoFrame) self.sidebar.setFocusPolicy(Qt.NoFocus) self.sidebar.setModel(self.names) self.sidebar.clicked.connect(self._update_data) splitter.addWidget(self.sidebar) self.infowidget = InfoWidget() splitter.addWidget(self.infowidget) width = splitter.size().width() splitter.setSizes((int(width * 0.3), int(width * 0.7))) self.setCentralWidget(splitter) self.status_label = QLabel() self.statusBar().addPermanentWidget(self.status_label) if settings["statusbar"]: self.statusBar().show() self.actions["statusbar"].setChecked(True) else: self.statusBar().hide() self.actions["statusbar"].setChecked(False) self.setAcceptDrops(True) self.data_changed() def data_changed(self): # update sidebar self.names.setStringList(self.model.names) self.sidebar.setCurrentIndex(self.names.index(self.model.index)) # update info widget if self.model.data: self.infowidget.set_values(self.model.get_info()) else: self.infowidget.clear() # update status bar if self.model.data: mb = self.model.nbytes / 1024 ** 2 self.status_label.setText(f"Total Memory: {mb:.2f} MB") else: self.status_label.clear() # toggle actions if len(self.model) == 0: # disable if no data sets are currently open enabled = False else: enabled = True for name, action in self.actions.items(): # toggle if name not in self.always_enabled: action.setEnabled(enabled) if self.model.data: # toggle if specific conditions are met bads = bool(self.model.current["data"].info["bads"]) self.actions["export_bads"].setEnabled(enabled and bads) events = self.model.current["events"] is not None self.actions["export_events"].setEnabled(enabled and events) if self.model.current["dtype"] == "raw": annot = bool(self.model.current["data"].annotations) else: annot = False self.actions["export_annotations"].setEnabled(enabled and annot) self.actions["annotations"].setEnabled(enabled and annot) montage = bool(self.model.current["montage"]) self.actions["plot_montage"].setEnabled(enabled and montage) ica = bool(self.model.current["ica"]) self.actions["apply_ica"].setEnabled(enabled and ica) self.actions["export_ica"].setEnabled(enabled and ica) self.actions["plot_ica_components"].setEnabled(enabled and ica and montage) self.actions["plot_ica_sources"].setEnabled(enabled and ica) self.actions["interpolate_bads"].setEnabled(enabled and montage and bads) self.actions["events"].setEnabled(enabled and events) self.actions["events_from_annotations"].setEnabled(enabled and annot) self.actions["find_events"].setEnabled( enabled and self.model.current["dtype"] == "raw") self.actions["epoch_data"].setEnabled( enabled and events and self.model.current["dtype"] == "raw") self.actions["crop"].setEnabled( enabled and self.model.current["dtype"] == "raw") append = bool(self.model.get_compatibles()) self.actions["append_data"].setEnabled(enabled and append and (self.model.current["dtype"] in ("raw", "epochs"))) self.actions["meta_info"].setEnabled( enabled and self.model.current["ftype"] == "Extensible Data Format") # add to recent files if len(self.model) > 0: self._add_recent(self.model.current["fname"]) def open_data(self, fname=None): """Open raw file.""" if fname is None: fname = QFileDialog.getOpenFileName(self, "Open raw", filter="*")[0] if fname: if not (isfile(fname) or isdir(fname)): self._remove_recent(fname) QMessageBox.critical(self, "File does not exist", f"File {fname} does not exist anymore.") return name, ext, ftype = split_fname(fname, IMPORT_FORMATS) if ext in [".xdf", ".xdfz", ".xdf.gz"]: streams = parse_chunks(parse_xdf(fname)) rows, disabled = [], [] for idx, s in enumerate(streams): rows.append([s["stream_id"], s["name"], s["type"], s["channel_count"], s["channel_format"], s["nominal_srate"]]) is_marker = (s["nominal_srate"] == 0 or s["channel_format"] == "string") if is_marker: # disable marker streams disabled.append(idx) enabled = list(set(range(len(rows))) - set(disabled)) if enabled: selected = enabled[0] else: selected = None dialog = XDFStreamsDialog(self, rows, selected=selected, disabled=disabled) if dialog.exec_(): row = dialog.view.selectionModel().selectedRows()[0].row() stream_id = dialog.model.data(dialog.model.index(row, 0)) self.model.load(fname, stream_id=stream_id) else: # all other file formats try: self.model.load(fname) except FileNotFoundError as e: QMessageBox.critical(self, "File not found", str(e)) except UnknownFileTypeError as e: QMessageBox.critical(self, "Unknown file type", str(e)) def open_file(self, f, text, ffilter="*"): """Open file.""" fname = QFileDialog.getOpenFileName(self, text, filter=ffilter)[0] if fname: f(fname) def export_file(self, f, text, ffilter="*"): """Export to file.""" fname = QFileDialog.getSaveFileName(self, text, filter=ffilter)[0] if fname: f(fname, ffilter) def import_file(self, f, text, ffilter="*"): """Import file.""" fname = QFileDialog.getOpenFileName(self, text, filter=ffilter)[0] if fname: try: f(fname) except LabelsNotFoundError as e: QMessageBox.critical(self, "Channel labels not found", str(e)) except InvalidAnnotationsError as e: QMessageBox.critical(self, "Invalid annotations", str(e)) def close_all(self): """Close all currently open data sets.""" msg = QMessageBox.question(self, "Close all data sets", "Close all data sets?") if msg == QMessageBox.Yes: while len(self.model) > 0: self.model.remove_data() def meta_info(self): xml = get_xml(self.model.current["fname"]) dialog = MetaInfoDialog(self, xml) dialog.exec_() def pick_channels(self): """Pick channels in current data set.""" channels = self.model.current["data"].info["ch_names"] dialog = PickChannelsDialog(self, channels, selected=channels) if dialog.exec_(): picks = [item.data(0) for item in dialog.channels.selectedItems()] drops = set(channels) - set(picks) if drops: self.auto_duplicate() self.model.drop_channels(drops) self.model.history.append(f"raw.drop({drops})") def channel_properties(self): """Show channel properties dialog.""" info = self.model.current["data"].info dialog = ChannelPropertiesDialog(self, info) if dialog.exec_(): dialog.model.sort(0) bads = [] renamed = {} types = {} for i in range(dialog.model.rowCount()): new_label = dialog.model.item(i, 1).data(Qt.DisplayRole) old_label = info["ch_names"][i] if new_label != old_label: renamed[old_label] = new_label new_type = dialog.model.item(i, 2).data(Qt.DisplayRole).lower() old_type = channel_type(info, i).lower() if new_type != old_type: types[new_label] = new_type if dialog.model.item(i, 3).checkState() == Qt.Checked: bads.append(info["ch_names"][i]) self.model.set_channel_properties(bads, renamed, types) def set_montage(self): """Set montage.""" montages = mne.channels.get_builtin_montages() # TODO: currently it is not possible to remove an existing montage dialog = MontageDialog(self, montages, selected=self.model.current["montage"]) if dialog.exec_(): name = dialog.montages.selectedItems()[0].data(0) montage = mne.channels.make_standard_montage(name) ch_names = self.model.current["data"].info["ch_names"] # check if at least one channel name matches a name in the montage if set(ch_names) & set(montage.ch_names): self.model.set_montage(name) else: QMessageBox.critical(self, "No matching channel names", "Channel names defined in the montage do " "not match any channel name in the data.") def edit_annotations(self): fs = self.model.current["data"].info["sfreq"] pos = self.model.current["data"].annotations.onset pos = (pos * fs).astype(int).tolist() dur = self.model.current["data"].annotations.duration dur = (dur * fs).astype(int).tolist() desc = self.model.current["data"].annotations.description.tolist() dialog = AnnotationsDialog(self, pos, dur, desc) if dialog.exec_(): rows = dialog.table.rowCount() onset, duration, description = [], [], [] for i in range(rows): data = dialog.table.item(i, 0).data(Qt.DisplayRole) onset.append(float(data) / fs) data = dialog.table.item(i, 1).data(Qt.DisplayRole) duration.append(float(data) / fs) data = dialog.table.item(i, 2).data(Qt.DisplayRole) description.append(data) self.model.set_annotations(onset, duration, description) def edit_events(self): pos = self.model.current["events"][:, 0].tolist() desc = self.model.current["events"][:, 2].tolist() dialog = EventsDialog(self, pos, desc) if dialog.exec_(): rows = dialog.table.rowCount() events = np.zeros((rows, 3), dtype=int) for i in range(rows): pos = int(dialog.table.item(i, 0).data(Qt.DisplayRole)) desc = int(dialog.table.item(i, 1).data(Qt.DisplayRole)) events[i] = pos, 0, desc self.model.set_events(events) def crop(self): """Crop data.""" fs = self.model.current["data"].info["sfreq"] length = self.model.current["data"].n_times / fs dialog = CropDialog(self, 0, length) if dialog.exec_(): self.auto_duplicate() self.model.crop(dialog.start or 0, dialog.stop) def append_data(self): """Concatenate raw data objects to current one.""" compatibles = self.model.get_compatibles() dialog = AppendDialog(self, compatibles) if dialog.exec_(): self.auto_duplicate() self.model.append_data(dialog.names) def plot_data(self): """Plot data.""" # self.bad is needed to update history if bad channels are selected in # the interactive plot window (see also self.eventFilter) self.bads = self.model.current["data"].info["bads"] events = self.model.current["events"] nchan = self.model.current["data"].info["nchan"] fig = self.model.current["data"].plot(events=events, n_channels=nchan, title=self.model.current["name"], scalings="auto", show=False) if events is not None: hist = f"data.plot(events=events, n_channels={nchan})" else: hist = f"data.plot(n_channels={nchan})" self.model.history.append(hist) win = fig.canvas.manager.window win.setWindowTitle(self.model.current["name"]) win.findChild(QStatusBar).hide() win.installEventFilter(self) # detect if the figure is closed # prevent closing the window with the escape key try: fig._mne_params["close_key"] = None except AttributeError: # does not exist in older MNE versions pass fig.show() def plot_psd(self): """Plot power spectral density (PSD).""" kwds = {} if self.model.current["dtype"] == "raw": kwds.update({"average": False, "spatial_colors": False}) fig = self.model.current["data"].plot_psd(show=False, **kwds) if kwds: tmp = ", ".join(f"{key}={value}" for key, value in kwds.items()) hist = f"data.plot_psd({tmp})" else: hist = "data.plot_psd()" self.model.history.append(hist) win = fig.canvas.manager.window win.setWindowTitle("Power spectral density") fig.show() def plot_montage(self): """Plot current montage.""" fig = self.model.current["data"].plot_sensors(show_names=True, show=False) win = fig.canvas.manager.window win.setWindowTitle("Montage") win.findChild(QStatusBar).hide() win.findChild(QToolBar).hide() fig.show() def plot_ica_components(self): self.model.current["ica"].plot_components( inst=self.model.current["data"]) def plot_ica_sources(self): self.model.current["ica"].plot_sources(inst=self.model.current["data"]) def run_ica(self): """Run ICA calculation.""" methods = ["Infomax"] if have["picard"]: methods.insert(0, "Picard") if have["sklearn"]: methods.append("FastICA") dialog = RunICADialog(self, self.model.current["data"].info["nchan"], methods) if dialog.exec_(): calc = CalcDialog(self, "Calculating ICA", "Calculating ICA.") method = dialog.method.currentText().lower() exclude_bad_segments = dialog.exclude_bad_segments.isChecked() fit_params = {} if dialog.extended.isEnabled(): fit_params["extended"] = dialog.extended.isChecked() if dialog.ortho.isEnabled(): fit_params["ortho"] = dialog.ortho.isChecked() ica = mne.preprocessing.ICA(method=method, fit_params=fit_params) history = f"ica = mne.preprocessing.ICA(method='{method}'" if fit_params: history += f", fit_params={fit_params})" else: history += ")" self.model.history.append(history) pool = pebble.ProcessPool(max_workers=1) process = pool.schedule(function=ica.fit, args=(self.model.current["data"],), kwargs={"reject_by_annotation": exclude_bad_segments}) process.add_done_callback(lambda x: calc.accept()) pool.close() if not calc.exec_(): pool.stop() pool.join() else: self.model.current["ica"] = process.result() self.model.history.append(f"ica.fit(inst=raw, " f"reject_by_annotation=" f"{exclude_bad_segments})") self.data_changed() pool.join() def apply_ica(self): """Apply current fitted ICA.""" self.auto_duplicate() self.model.apply_ica() def interpolate_bads(self): """Interpolate bad channels""" dialog = InterpolateBadsDialog(self) if dialog.exec_(): duplicated = self.auto_duplicate() try: self.model.interpolate_bads(dialog.reset_bads, dialog.mode, dialog.origin) except ValueError as e: if duplicated: # undo self.model.remove_data() self.model.index -= 1 self.data_changed() msgbox = ErrorMessageBox(self, "Could not interpolate bad channels", str(e), traceback.format_exc()) msgbox.show() def filter_data(self): """Filter data.""" dialog = FilterDialog(self) if dialog.exec_(): self.auto_duplicate() self.model.filter(dialog.low, dialog.high) def find_events(self): info = self.model.current["data"].info # use first stim channel as default in dialog default_stim = 0 for i in range(info["nchan"]): if mne.io.pick.channel_type(info, i) == "stim": default_stim = i break dialog = FindEventsDialog(self, info["ch_names"], default_stim) if dialog.exec_(): stim_channel = dialog.stimchan.currentText() consecutive = dialog.consecutive.isChecked() initial_event = dialog.initial_event.isChecked() uint_cast = dialog.uint_cast.isChecked() min_dur = dialog.minduredit.value() shortest_event = dialog.shortesteventedit.value() self.model.find_events(stim_channel=stim_channel, consecutive=consecutive, initial_event=initial_event, uint_cast=uint_cast, min_duration=min_dur, shortest_event=shortest_event) def events_from_annotations(self): self.model.events_from_annotations() def epoch_data(self): """Epoch raw data.""" dialog = EpochDialog(self, self.model.current["events"]) if dialog.exec_(): events = [int(item.text()) for item in dialog.events.selectedItems()] tmin = dialog.tmin.value() tmax = dialog.tmax.value() if dialog.baseline.isChecked(): baseline = dialog.a.value(), dialog.b.value() else: baseline = None duplicated = self.auto_duplicate() try: self.model.epoch_data(events, tmin, tmax, baseline) except ValueError as e: if duplicated: # undo self.model.remove_data() self.model.index -= 1 self.data_changed() msgbox = ErrorMessageBox(self, "Could not create epochs", str(e), traceback.format_exc()) msgbox.show() def set_reference(self): """Set reference.""" dialog = ReferenceDialog(self) if dialog.exec_(): self.auto_duplicate() if dialog.average.isChecked(): self.model.set_reference("average") else: ref = [c.strip() for c in dialog.channellist.text().split(",")] self.model.set_reference(ref) def show_history(self): """Show history.""" dialog = HistoryDialog(self, "\n".join(self.model.history)) dialog.exec_() def show_about(self): """Show About dialog.""" msg_box = QMessageBox(self) text = (f"<img src='{image_path('mnelab_logo.png')}'>" f"<p>MNELAB {__version__}</p>") msg_box.setText(text) mnelab_url = "github.com/cbrnr/mnelab" mne_url = "github.com/mne-tools/mne-python" pkgs = [] for key, value in have.items(): if value: pkgs.append(f"{key} ({value})") else: pkgs.append(f"{key} (not installed)") text = (f'<nobr><p>This program uses Python ' f'{".".join(str(k) for k in version_info[:3])} and the ' f'following packages:</p></nobr>' f'<p>{", ".join(pkgs)}</p>' f'<nobr><p>MNELAB repository: ' f'<a href=https://{mnelab_url}>{mnelab_url}</a></p></nobr>' f'<nobr><p>MNE repository: ' f'<a href=https://{mne_url}>{mne_url}</a></p></nobr>' f'<p>Licensed under the BSD 3-clause license.</p>' f'<p>Copyright 2017-2020 by Clemens Brunner.</p>') msg_box.setInformativeText(text) msg_box.exec_() def show_about_qt(self): """Show About Qt dialog.""" QMessageBox.aboutQt(self, "About Qt") def auto_duplicate(self): """Automatically duplicate current data set. If the current data set is stored in a file (i.e. was loaded directly from a file), a new data set is automatically created. If the current data set is not stored in a file (i.e. was created by operations in MNELAB), a dialog box asks the user if the current data set should be overwritten or duplicated. Returns ------- duplicated : bool True if the current data set was automatically duplicated, False if the current data set was overwritten. """ # if current data is stored in a file create a new data set if self.model.current["fname"]: self.model.duplicate_data() return True # otherwise ask the user else: msg = QMessageBox.question(self, "Overwrite existing data set", "Overwrite existing data set?") if msg == QMessageBox.No: # create new data set self.model.duplicate_data() return True return False def _add_recent(self, fname): """Add a file to recent file list. Parameters ---------- fname : str File name. """ if fname in self.recent: # avoid duplicates self.recent.remove(fname) self.recent.insert(0, fname) while len(self.recent) > MAX_RECENT: # prune list self.recent.pop() write_settings(recent=self.recent) if not self.recent_menu.isEnabled(): self.recent_menu.setEnabled(True) def _remove_recent(self, fname): """Remove file from recent file list. Parameters ---------- fname : str File name. """ if fname in self.recent: self.recent.remove(fname) write_settings(recent=self.recent) if not self.recent: self.recent_menu.setEnabled(False) @Slot(QModelIndex) def _update_data(self, selected): """Update index and information based on the state of the sidebar. Parameters ---------- selected : QModelIndex Index of the selected row. """ if selected.row() != self.model.index: self.model.index = selected.row() self.data_changed() self.model.history.append(f"data = datasets[{self.model.index}]") @Slot(QModelIndex, QModelIndex) def _update_names(self, start, stop): """Update names in DataSets after changes in sidebar.""" for index in range(start.row(), stop.row() + 1): self.model.data[index]["name"] = self.names.stringList()[index] @Slot() def _update_recent_menu(self): self.recent_menu.clear() for recent in self.recent: self.recent_menu.addAction(recent) @Slot(QAction) def _load_recent(self, action): self.open_data(fname=action.text()) @Slot() def _toggle_toolbar(self): if self.toolbar.isHidden(): self.toolbar.show() else: self.toolbar.hide() write_settings(toolbar=not self.toolbar.isHidden()) @Slot() def _toggle_statusbar(self): if self.statusBar().isHidden(): self.statusBar().show() else: self.statusBar().hide() write_settings(statusbar=not self.statusBar().isHidden()) @Slot(QDropEvent) def dragEnterEvent(self, event): if event.mimeData().hasUrls(): event.acceptProposedAction() @Slot(QDropEvent) def dropEvent(self, event): mime = event.mimeData() if mime.hasUrls(): urls = mime.urls() for url in urls: try: self.open_data(url.toLocalFile()) except FileNotFoundError as e: QMessageBox.critical(self, "File not found", str(e)) @Slot(QEvent) def closeEvent(self, event): """Close application. Parameters ---------- event : QEvent Close event. """ write_settings(geometry=self.saveGeometry(), state=self.saveState()) if self.model.history: print("\nCommand History") print("===============") print("\n".join(self.model.history)) QApplication.quit() def eventFilter(self, source, event): # currently the only source is the raw plot window if event.type() == QEvent.Close: self.data_changed() bads = self.model.current["data"].info["bads"] if self.bads != bads: self.model.history.append(f"data.info['bads'] = {bads}") return QObject.eventFilter(self, source, event)
class PyDMEmbeddedDisplay(QFrame, PyDMPrimitiveWidget): """ A QFrame capable of rendering a PyDM Display Parameters ---------- parent : QWidget The parent widget for the Label """ def __init__(self, parent=None): QFrame.__init__(self, parent) PyDMPrimitiveWidget.__init__(self) self.app = QApplication.instance() self._filename = None self._macros = None self._embedded_widget = None self._disconnect_when_hidden = True self._is_connected = False self._only_load_when_shown = True self._needs_load = True self._load_error_timer = None self._load_error = None self.layout = QVBoxLayout(self) self.err_label = QLabel(self) self.err_label.setAlignment(Qt.AlignHCenter) self.layout.addWidget(self.err_label) self.layout.setContentsMargins(0, 0, 0, 0) self.err_label.hide() if not is_pydm_app(): self.setFrameShape(QFrame.Box) else: self.setFrameShape(QFrame.NoFrame) def minimumSizeHint(self): """ This property holds the recommended minimum size for the widget. Returns ------- QSize """ # This is totally arbitrary, I just want *some* visible nonzero size return QSize(100, 100) @Property(str) def macros(self): """ JSON-formatted string containing macro variables to pass to the embedded file. Returns ------- str """ if self._macros is None: return "" return self._macros @macros.setter def macros(self, new_macros): """ JSON-formatted string containing macro variables to pass to the embedded file. .. warning:: If the macros property is not defined before the filename property, The widget will not have any macros defined when it loads the embedded file. This behavior will be fixed soon. Parameters ---------- new_macros : str """ new_macros = str(new_macros) if new_macros != self._macros: self._macros = new_macros self._needs_load = True self.load_if_needed() @Property(str) def filename(self): """ Filename of the display to embed. Returns ------- str """ if self._filename is None: return "" return self._filename @filename.setter def filename(self, filename): """ Filename of the display to embed. Parameters ---------- filename : str """ filename = str(filename) if filename != self._filename: self._filename = filename self._needs_load = True if is_qt_designer(): if self._load_error_timer: # Kill the timer here. If new filename still causes the problem, it will be restarted self._load_error_timer.stop() self._load_error_timer = None self.clear_error_text() self.load_if_needed() def parsed_macros(self): """ Dictionary containing the key value pair for each macro specified. Returns -------- dict """ parent_display = self.find_parent_display() parent_macros = {} if parent_display: parent_macros = copy.copy(parent_display.macros()) widget_macros = macro.parse_macro_string(self.macros) parent_macros.update(widget_macros) return parent_macros def load_if_needed(self): if self._needs_load and (not self._only_load_when_shown or self.isVisible() or is_qt_designer()): self.embedded_widget = self.open_file() def open_file(self, force=False): """ Opens the widget specified in the widget's filename property. Returns ------- display : QWidget """ if (not force) and (not self._needs_load): return if not self.filename: return try: parent_display = self.find_parent_display() base_path = "" if parent_display: base_path = os.path.dirname(parent_display.loaded_file()) fname = find_file(self.filename, base_path=base_path) w = load_file(fname, macros=self.parsed_macros(), target=None) self._needs_load = False self.clear_error_text() return w except Exception as e: self._load_error = e if self._load_error_timer: self._load_error_timer.stop() self._load_error_timer = QTimer(self) self._load_error_timer.setSingleShot(True) self._load_error_timer.setTimerType(Qt.VeryCoarseTimer) self._load_error_timer.timeout.connect( self._display_designer_load_error) self._load_error_timer.start(1000) return None def clear_error_text(self): if self._load_error_timer: self._load_error_timer.stop() self.err_label.clear() self.err_label.hide() def display_error_text(self, e): self.err_label.setText( "Could not open {filename}.\nError: {err}".format( filename=self._filename, err=e)) self.err_label.show() @property def embedded_widget(self): """ The embedded widget being displayed. Returns ------- QWidget """ return self._embedded_widget @embedded_widget.setter def embedded_widget(self, new_widget): """ Defines the embedded widget to display inside the QFrame Parameters ---------- new_widget : QWidget """ should_reconnect = False if new_widget is self._embedded_widget: return if self._embedded_widget is not None: self.layout.removeWidget(self._embedded_widget) self._embedded_widget.deleteLater() self._embedded_widget = None if new_widget is not None: self._embedded_widget = new_widget self._embedded_widget.setParent(self) self.layout.addWidget(self._embedded_widget) self.err_label.hide() self._embedded_widget.show() self._is_connected = True def connect(self): """ Establish the connection between the embedded widget and the channels associated with it. """ if self._is_connected or self.embedded_widget is None: return establish_widget_connections(self.embedded_widget) self._is_connected = True def disconnect(self): """ Disconnects the embedded widget from the channels associated with it. """ if not self._is_connected or self.embedded_widget is None: return close_widget_connections(self.embedded_widget) self._is_connected = False @Property(bool) def loadWhenShown(self): """ If True, only load and display the file once the PyDMEmbeddedDisplayWidget is visible on screen. This is very useful if you have many different PyDMEmbeddedWidgets in different tabs of a QTabBar or PyDMTabBar: only the tab that the user is looking at will be loaded, which can greatly speed up the launch time of a display. If this property is changed from 'True' to 'False', and the file has not been loaded yet, it will be loaded immediately. Returns ------- bool """ return self._only_load_when_shown @loadWhenShown.setter def loadWhenShown(self, val): self._only_load_when_shown = val self.load_if_needed() @Property(bool) def disconnectWhenHidden(self): """ Disconnect from PVs when this widget is not visible. Returns ------- bool """ return self._disconnect_when_hidden @disconnectWhenHidden.setter def disconnectWhenHidden(self, disconnect_when_hidden): """ Disconnect from PVs when this widget is not visible. Parameters ---------- disconnect_when_hidden : bool """ self._disconnect_when_hidden = disconnect_when_hidden def showEvent(self, e): """ Show events are sent to widgets that become visible on the screen. Parameters ---------- event : QShowEvent """ if self._only_load_when_shown: w = self.open_file() if w: self.embedded_widget = w if self.disconnectWhenHidden: self.connect() def hideEvent(self, e): """ Hide events are sent to widgets that become invisible on the screen. Parameters ---------- event : QHideEvent """ if self.disconnectWhenHidden: self.disconnect() def _display_designer_load_error(self): self._load_error_timer = None logger.exception("Exception while opening embedded display file.", exc_info=self._load_error) if self._load_error: self.display_error_text(self._load_error)
class PyDMChartingDisplay(Display): def __init__(self, parent=None, args=[], macros=None): """ Create all the widgets, including any child dialogs. Parameters ---------- parent : QWidget The parent widget of the charting display args : list The command parameters macros : str Macros to modify the UI parameters at runtime """ super(PyDMChartingDisplay, self).__init__(parent=parent, args=args, macros=macros) self.channel_map = dict() self.setWindowTitle("PyDM Charting Tool") self.main_layout = QVBoxLayout() self.body_layout = QVBoxLayout() self.pv_layout = QHBoxLayout() self.pv_name_line_edt = QLineEdit() self.pv_name_line_edt.setAcceptDrops(True) self.pv_name_line_edt.installEventFilter(self) self.pv_protocol_cmb = QComboBox() self.pv_protocol_cmb.addItems(["ca://", "archive://"]) self.pv_connect_push_btn = QPushButton("Connect") self.pv_connect_push_btn.clicked.connect(self.add_curve) self.tab_panel = QTabWidget() self.tab_panel.setMaximumWidth(450) self.curve_settings_tab = QWidget() self.chart_settings_tab = QWidget() self.charting_layout = QHBoxLayout() self.chart = PyDMTimePlot(plot_by_timestamps=False, plot_display=self) self.chart.setPlotTitle("Time Plot") self.splitter = QSplitter() self.curve_settings_layout = QVBoxLayout() self.curve_settings_layout.setAlignment(Qt.AlignTop) self.curve_settings_layout.setSizeConstraint(QLayout.SetMinAndMaxSize) self.curve_settings_layout.setSpacing(5) self.crosshair_settings_layout = QVBoxLayout() self.crosshair_settings_layout.setAlignment(Qt.AlignTop) self.crosshair_settings_layout.setSpacing(5) self.enable_crosshair_chk = QCheckBox("Enable Crosshair") self.cross_hair_coord_lbl = QLabel() self.curve_settings_inner_frame = QFrame() self.curve_settings_inner_frame.setLayout(self.curve_settings_layout) self.curve_settings_scroll = QScrollArea() self.curve_settings_scroll.setVerticalScrollBarPolicy( Qt.ScrollBarAsNeeded) self.curve_settings_scroll.setWidget(self.curve_settings_inner_frame) self.curves_tab_layout = QHBoxLayout() self.curves_tab_layout.addWidget(self.curve_settings_scroll) self.enable_crosshair_chk.setChecked(False) self.enable_crosshair_chk.clicked.connect( self.handle_enable_crosshair_checkbox_clicked) self.enable_crosshair_chk.clicked.emit(False) self.chart_settings_layout = QVBoxLayout() self.chart_settings_layout.setAlignment(Qt.AlignTop) self.chart_layout = QVBoxLayout() self.chart_panel = QWidget() self.chart_control_layout = QHBoxLayout() self.chart_control_layout.setAlignment(Qt.AlignHCenter) self.chart_control_layout.setSpacing(10) self.view_all_btn = QPushButton("View All") self.view_all_btn.clicked.connect(self.handle_view_all_button_clicked) self.view_all_btn.setEnabled(False) self.auto_scale_btn = QPushButton("Auto Scale") self.auto_scale_btn.clicked.connect(self.handle_auto_scale_btn_clicked) self.auto_scale_btn.setEnabled(False) self.reset_chart_btn = QPushButton("Reset") self.reset_chart_btn.clicked.connect( self.handle_reset_chart_btn_clicked) self.reset_chart_btn.setEnabled(False) self.resume_chart_text = "Resume" self.pause_chart_text = "Pause" self.pause_chart_btn = QPushButton(self.pause_chart_text) self.pause_chart_btn.clicked.connect( self.handle_pause_chart_btn_clicked) self.title_settings_layout = QVBoxLayout() self.title_settings_layout.setSpacing(10) self.title_settings_grpbx = QGroupBox() self.title_settings_grpbx.setFixedHeight(150) self.import_data_btn = QPushButton("Import Data...") self.import_data_btn.clicked.connect( self.handle_import_data_btn_clicked) self.export_data_btn = QPushButton("Export Data...") self.export_data_btn.clicked.connect( self.handle_export_data_btn_clicked) self.chart_title_lbl = QLabel(text="Chart Title") self.chart_title_line_edt = QLineEdit() self.chart_title_line_edt.setText(self.chart.getPlotTitle()) self.chart_title_line_edt.textChanged.connect( self.handle_title_text_changed) self.chart_change_axis_settings_btn = QPushButton( text="Change Axis Settings...") self.chart_change_axis_settings_btn.clicked.connect( self.handle_change_axis_settings_clicked) self.update_datetime_timer = QTimer(self) self.update_datetime_timer.timeout.connect( self.handle_update_datetime_timer_timeout) self.chart_sync_mode_layout = QVBoxLayout() self.chart_sync_mode_layout.setSpacing(5) self.chart_sync_mode_grpbx = QGroupBox("Data Sampling Mode") self.chart_sync_mode_grpbx.setFixedHeight(80) self.chart_sync_mode_sync_radio = QRadioButton("Synchronous") self.chart_sync_mode_async_radio = QRadioButton("Asynchronous") self.chart_sync_mode_async_radio.setChecked(True) self.graph_drawing_settings_layout = QVBoxLayout() self.chart_redraw_rate_lbl = QLabel("Redraw Rate (Hz)") self.chart_redraw_rate_spin = QSpinBox() self.chart_redraw_rate_spin.setRange(MIN_REDRAW_RATE_HZ, MAX_REDRAW_RATE_HZ) self.chart_redraw_rate_spin.setValue(DEFAULT_REDRAW_RATE_HZ) self.chart_redraw_rate_spin.valueChanged.connect( self.handle_redraw_rate_changed) self.chart_data_sampling_rate_lbl = QLabel( "Asynchronous Data Sampling Rate (Hz)") self.chart_data_async_sampling_rate_spin = QSpinBox() self.chart_data_async_sampling_rate_spin.setRange( MIN_DATA_SAMPLING_RATE_HZ, MAX_DATA_SAMPLING_RATE_HZ) self.chart_data_async_sampling_rate_spin.setValue( DEFAULT_DATA_SAMPLING_RATE_HZ) self.chart_data_async_sampling_rate_spin.valueChanged.connect( self.handle_data_sampling_rate_changed) self.chart_data_sampling_rate_lbl.hide() self.chart_data_async_sampling_rate_spin.hide() self.chart_limit_time_span_layout = QHBoxLayout() self.chart_limit_time_span_layout.setSpacing(5) self.limit_time_plan_text = "Limit Time Span" self.chart_limit_time_span_chk = QCheckBox(self.limit_time_plan_text) self.chart_limit_time_span_chk.hide() self.chart_limit_time_span_lbl = QLabel("Hours : Minutes : Seconds") self.chart_limit_time_span_hours_line_edt = QLineEdit() self.chart_limit_time_span_minutes_line_edt = QLineEdit() self.chart_limit_time_span_seconds_line_edt = QLineEdit() self.chart_limit_time_span_activate_btn = QPushButton("Apply") self.chart_limit_time_span_activate_btn.setDisabled(True) self.chart_ring_buffer_size_lbl = QLabel("Ring Buffer Size") self.chart_ring_buffer_size_edt = QLineEdit() self.chart_ring_buffer_size_edt.installEventFilter(self) self.chart_ring_buffer_size_edt.textChanged.connect( self.handle_buffer_size_changed) self.chart_ring_buffer_size_edt.setText(str(DEFAULT_BUFFER_SIZE)) self.show_legend_chk = QCheckBox("Show Legend") self.show_legend_chk.setChecked(self.chart.showLegend) self.show_legend_chk.clicked.connect( self.handle_show_legend_checkbox_clicked) self.graph_background_color_layout = QFormLayout() self.background_color_lbl = QLabel("Graph Background Color ") self.background_color_btn = QPushButton() self.background_color_btn.setStyleSheet( "background-color: " + self.chart.getBackgroundColor().name()) self.background_color_btn.setContentsMargins(10, 0, 5, 5) self.background_color_btn.setMaximumWidth(20) self.background_color_btn.clicked.connect( self.handle_background_color_button_clicked) self.axis_settings_layout = QVBoxLayout() self.axis_settings_layout.setSpacing(5) self.show_x_grid_chk = QCheckBox("Show x Grid") self.show_x_grid_chk.setChecked(self.chart.showXGrid) self.show_x_grid_chk.clicked.connect( self.handle_show_x_grid_checkbox_clicked) self.show_y_grid_chk = QCheckBox("Show y Grid") self.show_y_grid_chk.setChecked(self.chart.showYGrid) self.show_y_grid_chk.clicked.connect( self.handle_show_y_grid_checkbox_clicked) self.axis_color_lbl = QLabel("Axis and Grid Color") self.axis_color_lbl.setEnabled(False) self.axis_color_btn = QPushButton() self.axis_color_btn.setStyleSheet("background-color: " + DEFAULT_CHART_AXIS_COLOR.name()) self.axis_color_btn.setContentsMargins(10, 0, 5, 5) self.axis_color_btn.setMaximumWidth(20) self.axis_color_btn.clicked.connect( self.handle_axis_color_button_clicked) self.axis_color_btn.setEnabled(False) self.grid_opacity_lbl = QLabel("Grid Opacity") self.grid_opacity_lbl.setEnabled(False) self.grid_opacity_slr = QSlider(Qt.Horizontal) self.grid_opacity_slr.setFocusPolicy(Qt.StrongFocus) self.grid_opacity_slr.setRange(0, 10) self.grid_opacity_slr.setValue(5) self.grid_opacity_slr.setTickInterval(1) self.grid_opacity_slr.setSingleStep(1) self.grid_opacity_slr.setTickPosition(QSlider.TicksBelow) self.grid_opacity_slr.valueChanged.connect( self.handle_grid_opacity_slider_mouse_release) self.grid_opacity_slr.setEnabled(False) self.reset_chart_settings_btn = QPushButton("Reset Chart Settings") self.reset_chart_settings_btn.clicked.connect( self.handle_reset_chart_settings_btn_clicked) self.curve_checkbox_panel = QWidget() self.graph_drawing_settings_grpbx = QGroupBox() self.graph_drawing_settings_grpbx.setFixedHeight(270) self.axis_settings_grpbx = QGroupBox() self.axis_settings_grpbx.setFixedHeight(180) self.app = QApplication.instance() self.setup_ui() self.curve_settings_disp = None self.axis_settings_disp = None self.chart_data_export_disp = None self.chart_data_import_disp = None self.grid_alpha = 5 self.time_span_limit_hours = None self.time_span_limit_minutes = None self.time_span_limit_seconds = None self.data_sampling_mode = ASYNC_DATA_SAMPLING def minimumSizeHint(self): """ The minimum recommended size of the main window. """ return QSize(1490, 800) def ui_filepath(self): """ The path to the UI file created by Qt Designer, if applicable. """ # No UI file is being used return None def ui_filename(self): """ The name the UI file created by Qt Designer, if applicable. """ # No UI file is being used return None def setup_ui(self): """ Initialize the widgets and layouts. """ self.setLayout(self.main_layout) self.pv_layout.addWidget(self.pv_protocol_cmb) self.pv_layout.addWidget(self.pv_name_line_edt) self.pv_layout.addWidget(self.pv_connect_push_btn) QTimer.singleShot(0, self.pv_name_line_edt.setFocus) self.curve_settings_tab.setLayout(self.curves_tab_layout) self.chart_settings_tab.setLayout(self.chart_settings_layout) self.setup_chart_settings_layout() self.tab_panel.addTab(self.curve_settings_tab, "Curves") self.tab_panel.addTab(self.chart_settings_tab, "Chart") self.tab_panel.hide() self.crosshair_settings_layout.addWidget(self.enable_crosshair_chk) self.crosshair_settings_layout.addWidget(self.cross_hair_coord_lbl) self.chart_control_layout.addWidget(self.auto_scale_btn) self.chart_control_layout.addWidget(self.view_all_btn) self.chart_control_layout.addWidget(self.reset_chart_btn) self.chart_control_layout.addWidget(self.pause_chart_btn) self.chart_control_layout.addLayout(self.crosshair_settings_layout) self.chart_control_layout.addWidget(self.import_data_btn) self.chart_control_layout.addWidget(self.export_data_btn) self.chart_control_layout.setStretch(4, 15) self.chart_control_layout.insertSpacing(5, 350) self.chart_layout.addWidget(self.chart) self.chart_layout.addLayout(self.chart_control_layout) self.chart_panel.setLayout(self.chart_layout) self.splitter.addWidget(self.chart_panel) self.splitter.addWidget(self.tab_panel) self.splitter.setStretchFactor(0, 0) self.splitter.setStretchFactor(1, 1) self.charting_layout.addWidget(self.splitter) self.body_layout.addLayout(self.pv_layout) self.body_layout.addLayout(self.charting_layout) self.body_layout.addLayout(self.chart_control_layout) self.main_layout.addLayout(self.body_layout) self.enable_chart_control_buttons(False) def setup_chart_settings_layout(self): self.chart_sync_mode_sync_radio.toggled.connect( partial(self.handle_sync_mode_radio_toggle, self.chart_sync_mode_sync_radio)) self.chart_sync_mode_async_radio.toggled.connect( partial(self.handle_sync_mode_radio_toggle, self.chart_sync_mode_async_radio)) self.title_settings_layout.addWidget(self.chart_title_lbl) self.title_settings_layout.addWidget(self.chart_title_line_edt) self.title_settings_layout.addWidget(self.show_legend_chk) self.title_settings_layout.addWidget( self.chart_change_axis_settings_btn) self.title_settings_grpbx.setLayout(self.title_settings_layout) self.chart_settings_layout.addWidget(self.title_settings_grpbx) self.chart_sync_mode_layout.addWidget(self.chart_sync_mode_sync_radio) self.chart_sync_mode_layout.addWidget(self.chart_sync_mode_async_radio) self.chart_sync_mode_grpbx.setLayout(self.chart_sync_mode_layout) self.chart_settings_layout.addWidget(self.chart_sync_mode_grpbx) self.chart_settings_layout.addWidget(self.chart_sync_mode_grpbx) self.chart_limit_time_span_layout.addWidget( self.chart_limit_time_span_lbl) self.chart_limit_time_span_layout.addWidget( self.chart_limit_time_span_hours_line_edt) self.chart_limit_time_span_layout.addWidget( self.chart_limit_time_span_minutes_line_edt) self.chart_limit_time_span_layout.addWidget( self.chart_limit_time_span_seconds_line_edt) self.chart_limit_time_span_layout.addWidget( self.chart_limit_time_span_activate_btn) self.chart_limit_time_span_lbl.hide() self.chart_limit_time_span_hours_line_edt.hide() self.chart_limit_time_span_minutes_line_edt.hide() self.chart_limit_time_span_seconds_line_edt.hide() self.chart_limit_time_span_activate_btn.hide() self.chart_limit_time_span_hours_line_edt.textChanged.connect( self.handle_time_span_edt_text_changed) self.chart_limit_time_span_minutes_line_edt.textChanged.connect( self.handle_time_span_edt_text_changed) self.chart_limit_time_span_seconds_line_edt.textChanged.connect( self.handle_time_span_edt_text_changed) self.chart_limit_time_span_chk.clicked.connect( self.handle_limit_time_span_checkbox_clicked) self.chart_limit_time_span_activate_btn.clicked.connect( self.handle_chart_limit_time_span_activate_btn_clicked) self.chart_limit_time_span_activate_btn.installEventFilter(self) self.graph_background_color_layout.addRow(self.background_color_lbl, self.background_color_btn) self.graph_drawing_settings_layout.addLayout( self.graph_background_color_layout) self.graph_drawing_settings_layout.addWidget( self.chart_redraw_rate_lbl) self.graph_drawing_settings_layout.addWidget( self.chart_redraw_rate_spin) self.graph_drawing_settings_layout.addWidget( self.chart_data_sampling_rate_lbl) self.graph_drawing_settings_layout.addWidget( self.chart_data_async_sampling_rate_spin) self.graph_drawing_settings_layout.addWidget( self.chart_limit_time_span_chk) self.graph_drawing_settings_layout.addLayout( self.chart_limit_time_span_layout) self.graph_drawing_settings_layout.addWidget( self.chart_ring_buffer_size_lbl) self.graph_drawing_settings_layout.addWidget( self.chart_ring_buffer_size_edt) self.graph_drawing_settings_grpbx.setLayout( self.graph_drawing_settings_layout) self.axis_settings_layout.addWidget(self.show_x_grid_chk) self.axis_settings_layout.addWidget(self.show_y_grid_chk) self.axis_settings_layout.addWidget(self.axis_color_lbl) self.axis_settings_layout.addWidget(self.axis_color_btn) self.axis_settings_layout.addWidget(self.grid_opacity_lbl) self.axis_settings_layout.addWidget(self.grid_opacity_slr) self.axis_settings_grpbx.setLayout(self.axis_settings_layout) self.chart_settings_layout.addWidget(self.graph_drawing_settings_grpbx) self.chart_settings_layout.addWidget(self.axis_settings_grpbx) self.chart_settings_layout.addWidget(self.reset_chart_settings_btn) self.chart_sync_mode_async_radio.toggled.emit(True) self.update_datetime_timer.start(1000) def eventFilter(self, obj, event): """ Handle key and mouse events for any applicable widget. Parameters ---------- obj : QWidget The current widget that accepts the event event : QEvent The key or mouse event to handle Returns ------- True if the event was handled successfully; False otherwise """ if obj == self.pv_name_line_edt and event.type() == QEvent.KeyPress: if event.key() == Qt.Key_Enter or event.key() == Qt.Key_Return: self.add_curve() return True elif obj == self.chart_limit_time_span_activate_btn and event.type( ) == QEvent.KeyPress: if event.key() == Qt.Key_Enter or event.key() == Qt.Key_Return: self.handle_chart_limit_time_span_activate_btn_clicked() return True elif obj == self.chart_ring_buffer_size_edt: if event.type() == QEvent.KeyPress and (event.key() == Qt.Key_Enter or event.key() == Qt.Key_Return) or \ event.type() == QEvent.FocusOut: try: buffer_size = int(self.chart_ring_buffer_size_edt.text()) if buffer_size < MINIMUM_BUFFER_SIZE: self.chart_ring_buffer_size_edt.setText( str(MINIMUM_BUFFER_SIZE)) except ValueError: display_message_box(QMessageBox.Critical, "Invalid Values", "Only integer values are accepted.") return True return super(PyDMChartingDisplay, self).eventFilter(obj, event) def add_curve(self): """ Add a new curve to the chart. """ pv_name = self._get_full_pv_name(self.pv_name_line_edt.text()) color = random_color() for k, v in self.channel_map.items(): if color == v.color: color = random_color() self.add_y_channel(pv_name=pv_name, curve_name=pv_name, color=color) def handle_enable_crosshair_checkbox_clicked(self, is_checked): self.chart.enableCrosshair(is_checked) self.cross_hair_coord_lbl.setVisible(is_checked) def add_y_channel(self, pv_name, curve_name, color, line_style=Qt.SolidLine, line_width=2, symbol=None, symbol_size=None): if pv_name in self.channel_map: logger.error("'{0}' has already been added.".format(pv_name)) return curve = self.chart.addYChannel(y_channel=pv_name, name=curve_name, color=color, lineStyle=line_style, lineWidth=line_width, symbol=symbol, symbolSize=symbol_size) self.channel_map[pv_name] = curve self.generate_pv_controls(pv_name, color) self.enable_chart_control_buttons() self.app.establish_widget_connections(self) def generate_pv_controls(self, pv_name, curve_color): """ Generate a set of widgets to manage the appearance of a curve. The set of widgets includes: 1. A checkbox which shows the curve on the chart if checked, and hide the curve if not checked 2. Two buttons -- Modify... and Remove. Modify... will bring up the Curve Settings dialog. Remove will delete the curve from the chart This set of widgets will be hidden initially, until the first curve is plotted. Parameters ---------- pv_name: str The name of the PV the current curve is being plotted for curve_color : QColor The color of the curve to paint for the checkbox label to help the user track the curve to the checkbox """ checkbox = QCheckBox() checkbox.setObjectName(pv_name) palette = checkbox.palette() palette.setColor(QPalette.Active, QPalette.WindowText, curve_color) checkbox.setPalette(palette) display_name = pv_name.split("://")[1] if len(display_name) > MAX_DISPLAY_PV_NAME_LENGTH: # Only display max allowed number of characters of the PV Name display_name = display_name[:int(MAX_DISPLAY_PV_NAME_LENGTH / 2) - 1] + "..." + \ display_name[-int(MAX_DISPLAY_PV_NAME_LENGTH / 2) + 2:] checkbox.setText(display_name) data_text = QLabel() data_text.setObjectName(pv_name) data_text.setPalette(palette) checkbox.setChecked(True) checkbox.clicked.connect( partial(self.handle_curve_chkbox_toggled, checkbox)) curve_btn_layout = QHBoxLayout() modify_curve_btn = QPushButton("Modify...") modify_curve_btn.setObjectName(pv_name) modify_curve_btn.setMaximumWidth(100) modify_curve_btn.clicked.connect( partial(self.display_curve_settings_dialog, pv_name)) focus_curve_btn = QPushButton("Focus") focus_curve_btn.setObjectName(pv_name) focus_curve_btn.setMaximumWidth(100) focus_curve_btn.clicked.connect(partial(self.focus_curve, pv_name)) annotate_curve_btn = QPushButton("Annotate...") annotate_curve_btn.setObjectName(pv_name) annotate_curve_btn.setMaximumWidth(100) annotate_curve_btn.clicked.connect( partial(self.annotate_curve, pv_name)) remove_curve_btn = QPushButton("Remove") remove_curve_btn.setObjectName(pv_name) remove_curve_btn.setMaximumWidth(100) remove_curve_btn.clicked.connect(partial(self.remove_curve, pv_name)) curve_btn_layout.addWidget(modify_curve_btn) curve_btn_layout.addWidget(focus_curve_btn) curve_btn_layout.addWidget(annotate_curve_btn) curve_btn_layout.addWidget(remove_curve_btn) individual_curve_layout = QVBoxLayout() individual_curve_layout.addWidget(checkbox) individual_curve_layout.addWidget(data_text) individual_curve_layout.addLayout(curve_btn_layout) size_policy = QSizePolicy() size_policy.setVerticalPolicy(QSizePolicy.Fixed) individual_curve_grpbx = QGroupBox() individual_curve_grpbx.setSizePolicy(size_policy) individual_curve_grpbx.setObjectName(pv_name) individual_curve_grpbx.setLayout(individual_curve_layout) self.curve_settings_layout.addWidget(individual_curve_grpbx) self.tab_panel.show() def handle_curve_chkbox_toggled(self, checkbox): """ Handle a checkbox's checked and unchecked events. If a checkbox is checked, find the curve from the channel map. If found, re-draw the curve with its previous appearance settings. If a checkbox is unchecked, remove the curve from the chart, but keep the cached data in the channel map. Parameters ---------- checkbox : QCheckBox The current checkbox being toggled """ pv_name = self._get_full_pv_name(checkbox.text()) if checkbox.isChecked(): curve = self.channel_map.get(pv_name, None) if curve: self.chart.addLegendItem(curve, pv_name, self.show_legend_chk.isChecked()) curve.show() else: curve = self.chart.findCurve(pv_name) if curve: curve.hide() self.chart.removeLegendItem(pv_name) def display_curve_settings_dialog(self, pv_name): """ Bring up the Curve Settings dialog to modify the appearance of a curve. Parameters ---------- pv_name : str The name of the PV the curve is being plotted for """ self.curve_settings_disp = CurveSettingsDisplay(self, pv_name) self.curve_settings_disp.show() def focus_curve(self, pv_name): curve = self.chart.findCurve(pv_name) if curve: self.chart.plotItem.setYRange(curve.minY, curve.maxY, padding=0) def annotate_curve(self, pv_name): curve = self.chart.findCurve(pv_name) if curve: annot = TextItem( html= '<div style="text-align: center"><span style="color: #FFF;">This is the' '</span><br><span style="color: #FF0; font-size: 16pt;">PEAK</span></div>', anchor=(-0.3, 0.5), border='w', fill=(0, 0, 255, 100)) annot = TextItem("test", anchor=(-0.3, 0.5)) self.chart.annotateCurve(curve, annot) def remove_curve(self, pv_name): """ Remove a curve from the chart permanently. This will also clear the channel map cache from retaining the removed curve's appearance settings. Parameters ---------- pv_name : str The name of the PV the curve is being plotted for """ curve = self.chart.findCurve(pv_name) if curve: self.chart.removeYChannel(curve) del self.channel_map[pv_name] self.chart.removeLegendItem(pv_name) widgets = self.findChildren( (QCheckBox, QLabel, QPushButton, QGroupBox), pv_name) for w in widgets: w.deleteLater() if len(self.chart.getCurves()) < 1: self.enable_chart_control_buttons(False) self.show_legend_chk.setChecked(False) def handle_title_text_changed(self, new_text): self.chart.setPlotTitle(new_text) def handle_change_axis_settings_clicked(self): self.axis_settings_disp = AxisSettingsDisplay(self) self.axis_settings_disp.show() def handle_limit_time_span_checkbox_clicked(self, is_checked): self.chart_limit_time_span_lbl.setVisible(is_checked) self.chart_limit_time_span_hours_line_edt.setVisible(is_checked) self.chart_limit_time_span_minutes_line_edt.setVisible(is_checked) self.chart_limit_time_span_seconds_line_edt.setVisible(is_checked) self.chart_limit_time_span_activate_btn.setVisible(is_checked) self.chart_ring_buffer_size_lbl.setDisabled(is_checked) self.chart_ring_buffer_size_edt.setDisabled(is_checked) if not is_checked: self.chart_limit_time_span_chk.setText(self.limit_time_plan_text) def handle_time_span_edt_text_changed(self, new_text): try: self.time_span_limit_hours = int( self.chart_limit_time_span_hours_line_edt.text()) self.time_span_limit_minutes = int( self.chart_limit_time_span_minutes_line_edt.text()) self.time_span_limit_seconds = int( self.chart_limit_time_span_seconds_line_edt.text()) except ValueError as e: self.time_span_limit_hours = None self.time_span_limit_minutes = None self.time_span_limit_seconds = None if self.time_span_limit_hours is not None and self.time_span_limit_minutes is not None and \ self.time_span_limit_seconds is not None: self.chart_limit_time_span_activate_btn.setEnabled(True) else: self.chart_limit_time_span_activate_btn.setEnabled(False) def handle_chart_limit_time_span_activate_btn_clicked(self): if self.time_span_limit_hours is None or self.time_span_limit_minutes is None or \ self.time_span_limit_seconds is None: display_message_box( QMessageBox.Critical, "Invalid Values", "Hours, minutes, and seconds expect only integer values.") else: timeout_milliseconds = (self.time_span_limit_hours * 3600 + self.time_span_limit_minutes * 60 + self.time_span_limit_seconds) * 1000 self.chart.setTimeSpan(timeout_milliseconds / 1000.0) self.chart_ring_buffer_size_edt.setText( str(self.chart.getBufferSize())) def handle_buffer_size_changed(self, new_buffer_size): try: if new_buffer_size and int(new_buffer_size) > MINIMUM_BUFFER_SIZE: self.chart.setBufferSize(new_buffer_size) except ValueError: display_message_box(QMessageBox.Critical, "Invalid Values", "Only integer values are accepted.") def handle_redraw_rate_changed(self, new_redraw_rate): self.chart.maxRedrawRate = new_redraw_rate def handle_data_sampling_rate_changed(self, new_data_sampling_rate): # The chart expects the value in milliseconds sampling_rate_seconds = 1 / new_data_sampling_rate self.chart.setUpdateInterval(sampling_rate_seconds) def handle_background_color_button_clicked(self): selected_color = QColorDialog.getColor() self.chart.setBackgroundColor(selected_color) self.background_color_btn.setStyleSheet("background-color: " + selected_color.name()) def handle_axis_color_button_clicked(self): selected_color = QColorDialog.getColor() self.chart.setAxisColor(selected_color) self.axis_color_btn.setStyleSheet("background-color: " + selected_color.name()) def handle_grid_opacity_slider_mouse_release(self): self.grid_alpha = float(self.grid_opacity_slr.value()) / 10.0 self.chart.setShowXGrid(self.show_x_grid_chk.isChecked(), self.grid_alpha) self.chart.setShowYGrid(self.show_y_grid_chk.isChecked(), self.grid_alpha) def handle_show_x_grid_checkbox_clicked(self, is_checked): self.chart.setShowXGrid(is_checked, self.grid_alpha) self.axis_color_lbl.setEnabled(is_checked or self.show_y_grid_chk.isChecked()) self.axis_color_btn.setEnabled(is_checked or self.show_y_grid_chk.isChecked()) self.grid_opacity_lbl.setEnabled(is_checked or self.show_y_grid_chk.isChecked()) self.grid_opacity_slr.setEnabled(is_checked or self.show_y_grid_chk.isChecked()) def handle_show_y_grid_checkbox_clicked(self, is_checked): self.chart.setShowYGrid(is_checked, self.grid_alpha) self.axis_color_lbl.setEnabled(is_checked or self.show_x_grid_chk.isChecked()) self.axis_color_btn.setEnabled(is_checked or self.show_x_grid_chk.isChecked()) self.grid_opacity_lbl.setEnabled(is_checked or self.show_x_grid_chk.isChecked()) self.grid_opacity_slr.setEnabled(is_checked or self.show_x_grid_chk.isChecked()) def handle_show_legend_checkbox_clicked(self, is_checked): self.chart.setShowLegend(is_checked) def handle_export_data_btn_clicked(self): self.chart_data_export_disp = ChartDataExportDisplay(self) self.chart_data_export_disp.show() def handle_import_data_btn_clicked(self): open_file_info = QFileDialog.getOpenFileName(self, caption="Save File", filter="*." + IMPORT_FILE_FORMAT) open_file_name = open_file_info[0] if open_file_name: importer = SettingsImporter(self) importer.import_settings(open_file_name) def handle_sync_mode_radio_toggle(self, radio_btn): if radio_btn.isChecked(): if radio_btn.text() == "Synchronous": self.data_sampling_mode = SYNC_DATA_SAMPLING self.chart_data_sampling_rate_lbl.hide() self.chart_data_async_sampling_rate_spin.hide() self.chart.resetTimeSpan() self.chart_limit_time_span_chk.setChecked(False) self.chart_limit_time_span_chk.clicked.emit(False) self.chart_limit_time_span_chk.hide() self.graph_drawing_settings_grpbx.setFixedHeight(180) self.chart.setUpdatesAsynchronously(False) elif radio_btn.text() == "Asynchronous": self.data_sampling_mode = ASYNC_DATA_SAMPLING self.chart_data_sampling_rate_lbl.show() self.chart_data_async_sampling_rate_spin.show() self.chart_limit_time_span_chk.show() self.graph_drawing_settings_grpbx.setFixedHeight(270) self.chart.setUpdatesAsynchronously(True) self.app.establish_widget_connections(self) def handle_auto_scale_btn_clicked(self): self.chart.resetAutoRangeX() self.chart.resetAutoRangeY() def handle_view_all_button_clicked(self): self.chart.getViewBox().autoRange() def handle_pause_chart_btn_clicked(self): if self.chart.pausePlotting(): self.pause_chart_btn.setText(self.pause_chart_text) else: self.pause_chart_btn.setText(self.resume_chart_text) def handle_reset_chart_btn_clicked(self): self.chart.getViewBox().setXRange(DEFAULT_X_MIN, 0) self.chart.resetAutoRangeY() @Slot() def handle_reset_chart_settings_btn_clicked(self): self.chart_ring_buffer_size_edt.setText(str(DEFAULT_BUFFER_SIZE)) self.chart_redraw_rate_spin.setValue(DEFAULT_REDRAW_RATE_HZ) self.chart_data_async_sampling_rate_spin.setValue( DEFAULT_DATA_SAMPLING_RATE_HZ) self.chart_data_sampling_rate_lbl.hide() self.chart_data_async_sampling_rate_spin.hide() self.chart_sync_mode_async_radio.setChecked(True) self.chart_sync_mode_async_radio.toggled.emit(True) self.chart_limit_time_span_chk.setChecked(False) self.chart_limit_time_span_chk.setText(self.limit_time_plan_text) self.chart_limit_time_span_chk.clicked.emit(False) self.chart.setUpdatesAsynchronously(True) self.chart.resetTimeSpan() self.chart.resetUpdateInterval() self.chart.setBufferSize(DEFAULT_BUFFER_SIZE) self.chart.setBackgroundColor(DEFAULT_CHART_BACKGROUND_COLOR) self.background_color_btn.setStyleSheet( "background-color: " + DEFAULT_CHART_BACKGROUND_COLOR.name()) self.chart.setAxisColor(DEFAULT_CHART_AXIS_COLOR) self.axis_color_btn.setStyleSheet("background-color: " + DEFAULT_CHART_AXIS_COLOR.name()) self.grid_opacity_slr.setValue(5) self.show_x_grid_chk.setChecked(False) self.show_x_grid_chk.clicked.emit(False) self.show_y_grid_chk.setChecked(False) self.show_y_grid_chk.clicked.emit(False) self.show_legend_chk.setChecked(False) self.chart.setShowXGrid(False) self.chart.setShowYGrid(False) self.chart.setShowLegend(False) def enable_chart_control_buttons(self, enabled=True): self.auto_scale_btn.setEnabled(enabled) self.view_all_btn.setEnabled(enabled) self.reset_chart_btn.setEnabled(enabled) self.pause_chart_btn.setText(self.pause_chart_text) self.pause_chart_btn.setEnabled(enabled) self.export_data_btn.setEnabled(enabled) def _get_full_pv_name(self, pv_name): """ Append the protocol to the PV Name. Parameters ---------- pv_name : str The name of the PV the curve is being plotted for """ if pv_name and "://" not in pv_name: pv_name = ''.join([self.pv_protocol_cmb.currentText(), pv_name]) return pv_name def handle_update_datetime_timer_timeout(self): current_label = self.chart.getBottomAxisLabel() new_label = "Current Time: " + PyDMChartingDisplay.get_current_datetime( ) if X_AXIS_LABEL_SEPARATOR in current_label: current_label = current_label[current_label. find(X_AXIS_LABEL_SEPARATOR) + len(X_AXIS_LABEL_SEPARATOR):] new_label += X_AXIS_LABEL_SEPARATOR + current_label self.chart.setLabel("bottom", text=new_label) def update_curve_data(self, curve): """ Determine if the PV is active. If not, disable the related PV controls. If the PV is active, update the PV controls' states. Parameters ---------- curve : PlotItem A PlotItem, i.e. a plot, to draw on the chart. """ pv_name = curve.name() max_x = self.chart.getViewBox().viewRange()[1][0] max_y = self.chart.getViewBox().viewRange()[1][1] current_y = curve.data_buffer[1, -1] widgets = self.findChildren((QCheckBox, QLabel, QPushButton), pv_name) for w in widgets: if np.isnan(current_y): if isinstance(w, QCheckBox): w.setChecked(False) else: if isinstance(w, QCheckBox) and not w.isEnabled(): w.setChecked(True) if isinstance(w, QLabel): w.clear() w.setText( "(yMin = {0:.3f}, yMax = {1:.3f}) y = {2:.3f}".format( max_x, max_y, current_y)) w.show() w.setEnabled(not np.isnan(current_y)) if isinstance(w, QPushButton) and w.text() == "Remove": # Enable the Remove button to make removing inactive PVs possible anytime w.setEnabled(True) def show_mouse_coordinates(self, x, y): self.cross_hair_coord_lbl.clear() self.cross_hair_coord_lbl.setText("x = {0:.3f}, y = {1:.3f}".format( x, y)) @staticmethod def get_current_datetime(): current_date = datetime.datetime.now().strftime("%b %d, %Y") current_time = datetime.datetime.now().strftime("%H:%M:%S") current_datetime = current_time + ' (' + current_date + ')' return current_datetime @property def gridAlpha(self): return self.grid_alpha
class SliceViewerDataView(QWidget): """The view for the data portion of the sliceviewer""" def __init__(self, presenter: IDataViewSubscriber, dims_info, can_normalise, parent=None, conf=None): super().__init__(parent) self.presenter = presenter self.image = None self.line_plots_active = False self.can_normalise = can_normalise self.nonortho_transform = None self.conf = conf self._line_plots = None self._image_info_tracker = None self._region_selection_on = False self._orig_lims = None # Dimension widget self.dimensions_layout = QGridLayout() self.dimensions = DimensionWidget(dims_info, parent=self) self.dimensions.dimensionsChanged.connect(self.presenter.dimensions_changed) self.dimensions.valueChanged.connect(self.presenter.slicepoint_changed) self.dimensions_layout.addWidget(self.dimensions, 1, 0, 1, 1) self.colorbar_layout = QVBoxLayout() self.colorbar_layout.setContentsMargins(0, 0, 0, 0) self.colorbar_layout.setSpacing(0) self.image_info_widget = ImageInfoWidget(self) self.image_info_widget.setToolTip("Information about the selected pixel") self.track_cursor = QCheckBox("Track Cursor", self) self.track_cursor.setToolTip( "Update the image readout table when the cursor is over the plot. " "If unticked the table will update only when the plot is clicked") self.dimensions_layout.setHorizontalSpacing(10) self.dimensions_layout.addWidget(self.track_cursor, 0, 1, Qt.AlignRight) self.dimensions_layout.addWidget(self.image_info_widget, 1, 1) self.track_cursor.setChecked(True) self.track_cursor.stateChanged.connect(self.on_track_cursor_state_change) # normalization options if can_normalise: self.norm_label = QLabel("Normalization") self.colorbar_layout.addWidget(self.norm_label) self.norm_opts = QComboBox() self.norm_opts.addItems(["None", "By bin width"]) self.norm_opts.setToolTip("Normalization options") self.colorbar_layout.addWidget(self.norm_opts) # MPL figure + colorbar self.fig = Figure() self.ax = None self.image = None self._grid_on = False self.fig.set_facecolor(self.palette().window().color().getRgbF()) self.canvas = SliceViewerCanvas(self.fig) self.canvas.mpl_connect('button_release_event', self.mouse_release) self.canvas.mpl_connect('button_press_event', self.presenter.canvas_clicked) self.colorbar_label = QLabel("Colormap") self.colorbar_layout.addWidget(self.colorbar_label) norm_scale = self.get_default_scale_norm() self.colorbar = ColorbarWidget(self, norm_scale) self.colorbar.cmap.setToolTip("Colormap options") self.colorbar.crev.setToolTip("Reverse colormap") self.colorbar.norm.setToolTip("Colormap normalisation options") self.colorbar.powerscale.setToolTip("Power colormap scale") self.colorbar.cmax.setToolTip("Colormap maximum limit") self.colorbar.cmin.setToolTip("Colormap minimum limit") self.colorbar.autoscale.setToolTip("Automatically changes colormap limits when zooming on the plot") self.colorbar_layout.addWidget(self.colorbar) self.colorbar.colorbarChanged.connect(self.update_data_clim) self.colorbar.scaleNormChanged.connect(self.scale_norm_changed) # make width larger to fit image readout table self.colorbar.setMaximumWidth(200) # MPL toolbar self.toolbar_layout = QHBoxLayout() self.mpl_toolbar = SliceViewerNavigationToolbar(self.canvas, self, False) self.mpl_toolbar.gridClicked.connect(self.toggle_grid) self.mpl_toolbar.linePlotsClicked.connect(self.on_line_plots_toggle) self.mpl_toolbar.regionSelectionClicked.connect(self.on_region_selection_toggle) self.mpl_toolbar.homeClicked.connect(self.on_home_clicked) self.mpl_toolbar.nonOrthogonalClicked.connect(self.on_non_orthogonal_axes_toggle) self.mpl_toolbar.zoomPanClicked.connect(self.presenter.zoom_pan_clicked) self.mpl_toolbar.zoomPanFinished.connect(self.on_data_limits_changed) self.toolbar_layout.addWidget(self.mpl_toolbar) # Status bar self.status_bar = QStatusBar(parent=self) self.status_bar.setStyleSheet('QStatusBar::item {border: None;}') # Hide spacers between button and label self.status_bar_label = QLabel() self.help_button = QToolButton() self.help_button.setText("?") self.status_bar.addWidget(self.help_button) self.status_bar.addWidget(self.status_bar_label) # layout layout = QGridLayout(self) layout.setSpacing(1) layout.addLayout(self.dimensions_layout, 0, 0, 1, 2) layout.addLayout(self.toolbar_layout, 1, 0, 1, 1) layout.addLayout(self.colorbar_layout, 1, 1, 3, 1) layout.addWidget(self.canvas, 2, 0, 1, 1) layout.addWidget(self.status_bar, 3, 0, 1, 1) layout.setRowStretch(2, 1) @property def grid_on(self): return self._grid_on @property def line_plotter(self): return self._line_plots @property def nonorthogonal_mode(self): return self.nonortho_transform is not None def create_axes_orthogonal(self, redraw_on_zoom=False): """Create a standard set of orthogonal axes :param redraw_on_zoom: If True then when scroll zooming the canvas is redrawn immediately """ self.clear_figure() self.nonortho_transform = None self.ax = self.fig.add_subplot(111, projection='mantid') self.enable_zoom_on_mouse_scroll(redraw_on_zoom) if self.grid_on: self.ax.grid(self.grid_on) if self.line_plots_active: self.add_line_plots() self.plot_MDH = self.plot_MDH_orthogonal self.canvas.draw_idle() def create_axes_nonorthogonal(self, transform): self.clear_figure() self.set_nonorthogonal_transform(transform) self.ax = CurveLinearSubPlot(self.fig, 1, 1, 1, grid_helper=GridHelperCurveLinear( (transform.tr, transform.inv_tr))) # don't redraw on zoom as the data is rebinned and has to be redrawn again anyway self.enable_zoom_on_mouse_scroll(redraw=False) self.set_grid_on() self.fig.add_subplot(self.ax) self.plot_MDH = self.plot_MDH_nonorthogonal self.canvas.draw_idle() def enable_zoom_on_mouse_scroll(self, redraw): """Enable zoom on scroll the mouse wheel for the created axes :param redraw: Pass through to redraw option in enable_zoom_on_scroll """ self.canvas.enable_zoom_on_scroll(self.ax, redraw=redraw, toolbar=self.mpl_toolbar, callback=self.on_data_limits_changed) def add_line_plots(self, toolcls, exporter): """Assuming line plots are currently disabled, enable them on the current figure The image axes must have been created first. :param toolcls: Use this class to handle creating the plots :param exporter: Object defining methods to export cuts/roi """ if self.line_plots_active: return self.line_plots_active = True self._line_plots = toolcls(LinePlots(self.ax, self.colorbar), exporter) self.status_bar_label.setText(self._line_plots.status_message()) self.canvas.setFocus() self.mpl_toolbar.set_action_checked(ToolItemText.LINEPLOTS, True, trigger=False) def switch_line_plots_tool(self, toolcls, exporter): """Assuming line plots are currently enabled then switch the tool used to generate the plot curves. :param toolcls: Use this class to handle creating the plots """ if not self.line_plots_active: return # Keep the same set of line plots axes but swap the selection tool plotter = self._line_plots.plotter plotter.delete_line_plot_lines() self._line_plots.disconnect() self._line_plots = toolcls(plotter, exporter) self.status_bar_label.setText(self._line_plots.status_message()) self.canvas.setFocus() self.canvas.draw_idle() def remove_line_plots(self): """Assuming line plots are currently enabled, remove them from the current figure """ if not self.line_plots_active: return self._line_plots.plotter.close() self.status_bar_label.clear() self._line_plots = None self.line_plots_active = False def plot_MDH_orthogonal(self, ws, **kwargs): """ clears the plot and creates a new one using a MDHistoWorkspace """ self.clear_image() self.image = self.ax.imshow(ws, origin='lower', aspect='auto', transpose=self.dimensions.transpose, norm=self.colorbar.get_norm(), **kwargs) # ensure the axes data limits are updated to match the # image. For example if the axes were zoomed and the # swap dimensions was clicked we need to restore the # appropriate extents to see the image in the correct place extent = self.image.get_extent() self.ax.set_xlim(extent[0], extent[1]) self.ax.set_ylim(extent[2], extent[3]) # Set the original data limits which get passed to the ImageInfoWidget so that # the mouse projection to data space is correct for MDH workspaces when zoomed/changing slices self._orig_lims = self.get_axes_limits() self.on_track_cursor_state_change(self.track_cursor_checked()) self.draw_plot() def plot_MDH_nonorthogonal(self, ws, **kwargs): self.clear_image() self.image = pcolormesh_nonorthogonal(self.ax, ws, self.nonortho_transform.tr, transpose=self.dimensions.transpose, norm=self.colorbar.get_norm(), **kwargs) self.on_track_cursor_state_change(self.track_cursor_checked()) # swapping dimensions in nonorthogonal mode currently resets back to the # full data limits as the whole axes has been recreated so we don't have # access to the original limits # pcolormesh clears any grid that was previously visible if self.grid_on: self.ax.grid(self.grid_on) self.draw_plot() def plot_matrix(self, ws, **kwargs): """ clears the plot and creates a new one using a MatrixWorkspace keeping the axes limits that have already been set """ # ensure view is correct if zoomed in while swapping dimensions # compute required extent and just have resampling imshow deal with it old_extent = None if self.image is not None: old_extent = self.image.get_extent() if self.image.transpose != self.dimensions.transpose: e1, e2, e3, e4 = old_extent old_extent = e3, e4, e1, e2 self.clear_image() self.image = self.ax.imshow(ws, origin='lower', aspect='auto', interpolation='none', transpose=self.dimensions.transpose, norm=self.colorbar.get_norm(), extent=old_extent, **kwargs) self.on_track_cursor_state_change(self.track_cursor_checked()) self.draw_plot() def clear_image(self): """Removes any image from the axes""" if self.image is not None: if self.line_plots_active: self._line_plots.plotter.delete_line_plot_lines() self.image_info_widget.cursorAt(DBLMAX, DBLMAX, DBLMAX) if hasattr(self.ax, "remove_artists_if"): self.ax.remove_artists_if(lambda art: art == self.image) else: self.image.remove() self.image = None def clear_figure(self): """Removes everything from the figure""" if self.line_plots_active: self._line_plots.plotter.close() self.line_plots_active = False self.image = None self.canvas.disable_zoom_on_scroll() self.fig.clf() self.ax = None def draw_plot(self): self.ax.set_title('') self.canvas.draw() if self.image: self.colorbar.set_mappable(self.image) self.colorbar.update_clim() self.mpl_toolbar.update() # clear nav stack if self.line_plots_active: self._line_plots.plotter.delete_line_plot_lines() self._line_plots.plotter.update_line_plot_labels() def export_region(self, limits, cut): """ React to a region selection that should be exported :param limits: 2-tuple of ((left, right), (bottom, top)) :param cut: A str denoting which cuts to export. """ self.presenter.export_region(limits, cut) def update_plot_data(self, data): """ This just updates the plot data without creating a new plot. The extents can change if the data has been rebinned. """ if self.nonortho_transform: self.image.set_array(data.T.ravel()) else: self.image.set_data(data.T) self.colorbar.update_clim() def track_cursor_checked(self): return self.track_cursor.isChecked() if self.track_cursor else False def on_track_cursor_state_change(self, state): """ Called to notify the current state of the track cursor box """ if self._image_info_tracker is not None: self._image_info_tracker.disconnect() if self._line_plots is not None and not self._region_selection_on: self._line_plots.disconnect() self._image_info_tracker = ImageInfoTracker(image=self.image, transform=self.nonortho_transform, do_transform=self.nonorthogonal_mode, widget=self.image_info_widget, cursor_transform=self._orig_lims) if state: self._image_info_tracker.connect() if self._line_plots and not self._region_selection_on: self._line_plots.connect() else: self._image_info_tracker.disconnect() if self._line_plots and not self._region_selection_on: self._line_plots.disconnect() def on_home_clicked(self): """Reset the view to encompass all of the data""" self.presenter.show_all_data_clicked() def on_line_plots_toggle(self, state): """Switch state of the line plots""" self.presenter.line_plots(state) def on_region_selection_toggle(self, state): """Switch state of the region selection""" self.presenter.region_selection(state) self._region_selection_on = state # If state is off and track cursor is on, make sure line plots are re-connected to move cursor if not state and self.track_cursor_checked(): if self._line_plots: self._line_plots.connect() def on_non_orthogonal_axes_toggle(self, state): """ Switch state of the non-orthognal axes on/off """ self.presenter.nonorthogonal_axes(state) def on_data_limits_changed(self): """ React to when the data limits have changed """ self.presenter.data_limits_changed() def deactivate_and_disable_tool(self, tool_text): """Deactivate a tool as if the control had been pressed and disable the functionality""" self.deactivate_tool(tool_text) self.disable_tool_button(tool_text) def activate_tool(self, tool_text): """Activate a given tool as if the control had been pressed""" self.mpl_toolbar.set_action_checked(tool_text, True) def deactivate_tool(self, tool_text): """Deactivate a given tool as if the tool button had been pressed""" self.mpl_toolbar.set_action_checked(tool_text, False) def enable_tool_button(self, tool_text): """Set a given tool button enabled so it can be interacted with""" self.mpl_toolbar.set_action_enabled(tool_text, True) def disable_tool_button(self, tool_text): """Set a given tool button disabled so it cannot be interacted with""" self.mpl_toolbar.set_action_enabled(tool_text, False) def get_axes_limits(self): """ Return the limits on the image axes transformed into the nonorthogonal frame if appropriate """ if self.image is None: return None else: xlim, ylim = self.ax.get_xlim(), self.ax.get_ylim() if self.nonorthogonal_mode: inv_tr = self.nonortho_transform.inv_tr # viewing axis y not aligned with plot axis xmin_p, ymax_p = inv_tr(xlim[0], ylim[1]) xmax_p, ymin_p = inv_tr(xlim[1], ylim[0]) xlim, ylim = (xmin_p, xmax_p), (ymin_p, ymax_p) return xlim, ylim def get_full_extent(self): """ Return the full extent of image - only applicable for plots of matrix workspaces """ if self.image and isinstance(self.image, samplingimage.SamplingImage): return self.image.get_full_extent() else: return None def set_axes_limits(self, xlim, ylim): """ Set the view limits on the image axes to the given extents. Assume the limits are in the orthogonal frame. :param xlim: 2-tuple of (xmin, xmax) :param ylim: 2-tuple of (ymin, ymax) """ self.ax.set_xlim(xlim) self.ax.set_ylim(ylim) def set_grid_on(self): """ If not visible sets the grid visibility """ if not self._grid_on: self._grid_on = True self.mpl_toolbar.set_action_checked(ToolItemText.GRID, state=self._grid_on) def set_nonorthogonal_transform(self, transform): """ Set the transform for nonorthogonal axes mode :param transform: An object with a tr method to transform from nonorthognal coordinates to display coordinates """ self.nonortho_transform = transform def show_temporary_status_message(self, msg, timeout_ms): """ Show a message in the status bar that disappears after a set period :param msg: A str message to display :param timeout_ms: Timeout in milliseconds to display the message for """ self.status_bar.showMessage(msg, timeout_ms) def toggle_grid(self, state): """ Toggle the visibility of the grid on the axes """ self._grid_on = state self.ax.grid(self._grid_on) self.canvas.draw_idle() def mouse_release(self, event): if event.inaxes != self.ax: return self.canvas.setFocus() if event.button == 1: self._image_info_tracker.on_cursor_at(event.xdata, event.ydata) if self.line_plots_active and not self._region_selection_on: self._line_plots.on_cursor_at(event.xdata, event.ydata) if event.button == 3: self.on_home_clicked() def deactivate_zoom_pan(self): self.deactivate_tool(ToolItemText.PAN) self.deactivate_tool(ToolItemText.ZOOM) def update_data_clim(self): self.image.set_clim(self.colorbar.colorbar.mappable.get_clim()) if self.line_plots_active: self._line_plots.plotter.update_line_plot_limits() self.canvas.draw_idle() def set_normalization(self, ws, **kwargs): normalize_by_bin_width, _ = get_normalize_by_bin_width(ws, self.ax, **kwargs) is_normalized = normalize_by_bin_width or ws.isDistribution() self.presenter.normalization = is_normalized if is_normalized: self.norm_opts.setCurrentIndex(1) else: self.norm_opts.setCurrentIndex(0) def get_default_scale_norm(self): scale = 'Linear' if self.conf is None: return scale if self.conf.has(SCALENORM): scale = self.conf.get(SCALENORM) if scale == 'Power' and self.conf.has(POWERSCALE): exponent = self.conf.get(POWERSCALE) scale = (scale, exponent) scale = "SymmetricLog10" if scale == 'Log' else scale return scale def scale_norm_changed(self): if self.conf is None: return scale = self.colorbar.norm.currentText() self.conf.set(SCALENORM, scale) if scale == 'Power': exponent = self.colorbar.powerscale_value self.conf.set(POWERSCALE, exponent)
class PyDMEmbeddedDisplay(QFrame, PyDMPrimitiveWidget): """ A QFrame capable of rendering a PyDM Display Parameters ---------- parent : QWidget The parent widget for the Label """ def __init__(self, parent=None): QFrame.__init__(self, parent) PyDMPrimitiveWidget.__init__(self) self.app = QApplication.instance() self._filename = None self._macros = None self._embedded_widget = None self._disconnect_when_hidden = True self._is_connected = False self._only_load_when_shown = True self._needs_load = True self.base_path = "" self.base_macros = {} if is_pydm_app(): self.base_path = self.app.directory_stack[-1] self.base_macros = self.app.macro_stack[-1] self.layout = QVBoxLayout(self) self.err_label = QLabel(self) self.err_label.setAlignment(Qt.AlignHCenter) self.layout.addWidget(self.err_label) self.layout.setContentsMargins(0, 0, 0, 0) self.err_label.hide() if not is_pydm_app(): self.setFrameShape(QFrame.Box) else: self.setFrameShape(QFrame.NoFrame) def minimumSizeHint(self): """ This property holds the recommended minimum size for the widget. Returns ------- QSize """ # This is totally arbitrary, I just want *some* visible nonzero size return QSize(100, 100) @Property(str) def macros(self): """ JSON-formatted string containing macro variables to pass to the embedded file. Returns ------- str """ if self._macros is None: return "" return self._macros @macros.setter def macros(self, new_macros): """ JSON-formatted string containing macro variables to pass to the embedded file. .. warning:: If the macros property is not defined before the filename property, The widget will not have any macros defined when it loads the embedded file. This behavior will be fixed soon. Parameters ---------- new_macros : str """ new_macros = str(new_macros) if new_macros != self._macros: self._macros = new_macros self._needs_load = True self.load_if_needed() @Property(str) def filename(self): """ Filename of the display to embed. Returns ------- str """ if self._filename is None: return "" return self._filename @filename.setter def filename(self, filename): """ Filename of the display to embed. Parameters ---------- filename : str """ filename = str(filename) if filename != self._filename: self._filename = filename self._needs_load = True self.load_if_needed() def parsed_macros(self): """ Dictionary containing the key value pair for each macro specified. Returns -------- dict """ m = macro.find_base_macros(self) m.update(macro.parse_macro_string(self.macros)) return m def load_if_needed(self): if (not self._only_load_when_shown ) or self.isVisible() or is_qt_designer(): self.embedded_widget = self.open_file() def open_file(self, force=False): """ Opens the widget specified in the widget's filename property. Returns ------- display : QWidget """ if (not force) and (not self._needs_load): return if not self.filename: return # Expand user (~ or ~user) and environment variables. fname = os.path.expanduser(os.path.expandvars(self.filename)) if self.base_path: full_fname = os.path.join(self.base_path, fname) else: full_fname = fname if not is_pydm_app(): (filename, extension) = os.path.splitext(full_fname) if extension == ".ui": loadfunc = load_ui_file elif extension == ".py": loadfunc = load_py_file try: w = loadfunc(full_fname, macros=self.parsed_macros()) self._needs_load = False self.clear_error_text() return w except Exception as e: logger.exception( "Exception while opening embedded display file.") self.display_error_text(e) return None # If you get this far, you are running inside a PyDMApplication, load # using that system. try: if os.path.isabs(full_fname) and os.path.exists(full_fname): w = self.app.open_file(full_fname, macros=self.parsed_macros()) else: w = self.app.open_relative(fname, self, macros=self.parsed_macros()) self._needs_load = False self.clear_error_text() return w except (ValueError, IOError) as e: self.display_error_text(e) def clear_error_text(self): self.err_label.clear() self.err_label.hide() def display_error_text(self, e): self.err_label.setText( "Could not open {filename}.\nError: {err}".format( filename=self._filename, err=e)) self.err_label.show() @property def embedded_widget(self): """ The embedded widget being displayed. Returns ------- QWidget """ return self._embedded_widget @embedded_widget.setter def embedded_widget(self, new_widget): """ Defines the embedded widget to display inside the QFrame Parameters ---------- new_widget : QWidget """ should_reconnect = False if new_widget is self._embedded_widget: return if self._embedded_widget is not None: self.layout.removeWidget(self._embedded_widget) self._embedded_widget.deleteLater() self._embedded_widget = None if new_widget is not None: self._embedded_widget = new_widget self._embedded_widget.setParent(self) self.layout.addWidget(self._embedded_widget) self.err_label.hide() self._embedded_widget.show() self._is_connected = True def connect(self): """ Establish the connection between the embedded widget and the channels associated with it. """ if self._is_connected or self.embedded_widget is None: return establish_widget_connections(self.embedded_widget) def disconnect(self): """ Disconnects the embedded widget from the channels associated with it. """ if not self._is_connected or self.embedded_widget is None: return close_widget_connections(self.embedded_widget) @Property(bool) def loadWhenShown(self): """ If True, only load and display the file once the PyDMEmbeddedDisplayWidget is visible on screen. This is very useful if you have many different PyDMEmbeddedWidgets in different tabs of a QTabBar or PyDMTabBar: only the tab that the user is looking at will be loaded, which can greatly speed up the launch time of a display. If this property is changed from 'True' to 'False', and the file has not been loaded yet, it will be loaded immediately. Returns ------- bool """ return self._only_load_when_shown @loadWhenShown.setter def loadWhenShown(self, val): self._only_load_when_shown = val self.load_if_needed() @Property(bool) def disconnectWhenHidden(self): """ Disconnect from PVs when this widget is not visible. Returns ------- bool """ return self._disconnect_when_hidden @disconnectWhenHidden.setter def disconnectWhenHidden(self, disconnect_when_hidden): """ Disconnect from PVs when this widget is not visible. Parameters ---------- disconnect_when_hidden : bool """ self._disconnect_when_hidden = disconnect_when_hidden def showEvent(self, e): """ Show events are sent to widgets that become visible on the screen. Parameters ---------- event : QShowEvent """ if self._only_load_when_shown: w = self.open_file() if w: self.embedded_widget = w if self.disconnectWhenHidden: self.connect() def hideEvent(self, e): """ Hide events are sent to widgets that become invisible on the screen. Parameters ---------- event : QHideEvent """ if self.disconnectWhenHidden: self.disconnect()
class TimeChartDisplay(Display): def __init__(self, parent=None, args=[], macros=None, show_pv_add_panel=True, config_file=None): """ Create all the widgets, including any child dialogs. Parameters ---------- parent : QWidget The parent widget of the charting display args : list The command parameters macros : str Macros to modify the UI parameters at runtime show_pv_add_panel : bool Whether or not to show the PV add panel on top of the graph """ super(TimeChartDisplay, self).__init__(parent=parent, args=args, macros=macros) self.legend_font = None self.channel_map = dict() self.setWindowTitle("TimeChart Tool") self.main_layout = QVBoxLayout() self.body_layout = QVBoxLayout() self.pv_add_panel = QFrame() self.pv_add_panel.setVisible(show_pv_add_panel) self.pv_add_panel.setMaximumHeight(50) self.pv_layout = QHBoxLayout() self.pv_name_line_edt = QLineEdit() self.pv_name_line_edt.setAcceptDrops(True) self.pv_name_line_edt.returnPressed.connect(self.add_curve) self.pv_protocol_cmb = QComboBox() self.pv_protocol_cmb.addItems(["ca://", "archive://"]) self.pv_protocol_cmb.setEnabled(False) self.pv_connect_push_btn = QPushButton("Connect") self.pv_connect_push_btn.clicked.connect(self.add_curve) self.tab_panel = QTabWidget() self.tab_panel.setMinimumWidth(350) self.tab_panel.setMaximumWidth(350) self.curve_settings_tab = QWidget() self.data_settings_tab = QWidget() self.chart_settings_tab = QWidget() self.charting_layout = QHBoxLayout() self.chart = PyDMTimePlot(plot_by_timestamps=False) self.chart.setDownsampling(ds=False, auto=False, mode=None) self.chart.plot_redrawn_signal.connect(self.update_curve_data) self.chart.setBufferSize(DEFAULT_BUFFER_SIZE) self.chart.setPlotTitle(DEFAULT_CHART_TITLE) self.splitter = QSplitter() self.curve_settings_layout = QVBoxLayout() self.curve_settings_layout.setAlignment(Qt.AlignTop) self.curve_settings_layout.setSizeConstraint(QLayout.SetMinAndMaxSize) self.curve_settings_layout.setSpacing(5) self.crosshair_settings_layout = QVBoxLayout() self.crosshair_settings_layout.setAlignment(Qt.AlignTop) self.crosshair_settings_layout.setSpacing(5) self.enable_crosshair_chk = QCheckBox("Crosshair") self.crosshair_coord_lbl = QLabel() self.crosshair_coord_lbl.setWordWrap(True) self.curve_settings_inner_frame = QFrame() self.curve_settings_inner_frame.setLayout(self.curve_settings_layout) self.curve_settings_scroll = QScrollArea() self.curve_settings_scroll.setVerticalScrollBarPolicy( Qt.ScrollBarAsNeeded) self.curve_settings_scroll.setHorizontalScrollBarPolicy( Qt.ScrollBarAlwaysOff) self.curve_settings_scroll.setWidget(self.curve_settings_inner_frame) self.curve_settings_scroll.setWidgetResizable(True) self.enable_crosshair_chk.setChecked(False) self.enable_crosshair_chk.clicked.connect( self.handle_enable_crosshair_checkbox_clicked) self.enable_crosshair_chk.clicked.emit(False) self.curves_tab_layout = QHBoxLayout() self.curves_tab_layout.addWidget(self.curve_settings_scroll) self.data_tab_layout = QVBoxLayout() self.data_tab_layout.setAlignment(Qt.AlignTop) self.data_tab_layout.setSpacing(5) self.chart_settings_layout = QVBoxLayout() self.chart_settings_layout.setAlignment(Qt.AlignTop) self.chart_settings_layout.setSpacing(5) self.chart_layout = QVBoxLayout() self.chart_layout.setSpacing(10) self.chart_panel = QWidget() self.chart_panel.setMinimumHeight(400) self.chart_control_layout = QHBoxLayout() self.chart_control_layout.setAlignment(Qt.AlignHCenter) self.chart_control_layout.setSpacing(10) self.zoom_x_layout = QVBoxLayout() self.zoom_x_layout.setAlignment(Qt.AlignTop) self.zoom_x_layout.setSpacing(5) self.plus_icon = IconFont().icon("plus", color=QColor("green")) self.minus_icon = IconFont().icon("minus", color=QColor("red")) self.view_all_icon = IconFont().icon("globe", color=QColor("blue")) self.reset_icon = IconFont().icon("circle-o-notch", color=QColor("green")) self.zoom_in_x_btn = QPushButton("X Zoom") self.zoom_in_x_btn.setIcon(self.plus_icon) self.zoom_in_x_btn.clicked.connect( partial(self.handle_zoom_in_btn_clicked, "x", True)) self.zoom_in_x_btn.setEnabled(False) self.zoom_out_x_btn = QPushButton("X Zoom") self.zoom_out_x_btn.setIcon(self.minus_icon) self.zoom_out_x_btn.clicked.connect( partial(self.handle_zoom_in_btn_clicked, "x", False)) self.zoom_out_x_btn.setEnabled(False) self.zoom_y_layout = QVBoxLayout() self.zoom_y_layout.setAlignment(Qt.AlignTop) self.zoom_y_layout.setSpacing(5) self.zoom_in_y_btn = QPushButton("Y Zoom") self.zoom_in_y_btn.setIcon(self.plus_icon) self.zoom_in_y_btn.clicked.connect( partial(self.handle_zoom_in_btn_clicked, "y", True)) self.zoom_in_y_btn.setEnabled(False) self.zoom_out_y_btn = QPushButton("Y Zoom") self.zoom_out_y_btn.setIcon(self.minus_icon) self.zoom_out_y_btn.clicked.connect( partial(self.handle_zoom_in_btn_clicked, "y", False)) self.zoom_out_y_btn.setEnabled(False) self.view_all_btn = QPushButton("View All") self.view_all_btn.setIcon(self.view_all_icon) self.view_all_btn.clicked.connect(self.handle_view_all_button_clicked) self.view_all_btn.setEnabled(False) self.view_all_reset_chart_layout = QVBoxLayout() self.view_all_reset_chart_layout.setAlignment(Qt.AlignTop) self.view_all_reset_chart_layout.setSpacing(5) self.pause_chart_layout = QVBoxLayout() self.pause_chart_layout.setAlignment(Qt.AlignTop) self.pause_chart_layout.setSpacing(5) self.reset_chart_btn = QPushButton("Reset") self.reset_chart_btn.setIcon(self.reset_icon) self.reset_chart_btn.clicked.connect( self.handle_reset_chart_btn_clicked) self.reset_chart_btn.setEnabled(False) self.pause_icon = IconFont().icon("pause", color=QColor("red")) self.play_icon = IconFont().icon("play", color=QColor("green")) self.pause_chart_btn = QPushButton() self.pause_chart_btn.setIcon(self.pause_icon) self.pause_chart_btn.clicked.connect( self.handle_pause_chart_btn_clicked) self.title_settings_layout = QVBoxLayout() self.title_settings_layout.setAlignment(Qt.AlignTop) self.title_settings_layout.setSpacing(5) self.title_settings_grpbx = QGroupBox("Title and Legend") self.title_settings_grpbx.setMaximumHeight(120) self.import_export_data_layout = QVBoxLayout() self.import_export_data_layout.setAlignment(Qt.AlignTop) self.import_export_data_layout.setSpacing(5) self.import_data_btn = QPushButton("Import...") self.import_data_btn.clicked.connect( self.handle_import_data_btn_clicked) self.export_data_btn = QPushButton("Export...") self.export_data_btn.clicked.connect( self.handle_export_data_btn_clicked) self.chart_title_layout = QHBoxLayout() self.chart_title_layout.setSpacing(10) self.chart_title_lbl = QLabel(text="Graph Title") self.chart_title_line_edt = QLineEdit() self.chart_title_line_edt.setText(self.chart.getPlotTitle()) self.chart_title_line_edt.textChanged.connect( self.handle_title_text_changed) self.chart_title_font_btn = QPushButton() self.chart_title_font_btn.setFixedHeight(24) self.chart_title_font_btn.setFixedWidth(24) self.chart_title_font_btn.setIcon(IconFont().icon("font")) self.chart_title_font_btn.clicked.connect( partial(self.handle_chart_font_changed, "title")) self.chart_change_axis_settings_btn = QPushButton( text="Change Axis Settings...") self.chart_change_axis_settings_btn.clicked.connect( self.handle_change_axis_settings_clicked) self.update_datetime_timer = QTimer(self) self.update_datetime_timer.timeout.connect( self.handle_update_datetime_timer_timeout) self.chart_sync_mode_layout = QVBoxLayout() self.chart_sync_mode_layout.setSpacing(5) self.chart_sync_mode_grpbx = QGroupBox("Data Sampling Mode") self.chart_sync_mode_grpbx.setMaximumHeight(100) self.chart_sync_mode_sync_radio = QRadioButton("Synchronous") self.chart_sync_mode_async_radio = QRadioButton("Asynchronous") self.chart_sync_mode_async_radio.setChecked(True) self.graph_drawing_settings_layout = QVBoxLayout() self.graph_drawing_settings_layout.setAlignment(Qt.AlignVCenter) self.chart_interval_layout = QFormLayout() self.chart_redraw_rate_lbl = QLabel("Redraw Rate (Hz)") self.chart_redraw_rate_spin = QSpinBox() self.chart_redraw_rate_spin.setRange(MIN_REDRAW_RATE_HZ, MAX_REDRAW_RATE_HZ) self.chart_redraw_rate_spin.setValue(DEFAULT_REDRAW_RATE_HZ) self.chart_redraw_rate_spin.editingFinished.connect( self.handle_redraw_rate_changed) self.chart_data_sampling_rate_lbl = QLabel("Data Sampling Rate (Hz)") self.chart_data_async_sampling_rate_spin = QSpinBox() self.chart_data_async_sampling_rate_spin.setRange( MIN_DATA_SAMPLING_RATE_HZ, MAX_DATA_SAMPLING_RATE_HZ) self.chart_data_async_sampling_rate_spin.setValue( DEFAULT_DATA_SAMPLING_RATE_HZ) self.chart_data_async_sampling_rate_spin.editingFinished.connect( self.handle_data_sampling_rate_changed) self.chart_data_sampling_rate_lbl.hide() self.chart_data_async_sampling_rate_spin.hide() self.chart_limit_time_span_layout = QHBoxLayout() self.chart_limit_time_span_layout.setSpacing(5) self.limit_time_plan_text = "Limit Time Span" self.chart_limit_time_span_chk = QCheckBox(self.limit_time_plan_text) self.chart_limit_time_span_chk.hide() self.chart_limit_time_span_lbl = QLabel("Hr:Min:Sec") self.chart_limit_time_span_hours_spin_box = QSpinBox() self.chart_limit_time_span_hours_spin_box.setMaximum(999) self.chart_limit_time_span_minutes_spin_box = QSpinBox() self.chart_limit_time_span_minutes_spin_box.setMaximum(59) self.chart_limit_time_span_seconds_spin_box = QSpinBox() self.chart_limit_time_span_seconds_spin_box.setMaximum(59) self.chart_limit_time_span_activate_btn = QPushButton("Apply") self.chart_limit_time_span_activate_btn.setDisabled(True) self.chart_ring_buffer_layout = QFormLayout() self.chart_ring_buffer_size_lbl = QLabel("Ring Buffer Size") self.chart_ring_buffer_size_edt = QLineEdit() self.chart_ring_buffer_size_edt.returnPressed.connect( self.handle_buffer_size_changed) self.chart_ring_buffer_size_edt.setText(str(DEFAULT_BUFFER_SIZE)) self.show_legend_chk = QCheckBox("Show Legend") self.show_legend_chk.clicked.connect( self.handle_show_legend_checkbox_clicked) self.show_legend_chk.setChecked(self.chart.showLegend) self.legend_font_btn = QPushButton() self.legend_font_btn.setFixedHeight(24) self.legend_font_btn.setFixedWidth(24) self.legend_font_btn.setIcon(IconFont().icon("font")) self.legend_font_btn.clicked.connect( partial(self.handle_chart_font_changed, "legend")) self.graph_background_color_layout = QFormLayout() self.axis_grid_color_layout = QFormLayout() self.background_color_lbl = QLabel("Graph Background Color ") self.background_color_btn = QPushButton() self.background_color_btn.setStyleSheet( "background-color: " + self.chart.getBackgroundColor().name()) self.background_color_btn.setContentsMargins(10, 0, 5, 5) self.background_color_btn.setMaximumWidth(20) self.background_color_btn.clicked.connect( self.handle_background_color_button_clicked) self.axis_settings_layout = QVBoxLayout() self.axis_settings_layout.setSpacing(10) self.show_x_grid_chk = QCheckBox("Show x Grid") self.show_x_grid_chk.setChecked(self.chart.showXGrid) self.show_x_grid_chk.clicked.connect( self.handle_show_x_grid_checkbox_clicked) self.show_y_grid_chk = QCheckBox("Show y Grid") self.show_y_grid_chk.setChecked(self.chart.showYGrid) self.show_y_grid_chk.clicked.connect( self.handle_show_y_grid_checkbox_clicked) self.axis_color_lbl = QLabel("Axis and Grid Color") self.axis_color_btn = QPushButton() self.axis_color_btn.setStyleSheet("background-color: " + DEFAULT_CHART_AXIS_COLOR.name()) self.axis_color_btn.setContentsMargins(10, 0, 5, 5) self.axis_color_btn.setMaximumWidth(20) self.axis_color_btn.clicked.connect( self.handle_axis_color_button_clicked) self.grid_opacity_lbl = QLabel("Grid Opacity") self.grid_opacity_lbl.setEnabled(False) self.grid_opacity_slr = QSlider(Qt.Horizontal) self.grid_opacity_slr.setFocusPolicy(Qt.StrongFocus) self.grid_opacity_slr.setRange(0, 10) self.grid_opacity_slr.setValue(5) self.grid_opacity_slr.setTickInterval(1) self.grid_opacity_slr.setSingleStep(1) self.grid_opacity_slr.setTickPosition(QSlider.TicksBelow) self.grid_opacity_slr.valueChanged.connect( self.handle_grid_opacity_slider_mouse_release) self.grid_opacity_slr.setEnabled(False) self.reset_data_settings_btn = QPushButton("Reset Data Settings") self.reset_data_settings_btn.clicked.connect( self.handle_reset_data_settings_btn_clicked) self.reset_chart_settings_btn = QPushButton("Reset Chart Settings") self.reset_chart_settings_btn.clicked.connect( self.handle_reset_chart_settings_btn_clicked) self.curve_checkbox_panel = QWidget() self.graph_drawing_settings_grpbx = QGroupBox("Graph Intervals") self.graph_drawing_settings_grpbx.setAlignment(Qt.AlignTop) self.axis_settings_grpbx = QGroupBox("Graph Appearance") self.app = QApplication.instance() self.setup_ui() self.curve_settings_disp = None self.axis_settings_disp = None self.chart_data_export_disp = None self.chart_data_import_disp = None self.grid_alpha = 5 self.time_span_limit_hours = None self.time_span_limit_minutes = None self.time_span_limit_seconds = None self.data_sampling_mode = ASYNC_DATA_SAMPLING # If there is an imported config file, let's start TimeChart with the imported configuration data if config_file: importer = SettingsImporter(self) try: importer.import_settings(config_file) except SettingsImporterException: display_message_box( QMessageBox.Critical, "Import Failure", "Cannot import the file '{0}'. Check the log for the error details." .format(config_file)) logger.exception( "Cannot import the file '{0}'.".format(config_file)) def ui_filepath(self): """ The path to the UI file created by Qt Designer, if applicable. """ # No UI file is being used return None def ui_filename(self): """ The name the UI file created by Qt Designer, if applicable. """ # No UI file is being used return None def setup_ui(self): """ Initialize the widgets and layouts. """ self.setLayout(self.main_layout) self.pv_layout.addWidget(self.pv_protocol_cmb) self.pv_layout.addWidget(self.pv_name_line_edt) self.pv_layout.addWidget(self.pv_connect_push_btn) self.pv_add_panel.setLayout(self.pv_layout) QTimer.singleShot(0, self.pv_name_line_edt.setFocus) self.curve_settings_tab.setLayout(self.curves_tab_layout) self.chart_settings_tab.setLayout(self.chart_settings_layout) self.setup_chart_settings_layout() self.data_settings_tab.setLayout(self.data_tab_layout) self.setup_data_tab_layout() self.tab_panel.addTab(self.curve_settings_tab, "Curves") self.tab_panel.addTab(self.data_settings_tab, "Data") self.tab_panel.addTab(self.chart_settings_tab, "Graph") self.crosshair_settings_layout.addWidget(self.enable_crosshair_chk) self.crosshair_settings_layout.addWidget(self.crosshair_coord_lbl) self.zoom_x_layout.addWidget(self.zoom_in_x_btn) self.zoom_x_layout.addWidget(self.zoom_out_x_btn) self.zoom_y_layout.addWidget(self.zoom_in_y_btn) self.zoom_y_layout.addWidget(self.zoom_out_y_btn) self.view_all_reset_chart_layout.addWidget(self.reset_chart_btn) self.view_all_reset_chart_layout.addWidget(self.view_all_btn) self.pause_chart_layout.addWidget(self.pause_chart_btn) self.import_export_data_layout.addWidget(self.import_data_btn) self.import_export_data_layout.addWidget(self.export_data_btn) self.chart_control_layout.addLayout(self.zoom_x_layout) self.chart_control_layout.addLayout(self.zoom_y_layout) self.chart_control_layout.addLayout(self.view_all_reset_chart_layout) self.chart_control_layout.addLayout(self.pause_chart_layout) self.chart_control_layout.addLayout(self.crosshair_settings_layout) self.chart_control_layout.addLayout(self.import_export_data_layout) self.chart_control_layout.insertSpacing(5, 30) self.chart_layout.addWidget(self.chart) self.chart_layout.addLayout(self.chart_control_layout) self.chart_panel.setLayout(self.chart_layout) self.splitter.addWidget(self.chart_panel) self.splitter.addWidget(self.tab_panel) self.splitter.setSizes([1, 0]) self.splitter.setHandleWidth(10) self.splitter.setStretchFactor(0, 0) self.splitter.setStretchFactor(1, 1) self.charting_layout.addWidget(self.splitter) self.body_layout.addWidget(self.pv_add_panel) self.body_layout.addLayout(self.charting_layout) self.body_layout.setSpacing(0) self.body_layout.setContentsMargins(0, 0, 0, 0) self.main_layout.addLayout(self.body_layout) self.enable_chart_control_buttons(False) handle = self.splitter.handle(1) layout = QVBoxLayout() layout.setContentsMargins(0, 0, 0, 0) button = QToolButton(handle) button.setArrowType(Qt.LeftArrow) button.clicked.connect(lambda: self.handle_splitter_button(True)) layout.addWidget(button) button = QToolButton(handle) button.setArrowType(Qt.RightArrow) button.clicked.connect(lambda: self.handle_splitter_button(False)) layout.addWidget(button) handle.setLayout(layout) def handle_splitter_button(self, left=True): if left: self.splitter.setSizes([1, 1]) else: self.splitter.setSizes([1, 0]) def change_legend_font(self, font): if font is None: return self.legend_font = font items = self.chart.plotItem.legend.items for i in items: i[1].item.setFont(font) i[1].resizeEvent(None) i[1].updateGeometry() def change_title_font(self, font): current_text = self.chart.plotItem.titleLabel.text args = { "family": font.family, "size": "{}pt".format(font.pointSize()), "bold": font.bold(), "italic": font.italic(), } self.chart.plotItem.titleLabel.setText(current_text, **args) def handle_chart_font_changed(self, target): if target not in ("title", "legend"): return dialog = QFontDialog(self) dialog.setOption(QFontDialog.DontUseNativeDialog, True) if target == "title": dialog.fontSelected.connect(self.change_title_font) else: dialog.fontSelected.connect(self.change_legend_font) dialog.open() def setup_data_tab_layout(self): self.chart_sync_mode_sync_radio.toggled.connect( partial(self.handle_sync_mode_radio_toggle, self.chart_sync_mode_sync_radio)) self.chart_sync_mode_async_radio.toggled.connect( partial(self.handle_sync_mode_radio_toggle, self.chart_sync_mode_async_radio)) self.chart_sync_mode_layout.addWidget(self.chart_sync_mode_sync_radio) self.chart_sync_mode_layout.addWidget(self.chart_sync_mode_async_radio) self.chart_sync_mode_grpbx.setLayout(self.chart_sync_mode_layout) self.data_tab_layout.addWidget(self.chart_sync_mode_grpbx) self.chart_limit_time_span_layout.addWidget( self.chart_limit_time_span_lbl) self.chart_limit_time_span_layout.addWidget( self.chart_limit_time_span_hours_spin_box) self.chart_limit_time_span_layout.addWidget( self.chart_limit_time_span_minutes_spin_box) self.chart_limit_time_span_layout.addWidget( self.chart_limit_time_span_seconds_spin_box) self.chart_limit_time_span_layout.addWidget( self.chart_limit_time_span_activate_btn) self.chart_limit_time_span_lbl.hide() self.chart_limit_time_span_hours_spin_box.hide() self.chart_limit_time_span_minutes_spin_box.hide() self.chart_limit_time_span_seconds_spin_box.hide() self.chart_limit_time_span_activate_btn.hide() self.chart_limit_time_span_hours_spin_box.valueChanged.connect( self.handle_time_span_changed) self.chart_limit_time_span_minutes_spin_box.valueChanged.connect( self.handle_time_span_changed) self.chart_limit_time_span_seconds_spin_box.valueChanged.connect( self.handle_time_span_changed) self.chart_limit_time_span_chk.clicked.connect( self.handle_limit_time_span_checkbox_clicked) self.chart_limit_time_span_activate_btn.clicked.connect( self.handle_chart_limit_time_span_activate_btn_clicked) self.chart_interval_layout.addRow(self.chart_redraw_rate_lbl, self.chart_redraw_rate_spin) self.chart_interval_layout.addRow( self.chart_data_sampling_rate_lbl, self.chart_data_async_sampling_rate_spin) self.graph_drawing_settings_layout.addLayout( self.chart_interval_layout) self.graph_drawing_settings_layout.addWidget( self.chart_limit_time_span_chk) self.graph_drawing_settings_layout.addLayout( self.chart_limit_time_span_layout) self.chart_ring_buffer_layout.addRow(self.chart_ring_buffer_size_lbl, self.chart_ring_buffer_size_edt) self.graph_drawing_settings_layout.addLayout( self.chart_ring_buffer_layout) self.graph_drawing_settings_grpbx.setLayout( self.graph_drawing_settings_layout) self.data_tab_layout.addWidget(self.graph_drawing_settings_grpbx) self.chart_sync_mode_async_radio.toggled.emit(True) self.data_tab_layout.addWidget(self.reset_data_settings_btn) def setup_chart_settings_layout(self): self.chart_title_layout.addWidget(self.chart_title_lbl) self.chart_title_layout.addWidget(self.chart_title_line_edt) self.chart_title_layout.addWidget(self.chart_title_font_btn) self.title_settings_layout.addLayout(self.chart_title_layout) legend_layout = QHBoxLayout() legend_layout.addWidget(self.show_legend_chk) legend_layout.addWidget(self.legend_font_btn) self.title_settings_layout.addLayout(legend_layout) self.title_settings_layout.addWidget( self.chart_change_axis_settings_btn) self.title_settings_grpbx.setLayout(self.title_settings_layout) self.chart_settings_layout.addWidget(self.title_settings_grpbx) self.graph_background_color_layout.addRow(self.background_color_lbl, self.background_color_btn) self.axis_settings_layout.addLayout(self.graph_background_color_layout) self.axis_grid_color_layout.addRow(self.axis_color_lbl, self.axis_color_btn) self.axis_settings_layout.addLayout(self.axis_grid_color_layout) self.axis_settings_layout.addWidget(self.show_x_grid_chk) self.axis_settings_layout.addWidget(self.show_y_grid_chk) self.axis_settings_layout.addWidget(self.grid_opacity_lbl) self.axis_settings_layout.addWidget(self.grid_opacity_slr) self.axis_settings_grpbx.setLayout(self.axis_settings_layout) self.chart_settings_layout.addWidget(self.axis_settings_grpbx) self.chart_settings_layout.addWidget(self.reset_chart_settings_btn) self.update_datetime_timer.start(1000) def add_curve(self): """ Add a new curve to the chart. """ pv_name = self._get_full_pv_name(self.pv_name_line_edt.text()) if pv_name and len(pv_name): color = random_color(curve_colors_only=True) for k, v in self.channel_map.items(): if color == v.color: color = random_color(curve_colors_only=True) self.add_y_channel(pv_name=pv_name, curve_name=pv_name, color=color) self.handle_splitter_button(left=True) def show_mouse_coordinates(self, x, y): self.crosshair_coord_lbl.clear() self.crosshair_coord_lbl.setText("x = {0:.3f}\ny = {1:.3f}".format( x, y)) def handle_enable_crosshair_checkbox_clicked(self, is_checked): self.chart.enableCrosshair(is_checked) self.crosshair_coord_lbl.setVisible(is_checked) self.chart.crosshair_position_updated.connect( self.show_mouse_coordinates) def add_y_channel(self, pv_name, curve_name, color, line_style=Qt.SolidLine, line_width=2, symbol=None, symbol_size=None, is_visible=True): if pv_name in self.channel_map: logger.error("'{0}' has already been added.".format(pv_name)) return curve = self.chart.addYChannel(y_channel=pv_name, name=curve_name, color=color, lineStyle=line_style, lineWidth=line_width, symbol=symbol, symbolSize=symbol_size) curve.show() if is_visible else curve.hide() if self.show_legend_chk.isChecked(): self.change_legend_font(self.legend_font) self.channel_map[pv_name] = curve self.generate_pv_controls(pv_name, color) self.enable_chart_control_buttons() try: self.app.add_connection(curve.channel) except AttributeError: # these methods are not needed on future versions of pydm pass def generate_pv_controls(self, pv_name, curve_color): """ Generate a set of widgets to manage the appearance of a curve. The set of widgets includes: 1. A checkbox which shows the curve on the chart if checked, and hide the curve if not checked 2. Three buttons -- Modify..., Focus, and Remove. Modify... will bring up the Curve Settings dialog. Focus adjusts the chart's zooming for the current curve. Remove will delete the curve from the chart Parameters ---------- pv_name: str The name of the PV the current curve is being plotted for curve_color : QColor The color of the curve to paint for the checkbox label to help the user track the curve to the checkbox """ individual_curve_layout = QVBoxLayout() size_policy = QSizePolicy() size_policy.setVerticalPolicy(QSizePolicy.Fixed) size_policy.setHorizontalPolicy(QSizePolicy.Fixed) individual_curve_grpbx = QGroupBox() individual_curve_grpbx.setMinimumWidth(300) individual_curve_grpbx.setMinimumHeight(120) individual_curve_grpbx.setAlignment(Qt.AlignTop) individual_curve_grpbx.setSizePolicy(size_policy) individual_curve_grpbx.setObjectName(pv_name + "_grb") individual_curve_grpbx.setLayout(individual_curve_layout) checkbox = QCheckBox(parent=individual_curve_grpbx) checkbox.setObjectName(pv_name + "_chb") palette = checkbox.palette() palette.setColor(QPalette.Active, QPalette.WindowText, curve_color) checkbox.setPalette(palette) display_name = pv_name.split("://")[1] if len(display_name) > MAX_DISPLAY_PV_NAME_LENGTH: # Only display max allowed number of characters of the PV Name display_name = display_name[ :int(MAX_DISPLAY_PV_NAME_LENGTH / 2) - 1] + "..." + \ display_name[ -int(MAX_DISPLAY_PV_NAME_LENGTH / 2) + 2:] checkbox.setText(display_name) data_text = QLabel(parent=individual_curve_grpbx) data_text.setWordWrap(True) data_text.setObjectName(pv_name + "_lbl") data_text.setPalette(palette) checkbox.setChecked(True) checkbox.toggled.connect( partial(self.handle_curve_chkbox_toggled, checkbox)) if not self.chart.findCurve(pv_name).isVisible(): checkbox.setChecked(False) modify_curve_btn = QPushButton("Modify...", parent=individual_curve_grpbx) modify_curve_btn.setObjectName(pv_name + "_btn_modify") modify_curve_btn.setMaximumWidth(80) modify_curve_btn.clicked.connect( partial(self.display_curve_settings_dialog, pv_name)) focus_curve_btn = QPushButton("Focus", parent=individual_curve_grpbx) focus_curve_btn.setObjectName(pv_name + "_btn_focus") focus_curve_btn.setMaximumWidth(80) focus_curve_btn.clicked.connect(partial(self.focus_curve, pv_name)) clear_curve_btn = QPushButton("Clear", parent=individual_curve_grpbx) clear_curve_btn.setObjectName(pv_name + "_btn_clear") clear_curve_btn.setMaximumWidth(80) clear_curve_btn.clicked.connect(partial(self.clear_curve, pv_name)) # annotate_curve_btn = QPushButton("Annotate...", # parent=individual_curve_grpbx) # annotate_curve_btn.setObjectName(pv_name+"_btn_ann") # annotate_curve_btn.setMaximumWidth(80) # annotate_curve_btn.clicked.connect( # partial(self.annotate_curve, pv_name)) remove_curve_btn = QPushButton("Remove", parent=individual_curve_grpbx) remove_curve_btn.setObjectName(pv_name + "_btn_remove") remove_curve_btn.setMaximumWidth(80) remove_curve_btn.clicked.connect(partial(self.remove_curve, pv_name)) curve_btn_layout = QHBoxLayout() curve_btn_layout.setSpacing(5) curve_btn_layout.addWidget(modify_curve_btn) curve_btn_layout.addWidget(focus_curve_btn) curve_btn_layout.addWidget(clear_curve_btn) # curve_btn_layout.addWidget(annotate_curve_btn) curve_btn_layout.addWidget(remove_curve_btn) individual_curve_layout.addWidget(checkbox) individual_curve_layout.addWidget(data_text) individual_curve_layout.addLayout(curve_btn_layout) self.curve_settings_layout.addWidget(individual_curve_grpbx) self.tab_panel.setCurrentIndex(0) def handle_curve_chkbox_toggled(self, checkbox): """ Handle a checkbox's checked and unchecked events. If a checkbox is checked, find the curve from the channel map. If found, re-draw the curve with its previous appearance settings. If a checkbox is unchecked, remove the curve from the chart, but keep the cached data in the channel map. Parameters ---------- checkbox : QCheckBox The current checkbox being toggled """ pv_name = self._get_full_pv_name(checkbox.text()) if checkbox.isChecked(): curve = self.channel_map.get(pv_name, None) if curve: curve.show() self.chart.addLegendItem(curve, pv_name, self.show_legend_chk.isChecked()) self.change_legend_font(self.legend_font) else: curve = self.chart.findCurve(pv_name) if curve: curve.hide() self.chart.removeLegendItem(pv_name) def display_curve_settings_dialog(self, pv_name): """ Bring up the Curve Settings dialog to modify the appearance of a curve. Parameters ---------- pv_name : str The name of the PV the curve is being plotted for """ self.curve_settings_disp = CurveSettingsDisplay(self, pv_name) self.curve_settings_disp.show() def focus_curve(self, pv_name): curve = self.chart.findCurve(pv_name) if curve: self.chart.plotItem.setYRange(curve.minY, curve.maxY, padding=0) def clear_curve(self, pv_name): curve = self.chart.findCurve(pv_name) if curve: curve.initialize_buffer() def annotate_curve(self, pv_name): curve = self.chart.findCurve(pv_name) if curve: annot = TextItem( html= '<div style="text-align: center"><span style="color: #FFF;">This is the' '</span><br><span style="color: #FF0; font-size: 16pt;">PEAK</span></div>', anchor=(-0.3, 0.5), border='w', fill=(0, 0, 255, 100)) self.chart.annotateCurve(curve, annot) def remove_curve(self, pv_name): """ Remove a curve from the chart permanently. This will also clear the channel map cache from retaining the removed curve's appearance settings. Parameters ---------- pv_name : str The name of the PV the curve is being plotted for """ curve = self.chart.findCurve(pv_name) if curve: try: self.app.remove_connection(curve.channel) except AttributeError: # these methods are not needed on future versions of pydm pass self.chart.removeYChannel(curve) del self.channel_map[pv_name] self.chart.removeLegendItem(pv_name) widget = self.findChild(QGroupBox, pv_name + "_grb") if widget: widget.deleteLater() if len(self.chart.getCurves()) < 1: self.enable_chart_control_buttons(False) self.show_legend_chk.setChecked(False) def handle_title_text_changed(self, new_text): self.chart.setPlotTitle(new_text) def handle_change_axis_settings_clicked(self): self.axis_settings_disp = AxisSettingsDisplay(self) self.axis_settings_disp.show() def handle_limit_time_span_checkbox_clicked(self, is_checked): self.chart_limit_time_span_lbl.setVisible(is_checked) self.chart_limit_time_span_hours_spin_box.setVisible(is_checked) self.chart_limit_time_span_minutes_spin_box.setVisible(is_checked) self.chart_limit_time_span_seconds_spin_box.setVisible(is_checked) self.chart_limit_time_span_activate_btn.setVisible(is_checked) self.chart_ring_buffer_size_lbl.setDisabled(is_checked) self.chart_ring_buffer_size_edt.setDisabled(is_checked) if not is_checked: self.chart_limit_time_span_chk.setText(self.limit_time_plan_text) def handle_time_span_changed(self): self.time_span_limit_hours = self.chart_limit_time_span_hours_spin_box.value( ) self.time_span_limit_minutes = self.chart_limit_time_span_minutes_spin_box.value( ) self.time_span_limit_seconds = self.chart_limit_time_span_seconds_spin_box.value( ) status = self.time_span_limit_hours > 0 or self.time_span_limit_minutes > 0 or self.time_span_limit_seconds > 0 self.chart_limit_time_span_activate_btn.setEnabled(status) def handle_chart_limit_time_span_activate_btn_clicked(self): timeout_milliseconds = (self.time_span_limit_hours * 3600 + self.time_span_limit_minutes * 60 + self.time_span_limit_seconds) * 1000 self.chart.setTimeSpan(timeout_milliseconds / 1000.0) self.chart_ring_buffer_size_edt.setText(str( self.chart.getBufferSize())) def handle_buffer_size_changed(self): try: new_buffer_size = int(self.chart_ring_buffer_size_edt.text()) if new_buffer_size and int(new_buffer_size) >= MINIMUM_BUFFER_SIZE: self.chart.setBufferSize(new_buffer_size) except ValueError: display_message_box(QMessageBox.Critical, "Invalid Values", "Only integer values are accepted.") def handle_redraw_rate_changed(self): self.chart.maxRedrawRate = self.chart_redraw_rate_spin.value() def handle_data_sampling_rate_changed(self): # The chart expects the value in milliseconds sampling_rate_seconds = 1.0 / self.chart_data_async_sampling_rate_spin.value( ) buffer_size = self.chart.getBufferSize() self.chart.setUpdateInterval(sampling_rate_seconds) if self.chart.getBufferSize() < buffer_size: self.chart.setBufferSize(buffer_size) self.chart_ring_buffer_size_edt.setText(str( self.chart.getBufferSize())) def handle_background_color_button_clicked(self): selected_color = QColorDialog.getColor() self.chart.setBackgroundColor(selected_color) self.background_color_btn.setStyleSheet("background-color: " + selected_color.name()) def handle_axis_color_button_clicked(self): selected_color = QColorDialog.getColor() self.chart.setAxisColor(selected_color) self.axis_color_btn.setStyleSheet("background-color: " + selected_color.name()) def handle_grid_opacity_slider_mouse_release(self): self.grid_alpha = float(self.grid_opacity_slr.value()) / 10.0 self.chart.setShowXGrid(self.show_x_grid_chk.isChecked(), self.grid_alpha) self.chart.setShowYGrid(self.show_y_grid_chk.isChecked(), self.grid_alpha) def handle_show_x_grid_checkbox_clicked(self, is_checked): self.chart.setShowXGrid(is_checked, self.grid_alpha) self.grid_opacity_lbl.setEnabled(is_checked or self.show_y_grid_chk.isChecked()) self.grid_opacity_slr.setEnabled(is_checked or self.show_y_grid_chk.isChecked()) def handle_show_y_grid_checkbox_clicked(self, is_checked): self.chart.setShowYGrid(is_checked, self.grid_alpha) self.grid_opacity_lbl.setEnabled(is_checked or self.show_x_grid_chk.isChecked()) self.grid_opacity_slr.setEnabled(is_checked or self.show_x_grid_chk.isChecked()) def handle_show_legend_checkbox_clicked(self, is_checked): self.chart.setShowLegend(is_checked) def handle_export_data_btn_clicked(self): self.chart_data_export_disp = ChartDataExportDisplay(self) self.chart_data_export_disp.show() def handle_import_data_btn_clicked(self): open_file_info = QFileDialog.getOpenFileName( self, caption="Open File", directory=os.path.expanduser('~'), filter=IMPORT_FILE_FORMAT) open_filename = open_file_info[0] if open_filename: try: importer = SettingsImporter(self) importer.import_settings(open_filename) except SettingsImporterException: display_message_box( QMessageBox.Critical, "Import Failure", "Cannot import the file '{0}'. Check the log for the error details." .format(open_filename)) logger.exception( "Cannot import the file '{0}'".format(open_filename)) def handle_sync_mode_radio_toggle(self, radio_btn): if radio_btn.isChecked(): if radio_btn.text() == "Synchronous": self.data_sampling_mode = SYNC_DATA_SAMPLING self.chart_data_sampling_rate_lbl.hide() self.chart_data_async_sampling_rate_spin.hide() self.chart.resetTimeSpan() self.chart_limit_time_span_chk.setChecked(False) self.chart_limit_time_span_chk.clicked.emit(False) self.chart_limit_time_span_chk.hide() self.chart.setUpdatesAsynchronously(False) elif radio_btn.text() == "Asynchronous": self.data_sampling_mode = ASYNC_DATA_SAMPLING self.chart_data_sampling_rate_lbl.show() self.chart_data_async_sampling_rate_spin.show() self.chart_limit_time_span_chk.show() self.chart.setUpdatesAsynchronously(True) def handle_zoom_in_btn_clicked(self, axis, is_zoom_in): scale_factor = 0.5 if not is_zoom_in: scale_factor += 1.0 if axis == "x": self.chart.getViewBox().scaleBy(x=scale_factor) elif axis == "y": self.chart.getViewBox().scaleBy(y=scale_factor) def handle_view_all_button_clicked(self): self.chart.plotItem.getViewBox().autoRange() def handle_pause_chart_btn_clicked(self): if self.chart.pausePlotting(): self.pause_chart_btn.setIcon(self.pause_icon) else: self.pause_chart_btn.setIcon(self.play_icon) def handle_reset_chart_btn_clicked(self): self.chart.getViewBox().setXRange(DEFAULT_X_MIN, 0) self.chart.resetAutoRangeY() @Slot() def handle_reset_chart_settings_btn_clicked(self): self.chart.setBackgroundColor(DEFAULT_CHART_BACKGROUND_COLOR) self.background_color_btn.setStyleSheet( "background-color: " + DEFAULT_CHART_BACKGROUND_COLOR.name()) self.chart.setAxisColor(DEFAULT_CHART_AXIS_COLOR) self.axis_color_btn.setStyleSheet("background-color: " + DEFAULT_CHART_AXIS_COLOR.name()) self.grid_opacity_slr.setValue(5) self.show_x_grid_chk.setChecked(False) self.show_x_grid_chk.clicked.emit(False) self.show_y_grid_chk.setChecked(False) self.show_y_grid_chk.clicked.emit(False) self.show_legend_chk.setChecked(False) self.chart.setShowXGrid(False) self.chart.setShowYGrid(False) self.chart.setShowLegend(False) @Slot() def handle_reset_data_settings_btn_clicked(self): self.chart_ring_buffer_size_edt.setText(str(DEFAULT_BUFFER_SIZE)) self.chart_redraw_rate_spin.setValue(DEFAULT_REDRAW_RATE_HZ) self.handle_redraw_rate_changed() self.chart_data_async_sampling_rate_spin.setValue( DEFAULT_DATA_SAMPLING_RATE_HZ) self.chart_data_sampling_rate_lbl.hide() self.chart_data_async_sampling_rate_spin.hide() self.chart_sync_mode_async_radio.setChecked(True) self.chart_sync_mode_async_radio.toggled.emit(True) self.chart_limit_time_span_chk.setChecked(False) self.chart_limit_time_span_chk.setText(self.limit_time_plan_text) self.chart_limit_time_span_chk.clicked.emit(False) self.chart.setUpdatesAsynchronously(True) self.chart.resetTimeSpan() self.chart.resetUpdateInterval() self.chart.setBufferSize(DEFAULT_BUFFER_SIZE) def enable_chart_control_buttons(self, enabled=True): self.zoom_in_x_btn.setEnabled(enabled) self.zoom_out_x_btn.setEnabled(enabled) self.zoom_in_y_btn.setEnabled(enabled) self.zoom_out_y_btn.setEnabled(enabled) self.view_all_btn.setEnabled(enabled) self.reset_chart_btn.setEnabled(enabled) self.pause_chart_btn.setIcon(self.pause_icon) self.pause_chart_btn.setEnabled(enabled) self.export_data_btn.setEnabled(enabled) def _get_full_pv_name(self, pv_name): """ Append the protocol to the PV Name. Parameters ---------- pv_name : str The name of the PV the curve is being plotted for """ if pv_name and "://" not in pv_name: pv_name = ''.join([self.pv_protocol_cmb.currentText(), pv_name]) return pv_name def handle_update_datetime_timer_timeout(self): current_label = self.chart.getBottomAxisLabel() new_label = "Current Time: " + TimeChartDisplay.get_current_datetime() if X_AXIS_LABEL_SEPARATOR in current_label: current_label = current_label[current_label. find(X_AXIS_LABEL_SEPARATOR) + len(X_AXIS_LABEL_SEPARATOR):] new_label += X_AXIS_LABEL_SEPARATOR + current_label self.chart.setLabel("bottom", text=new_label) def update_curve_data(self, curve): """ Determine if the PV is active. If not, disable the related PV controls. If the PV is active, update the PV controls' states. Parameters ---------- curve : PlotItem A PlotItem, i.e. a plot, to draw on the chart. """ pv_name = curve.address min_y = curve.minY if curve.minY else 0 max_y = curve.maxY if curve.maxY else 0 current_y = curve.data_buffer[1, -1] grb = self.findChild(QGroupBox, pv_name + "_grb") lbl = grb.findChild(QLabel, pv_name + "_lbl") lbl.setText("(yMin = {0:.3f}, yMax = {1:.3f}) y = {2:.3f}".format( min_y, max_y, current_y)) chb = grb.findChild(QCheckBox, pv_name + "_chb") connected = curve.connected if connected and chb.isEnabled(): return chb.setEnabled(connected) btn_modify = grb.findChild(QPushButton, pv_name + "_btn_modify") btn_modify.setEnabled(connected) btn_focus = grb.findChild(QPushButton, pv_name + "_btn_focus") btn_focus.setEnabled(connected) # btn_ann = grb.findChild(QPushButton, pv_name + "_btn_ann") # btn_ann.setEnabled(connected) @staticmethod def get_current_datetime(): current_date = datetime.datetime.now().strftime("%b %d, %Y") current_time = datetime.datetime.now().strftime("%H:%M:%S") current_datetime = current_time + ' (' + current_date + ')' return current_datetime @property def gridAlpha(self): return self.grid_alpha