import sys
import yaml
import rich
import pandas as pd
from pathlib import Path
from typing import Union, Optional
import os
import logging
import time
import psutil
import threading
import queue
from qtpy import QtWidgets
from qtpy.QtGui import QIcon, QPalette
from qtpy.QtWidgets import (
QApplication,
QTableView,
QGridLayout,
QWidget,
QMainWindow,
QPushButton,
QVBoxLayout,
QCheckBox,
QMessageBox,
QDialog,
QDialogButtonBox,
QLabel,
QSplitter,
QProgressBar,
)
from qtpy.QtCore import QAbstractTableModel, Qt, QTimer
from pyqtgraph.parametertree import Parameter, ParameterTree
from .utils.sound import parse_table
from . import client
from .utils.config import readconfig
logger = logging.getLogger(__name__)
_ICON_DIR = Path(__file__).with_name("assets")
_LIGHT_ICON = _ICON_DIR / "etho_icon1_pipeline_light.svg"
_DARK_ICON = _ICON_DIR / "etho_icon1_pipeline_dark.svg"
def _system_icon(app: QApplication) -> QIcon:
icon_path = _LIGHT_ICON
try:
window_role = getattr(getattr(QPalette, "ColorRole", QPalette), "Window")
if app.palette().color(window_role).lightness() < 128:
icon_path = _DARK_ICON
except Exception:
pass
if not icon_path.exists():
icon_path = _LIGHT_ICON
return QIcon(icon_path.as_posix())
[docs]
class PandasModel(QAbstractTableModel):
def __init__(self, data, editable: bool = False):
QAbstractTableModel.__init__(self)
self._data = data
self._editable = editable
def rowCount(self, parent=None):
return self._data.shape[0]
def columnCount(self, parent=None):
return self._data.shape[1]
def data(self, index, role=Qt.DisplayRole):
if index.isValid():
if role == Qt.DisplayRole or role == Qt.EditRole:
value = self._data.iloc[index.row(), index.column()]
return str(value)
def setData(self, index, value, role):
if role == Qt.EditRole:
self._data.iloc[index.row(), index.column()] = value
return True
def headerData(self, col, orientation, role):
if orientation == Qt.Horizontal and role == Qt.DisplayRole:
return self._data.columns[col]
return None
def flags(self, index):
if self._editable:
return Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsEditable
else:
return Qt.ItemIsSelectable | Qt.ItemIsEnabled
def replaceData(self, data):
self.beginResetModel()
self._data = data
self.endResetModel()
[docs]
class TableView(QTableView):
def __init__(self, model, child=None, folder=None):
QTableView.__init__(self)
self._child = child
self._folder = folder
self.setModel(model)
self.selectionModel().selectionChanged.connect(self.update_child)
self.doubleClicked.connect(self.edit_file)
header = self.horizontalHeader()
header.setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
header.setStretchLastSection(True)
self.resizeRowsToContents()
self.data = None
self.selected_string = None
def update_child(self, selected, deselected):
if self._child is not None:
selected_row = selected.indexes()[0].row()
self.selected_string = str(self.model()._data.iloc[selected_row, 0])
self.data = self._child.data_from_filename(self.selected_string)
self._child.replaceData(self.data)
def edit_file(self):
if self._folder is not None:
print(f"code {self.folder}/{self.selected_string}")
os.system(f"code {self.folder}/{self.selected_string}")
def _parameter_item(name, value):
item = {"name": str(name)}
if isinstance(value, dict):
item["type"] = "group"
item["original_type"] = "dict"
item["children"] = [_parameter_item(key, val) for key, val in value.items()]
elif isinstance(value, list):
item["type"] = "group"
item["original_type"] = "list"
scalar_names = [str(val) for val in value if not isinstance(val, (dict, list))]
use_checkboxes = len(scalar_names) == len(value) and len(set(scalar_names)) == len(scalar_names)
if use_checkboxes:
item["list_mode"] = "checkbox"
item["children"] = [{"name": str(val), "type": "bool", "value": True, "original_value": val} for val in value]
else:
item["children"] = [_parameter_item(idx, val) for idx, val in enumerate(value)]
elif isinstance(value, bool):
item["type"] = "bool"
item["value"] = value
elif isinstance(value, int):
item["type"] = "int"
item["value"] = value
elif isinstance(value, float):
item["type"] = "float"
item["value"] = value
elif value is None:
item["type"] = "str"
item["value"] = ""
item["original_type"] = "none"
else:
item["type"] = "str"
item["value"] = str(value)
return item
# format to parametertree
def from_yaml(d, readonly=True):
pt = [_parameter_item(key, val) for key, val in d.items()]
p = Parameter.create(name="Protocol parameters", type="group", children=pt)
if readonly:
children_read_only(p.children())
return p
def children_read_only(children):
for child in children:
if child.children():
children_read_only(child.children())
else:
child.setReadonly()
def to_yaml(p):
if p.children():
if p.opts.get("original_type") == "list":
if p.opts.get("list_mode") == "checkbox":
return [child.opts.get("original_value", child.name()) for child in p.children() if child.value()]
return [to_yaml(child) for child in p.children()]
return {child.name(): to_yaml(child) for child in p.children()}
value = p.value()
if p.opts.get("original_type") == "none":
return yaml.safe_load(value)
return value
def load(filename: str):
with open(filename, "r") as f:
d = yaml.load(f, Loader=yaml.SafeLoader)
return d
def save(d, filename: str):
pass
def kill_child_processes():
try:
parent = psutil.Process()
except psutil.NoSuchProcess:
return
children = parent.children(recursive=True)
for child in children:
child.terminate() # friendly termination
_, still_alive = psutil.wait_procs(children, timeout=3)
for child in still_alive:
child.kill() # unfriendly termination
# os.kill(child.pid, signal.SIGKILL)
[docs]
class RunDialog(QDialog):
def __init__(self, stop_event, done_event, queue_total):
super().__init__()
self.stop_event = stop_event
self.done_event = done_event
self.queue_total = queue_total
self.setWindowTitle("Progress")
QBtn = QDialogButtonBox.Cancel
self.message = QLabel("Running")
self.message.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter)
self.pbar = QProgressBar(self)
self.pbar.setValue(0)
self.buttonBox = QDialogButtonBox(QBtn)
self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject)
self.layout = QVBoxLayout()
self.layout.addWidget(self.message)
self.layout.addWidget(self.pbar)
self.layout.addWidget(self.buttonBox)
self.setLayout(self.layout)
self.total = queue_total.get()
self.monitor_thread = threading.Thread(
target=monitor,
args=[self.done_event, self.stop_event, self.accept, self.pbar, self.total],
)
self.monitor_thread.start()
def reject(self):
self.stop_event.set()
super().reject()
def accept(self):
self.stop_event.set()
super().accept()
def monitor(event1, event2, callback, progress=None, progress_total=100):
cnt = 0
RUN = True
while RUN:
if event1.is_set():
callback()
RUN = False
if event2.is_set():
RUN = False
cnt += 1
if progress is not None:
progress.setValue(int(100 * cnt / progress_total))
time.sleep(1)
[docs]
class MainWindow(QMainWindow):
def __init__(
self,
protocol_folder: Optional[Union[str, os.PathLike]] = None,
playlist_folder: Optional[Union[str, os.PathLike]] = None,
):
super(MainWindow, self).__init__()
# rich.print(config)
if protocol_folder is None:
config = readconfig()
protocol_folder = config["protocolfolder"]
self.protocol_folder = Path(protocol_folder)
if not self.protocol_folder.exists():
raise FileExistsError(f"{self.protocol_folder} does not exist!")
logging.info(f"Loading protocols from {self.protocol_folder}.")
if playlist_folder is None:
config = readconfig()
playlist_folder = config["playlistfolder"]
self.playlist_folder = Path(playlist_folder)
if not self.playlist_folder.exists():
raise FileExistsError(f"{self.playlist_folder} does not exist!")
logging.info(f"Loading playlists from {self.playlist_folder}.")
self.setWindowTitle("etho control")
self.run_thread = None
self.stop_event = None
self.done_event = None
self.run_timer = QTimer(self)
self.run_timer.setInterval(200)
self.run_timer.timeout.connect(self.sync_run_state)
# Buttons
buttons = QVBoxLayout()
self.button = {}
self.button["Refresh lists"] = QPushButton("Refresh lists")
self.button["Refresh lists"].clicked.connect(self.refresh_lists)
self.button["Start"] = QPushButton("Start")
self.button["Start"].clicked.connect(self.start)
self.button["Stop"] = QPushButton("Stop")
self.button["Stop"].clicked.connect(self.request_stop)
self.button["Camera_preview"] = QPushButton("Camera preview")
self.button["Camera_preview"].clicked.connect(self.camera_preview)
self.button["Exit"] = QPushButton("Exit")
self.button["Exit"].clicked.connect(self.close)
self.button["Debug"] = QCheckBox("Debug")
self.button["Progress"] = QCheckBox("Show Progress")
self.button["Progress"].setChecked(True)
[buttons.addWidget(b) for b in self.button.values()]
self.help = QtWidgets.QLabel(
"<br><br><B>Instructions</B><br><br>Single Click on<br> playlist or protocol.<br>previews the file.<br><br>Double Click opens<br>playlist or protocol<br>in VS code.<br>"
)
buttons.addWidget(self.help)
# Layout
self.layout = QGridLayout()
self.layout.addLayout(buttons, 0, 0)
self.refresh_lists(init=True)
self.layout.setColumnMinimumWidth(1, 200)
self.layout.setColumnMinimumWidth(2, 600)
self.layout.setColumnStretch(2, 1)
self.layout.setColumnStretch(1, 2)
widget = QWidget()
widget.setLayout(self.layout)
self.setCentralWidget(widget)
self.set_run_buttons(False)
def on_protocol_changed(self, param, changes):
self.current_protocol = to_yaml(param)
def refresh_lists(self, init: bool = False):
playlist_files = sorted(self.playlist_folder.glob("*.txt"))
if len(playlist_files) == 0:
raise FileNotFoundError(f"No files found in {self.playlist_folder}.")
df_playlists = pd.DataFrame({"playlist": sorted([Path(plf).name for plf in playlist_files])})
playlist_file = Path(playlist_files[0]).name
playlist_from_filename = lambda filename: parse_table((self.playlist_folder / filename).as_posix())
playlist_model = PandasModel(playlist_from_filename(playlist_file))
playlist_model.data_from_filename = playlist_from_filename
playlist_view = TableView(playlist_model)
playlist_view.setAlternatingRowColors(True)
# List of playlist files
playlists_model = PandasModel(df_playlists, editable=False)
playlists_view = TableView(playlists_model, playlist_model, self.playlist_folder)
playlists_view.setAlternatingRowColors(True)
playlists_view.folder = self.playlist_folder
playlists_view.selectRow(0)
# Protocols
protocol_files = sorted(self.protocol_folder.glob("*.yml"))
if len(protocol_files) == 0:
raise FileNotFoundError(f"No files found in {self.protocol_folder}.")
df_protocols = pd.DataFrame({"protocol": sorted([Path(plf).name for plf in protocol_files])})
# Content of selected protocol file
protocol_file = Path(protocol_files[0]).name
protocol_from_filename = lambda filename: from_yaml(load(self.protocol_folder / filename), readonly=False)
protocol_model = protocol_from_filename(protocol_file)
protocol_view = ParameterTree()
def set_protocol_model(parameter):
self.current_protocol = to_yaml(parameter)
parameter.sigTreeStateChanged.connect(self.on_protocol_changed)
protocol_view.protocol_model = parameter
protocol_view.setParameters(parameter, showTop=False)
set_protocol_model(protocol_model)
protocol_view.replaceData = set_protocol_model
protocol_view.data_from_filename = protocol_from_filename
# List of protocol files
protocols_model = PandasModel(df_protocols, editable=False)
protocols_view = TableView(protocols_model, protocol_view, self.protocol_folder)
protocols_view.setAlternatingRowColors(True)
protocols_view.folder = self.protocol_folder
protocols_view.selectRow(0)
if init:
self.playlist_view = playlist_view
self.playlists_view = playlists_view
self.protocol_view = protocol_view
self.protocols_view = protocols_view
# splitter = QSplitter(QtCore.Qt.Vertical)
# splitter.addWidget(self.playlists_view)
# splitter.addWidget(self.protocols_view)
# # self.layout.addWidget(splitter)
# splitter2 = QSplitter(QtCore.Qt.Vertical)
# splitter2.addWidget(self.playlist_view)
# splitter2.addWidget(self.protocol_view)
# splitterH = QSplitter(QtCore.Qt.Horizontal)
# splitterH.addWidget(splitter)
# splitterH.addWidget(splitter2)
# self.layout.addWidget(splitterH, 0, 1, 1, 1)
self.layout.addWidget(self.playlists_view, 0, 1, 1, 1)
self.layout.addWidget(self.playlist_view, 0, 2, 1, 5)
self.layout.addWidget(self.protocols_view, 1, 1, 1, 1)
self.layout.addWidget(self.protocol_view, 1, 2, 1, 5)
else:
self.layout.replaceWidget(self.playlists_view, playlists_view)
self.layout.replaceWidget(self.playlist_view, playlist_view)
self.layout.replaceWidget(self.protocols_view, protocols_view)
self.layout.replaceWidget(self.protocol_view, protocol_view)
self.playlist_view = playlist_view
self.playlists_view = playlists_view
self.protocol_view = protocol_view
self.protocols_view = protocols_view
def start(self, preview: bool = False):
if self.run_thread is not None and self.run_thread.is_alive():
return
msg = []
if self.playlists_view.selected_string is None:
msg.append("playlist")
if self.protocols_view.selected_string is None:
msg.append("protocol")
if len(msg):
dlg = QMessageBox(self)
dlg.setWindowTitle("Error: Could not start the experiment.")
dlg.setText(f"Please select a {' and a '.join(msg)}.")
dlg.exec_()
return
stop_event = threading.Event()
done_event = threading.Event()
kwargs = {
"playlistfile": (self.playlist_folder / self.playlists_view.selected_string).as_posix(),
"protocolfile": (self.protocol_folder / self.protocols_view.selected_string).as_posix(),
"protocol": self.current_protocol,
"debug": self.button["Debug"].isChecked(),
"show_progress": self.button["Progress"].isChecked(),
"monitor": True,
"save_prefix": None,
"preview": preview,
"_stop_event": stop_event,
"_done_event": done_event,
}
rich.print("Starting experiment with these args:")
rich.print(kwargs)
self.stop_event = stop_event
self.done_event = done_event
self.run_thread = threading.Thread(target=self.run_client, kwargs=kwargs, daemon=True)
self.set_run_buttons(True)
self.run_thread.start()
self.run_timer.start()
# dlg = RunDialog(stop_event, done_event, queue_total)
# dlg.exec_()
def run_client(self, **kwargs):
done_event = kwargs.get("_done_event")
try:
client.client(**kwargs)
except Exception:
logging.exception("Experiment failed.")
finally:
if done_event is not None:
done_event.set()
def request_stop(self):
if self.stop_event is None:
return
self.stop_event.set()
self.set_run_buttons(True, stopping=True)
def sync_run_state(self):
if self.run_thread is None:
self.run_timer.stop()
self.set_run_buttons(False)
return
if self.stop_event is not None and self.stop_event.is_set():
self.set_run_buttons(True, stopping=True)
if self.run_thread.is_alive():
return
if self.done_event is not None and not self.done_event.is_set():
return
self.run_timer.stop()
self.run_thread = None
self.stop_event = None
self.done_event = None
self.set_run_buttons(False)
def set_run_buttons(self, running: bool, stopping: bool = False):
self.button["Start"].setEnabled(not running)
self.button["Camera_preview"].setEnabled(not running)
self.button["Refresh lists"].setEnabled(not running)
self.button["Stop"].setEnabled(running and not stopping)
self.button["Stop"].setText("Stopping..." if stopping else "Stop")
self.button["Exit"].setEnabled(not running)
def camera_preview(self):
self.start(preview=True)
[docs]
def main(protocol_folder: Optional[str] = None, playlist_folder: Optional[str] = None):
"""Opens the graphical user interface.
Args:
protocol_folder (Optional[str]): Folder with protocol files.
Defaults to value `protocolfolder` in `~/ethoconfig/ethoconfig.yml`.
playlist_folder (Optional[str]): Folder with playlist files.
Defaults to value `playlistfolder` in `~/ethoconfig/ethoconfig.yml`.
"""
app = QApplication(sys.argv)
icon = _system_icon(app)
app.setWindowIcon(icon)
m = MainWindow(protocol_folder, playlist_folder)
m.setWindowIcon(icon)
m.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()