From 979938a4af04fedabec8a31a9dd37bc917e3b534 Mon Sep 17 00:00:00 2001 From: Bernhard Arnold Date: Wed, 5 Jun 2024 17:32:42 +0200 Subject: [PATCH 01/14] refs #55 added inno setup template, updated windows app spec and workflow --- .github/workflows/windows-app.yml | 11 ++++++++- innosetup.iss | 38 +++++++++++++++++++++++++++++++ pyinstaller/windows_app.spec | 21 +++++++++++++---- 3 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 innosetup.iss diff --git a/.github/workflows/windows-app.yml b/.github/workflows/windows-app.yml index 1184657..d8d6965 100644 --- a/.github/workflows/windows-app.yml +++ b/.github/workflows/windows-app.yml @@ -27,7 +27,16 @@ jobs: run: | pyinstaller pyinstaller/windows_app.spec - name: Publish - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: sqc-artifact + path: dist + - name: Build installer + run: | + $VERSION = python -c "import sqc; print(sqc.__version__)" + ISCC.exe /DVersion=$VERSION innosetup.iss + - name: Publish installer + uses: actions/upload-artifact@v4 + with: + name: sqc-setup-artifact path: dist/sqc*.exe diff --git a/innosetup.iss b/innosetup.iss new file mode 100644 index 0000000..c16957c --- /dev/null +++ b/innosetup.iss @@ -0,0 +1,38 @@ +; Inno Setup template + +#ifndef Version + #error The define variable Version is not set! Use /D Version=value to set it. +#endif + +#define Organization "HEPHY" +#define Name "SQC" +#define ExeName "sqc.exe" + +[Setup] +AppId={#Organization}_{#Name}_{#Version} +AppName={#Name} +AppVersion={#Version} +DefaultDirName={userappdata}\{#Organization}\sqc\{#Version} +DefaultGroupName={#Name} +OutputDir=dist +OutputBaseFilename=sqc-{#Version}-win-x64-setup +SetupIconFile=src\sqc\assets\icons\sqc.ico +Compression=lzma +SolidCompression=yes +PrivilegesRequired=lowest + +[Files] +Source: "dist\sqc\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{userdesktop}\{#Name} {#Version}"; Filename: "{app}\{#ExeName}" +Name: "{group}\{#Name} {#Version}"; Filename: "{app}\{#ExeName}"; WorkingDir: "{app}" + +[Run] +Filename: "{app}\{#ExeName}"; Description: "{cm:LaunchProgram,{#Name} {#Version}}"; Flags: nowait postinstall skipifsilent + +[UninstallDelete] +Type: filesandordirs; Name: "{app}" + +[UninstallRun] +Filename: "{app}\uninstall.exe" diff --git a/pyinstaller/windows_app.spec b/pyinstaller/windows_app.spec index fdd6564..9c2b199 100644 --- a/pyinstaller/windows_app.spec +++ b/pyinstaller/windows_app.spec @@ -4,8 +4,10 @@ from pyinstaller_versionfile import create_versionfile import sqc version = sqc.__version__ -filename = f"sqc-{version}.exe" +bundle = "sqc" +filename = "sqc.exe" console = False +debug = False block_cipher = None package_root = os.path.join(os.path.dirname(sqc.__file__)) @@ -58,13 +60,11 @@ pyz = PYZ( exe = EXE( pyz, a.scripts, - a.binaries, - a.zipfiles, - a.datas, [], + exclude_binaries=True, name=filename, version=version_info, - debug=False, + debug=debug, bootloader_ignore_signals=False, strip=False, upx=True, @@ -73,3 +73,14 @@ exe = EXE( console=console, icon=package_icon, ) + +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name=bundle, +) From 08fa5be7befb313f18d0abf619f66bcb040aa14e Mon Sep 17 00:00:00 2001 From: Bernhard Arnold Date: Thu, 6 Jun 2024 16:30:27 +0200 Subject: [PATCH 02/14] refs #55 added publisher info, uninstaller icon, updated pyinstaller --- innosetup.iss | 3 +++ pyinstaller/requirements.txt | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/innosetup.iss b/innosetup.iss index c16957c..8198203 100644 --- a/innosetup.iss +++ b/innosetup.iss @@ -12,11 +12,14 @@ AppId={#Organization}_{#Name}_{#Version} AppName={#Name} AppVersion={#Version} +AppPublisher=HEPHY Detector Development +AppPublisherURL=https://github.com/hephy-dd/ DefaultDirName={userappdata}\{#Organization}\sqc\{#Version} DefaultGroupName={#Name} OutputDir=dist OutputBaseFilename=sqc-{#Version}-win-x64-setup SetupIconFile=src\sqc\assets\icons\sqc.ico +UninstallDisplayIcon={app}\{#ExeName} Compression=lzma SolidCompression=yes PrivilegesRequired=lowest diff --git a/pyinstaller/requirements.txt b/pyinstaller/requirements.txt index b47203c..d8e4d79 100644 --- a/pyinstaller/requirements.txt +++ b/pyinstaller/requirements.txt @@ -2,5 +2,5 @@ wheel pyusb==1.2.1 pyserial==3.5 gpib-ctypes==0.3.0 -pyinstaller==5.13.2 +pyinstaller==6.7.0 pyinstaller-versionfile==2.1.1 From 94983561bb3d63f913eddfa30294585b75cf21f9 Mon Sep 17 00:00:00 2001 From: Bernhard Arnold Date: Mon, 16 Sep 2024 13:12:23 +0200 Subject: [PATCH 03/14] pyproject.toml, ruff, bump version --- .github/workflows/python-package.yml | 15 ++------ changelog | 6 +++ pyproject.toml | 36 ++++++++++++++++++ setup.cfg | 56 ---------------------------- src/sqc/__init__.py | 2 +- tox.ini | 6 +-- 6 files changed, 48 insertions(+), 73 deletions(-) delete mode 100644 setup.cfg diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index c6fe14d..bedccd5 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -35,20 +35,11 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install wheel flake8 pylint pytest + pip install tox pip install -e . - - name: Lint with flake8 + - name: Test with tox run: | - # stop the build if there are Python syntax errors or undefined names - flake8 src --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 src --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Lint with pylint - run: | - pylint -E src - - name: Test with pytest - run: | - pytest + tox -epy - name: Run application run: | python -m sqc --version diff --git a/changelog b/changelog index 30760d9..0e32860 100644 --- a/changelog +++ b/changelog @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.11.0] - 2024-09-16 + +### Changed +- Switched to pyproject.toml config. +- Switched to ruff linter. + ## [0.10.2] - 2024-04-26 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index fed528d..bf2bdb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,39 @@ +[project] +name = "sqc" +description = "Sensor Quality Control" +readme = "README.md" +authors = [ + {name = "Bernhard Arnold", email = "bernhard.arnold@oeaw.ac.at"}, +] +requires-python = ">=3.9" +dependencies = [ + "comet @ git+https://github.com/hephy-dd/comet.git@main", + "PyQt5==5.15.10", + "PyQtChart==5.15.6", + "PyYAML==6.0.1", + "numpy==1.26.4", + "scipy==1.12.0", + "schema==0.7.5", + "pyueye==4.95.0", +] +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/hephy-dd/sqc" +Documentation = "https://hephy-dd.github.io/sqc/" + +[project.scripts] +sqc = "sqc.__main__:main" + [build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +"sqc.assets.icons" = ["*.svg", "*.png", "*.ico"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index f6a7132..0000000 --- a/setup.cfg +++ /dev/null @@ -1,56 +0,0 @@ -[metadata] -name = sqc -version = attr: sqc.__version__ -author = Bernhard Arnold -author_email = bernhard.arnold@oeaw.ac.at -description = Sensor Quality Control -long_description = file: README.md -long_description_content_type = text/markdown -license = GPLv3 - -[options] -python_requires = >=3.9 -install_requires = - comet @ git+https://github.com/hephy-dd/comet.git@v1.0.0 - PyQt5==5.15.10 - PyQtChart==5.15.6 - PyYAML==6.0.1 - numpy==1.26.4 - scipy==1.12.0 - schema==0.7.5 - pyueye==4.95.0 -test_suite = tests -include_package_data = True - -[options.packages.find] -exclude = tests - -[options.package_data] -sqc.assets.icons = - *.svg - *.png - *.ico - -[options.entry_points] -console_scripts = - sqc = sqc.__main__:main - -[flake8] -exclude = env - -[mypy] - -[mypy-pyueye.*] -ignore_missing_imports = True - -[mypy-pint.*] -ignore_missing_imports = True - -[mypy-scipy.*] -ignore_missing_imports = True - -[mypy-schema.*] -ignore_missing_imports = True - -[mypy-comet.*] -ignore_missing_imports = True diff --git a/src/sqc/__init__.py b/src/sqc/__init__.py index 17c1a62..ae6db5f 100644 --- a/src/sqc/__init__.py +++ b/src/sqc/__init__.py @@ -1 +1 @@ -__version__ = "0.10.2" +__version__ = "0.11.0" diff --git a/tox.ini b/tox.ini index 37cad16..887974e 100644 --- a/tox.ini +++ b/tox.ini @@ -5,14 +5,12 @@ skip_missing_interpreters = true [testenv] deps = - flake8 - pylint + ruff mypy types-PyYAML PyQt5-stubs pytest commands = - flake8 src --select=E9,F63,F7,F82 - pylint -E src + ruff check src --select=E9,F63,F7,F82 mypy src pytest From cf2f5f04328ca6a78b669d94f93539f6f4523999 Mon Sep 17 00:00:00 2001 From: Bernhard Arnold Date: Mon, 16 Sep 2024 13:18:10 +0200 Subject: [PATCH 04/14] updated github actions --- .github/workflows/docs.yml | 4 ++-- .github/workflows/python-package.yml | 8 ++++---- .github/workflows/windows-app.yml | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index ec04c9e..d137445 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -12,10 +12,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Python 3 - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 - name: Install dependencies run: | diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index bedccd5..9a35549 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -13,19 +13,19 @@ jobs: os: [ubuntu-latest, windows-latest] python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - uses: actions/cache@v2 + - uses: actions/cache@v4 if: startsWith(runner.os, 'Linux') with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.cfg') }} restore-keys: | ${{ runner.os }}-pip- - - uses: actions/cache@v2 + - uses: actions/cache@v4 if: startsWith(runner.os, 'Windows') with: path: ~\AppData\Local\pip\Cache diff --git a/.github/workflows/windows-app.yml b/.github/workflows/windows-app.yml index 1184657..07cb7dd 100644 --- a/.github/workflows/windows-app.yml +++ b/.github/workflows/windows-app.yml @@ -13,9 +13,9 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python 3.11 - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: 3.11 - name: Install dependencies @@ -27,7 +27,7 @@ jobs: run: | pyinstaller pyinstaller/windows_app.spec - name: Publish - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: sqc-artifact path: dist/sqc*.exe From 31dc6db46f6d10b325edcb75f66b3bb2dfaa50a1 Mon Sep 17 00:00:00 2001 From: Bernhard Arnold Date: Mon, 16 Sep 2024 13:19:02 +0200 Subject: [PATCH 05/14] added mypy config --- mypy.ini | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 mypy.ini diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..a4016b3 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,16 @@ +[mypy] + +[mypy-pyueye.*] +ignore_missing_imports = True + +[mypy-pint.*] +ignore_missing_imports = True + +[mypy-scipy.*] +ignore_missing_imports = True + +[mypy-schema.*] +ignore_missing_imports = True + +[mypy-comet.*] +ignore_missing_imports = True From be95e67ac48868a2a3871ab1abe57203f4633da2 Mon Sep 17 00:00:00 2001 From: Bernhard Arnold Date: Mon, 16 Sep 2024 16:54:38 +0200 Subject: [PATCH 06/14] refs #25 wip bad strip select --- src/sqc/context.py | 2 + src/sqc/gui/badstrips.py | 225 ++++++++++++++++++++++++++++++-------- src/sqc/gui/dashboard.py | 2 + src/sqc/gui/plotarea.py | 17 +++ src/sqc/gui/plotwidget.py | 24 ++++ src/sqc/gui/sequence.py | 4 +- 6 files changed, 228 insertions(+), 46 deletions(-) diff --git a/src/sqc/context.py b/src/sqc/context.py index 2ee5df2..af101d3 100644 --- a/src/sqc/context.py +++ b/src/sqc/context.py @@ -73,6 +73,8 @@ class Context(QtCore.QObject): exception_raised = QtCore.pyqtSignal(Exception) + update_boxes = QtCore.pyqtSignal(list) + def __init__(self, station: Station, parent: Optional[QtCore.QObject] = None) -> None: super().__init__(parent) self._station: Station = station diff --git a/src/sqc/gui/badstrips.py b/src/sqc/gui/badstrips.py index 1322667..69ae692 100644 --- a/src/sqc/gui/badstrips.py +++ b/src/sqc/gui/badstrips.py @@ -1,25 +1,110 @@ +import logging from typing import Dict, Optional from PyQt5 import QtCore, QtWidgets +def safe_float(text: str) -> float: + try: + return float(text) + except Exception: + return 0.0 + + +class ComboBoxDelegate(QtWidgets.QStyledItemDelegate): + typeChanged = QtCore.pyqtSignal(object, str) + + def __init__(self, constraints, parent=None): + super().__init__(parent) + self.constraints = constraints + + def createEditor(self, parent, option, index) -> QtWidgets.QWidget: + combo = QtWidgets.QComboBox(parent) + combo.addItems(self.constraints) + return combo + + def setEditorData(self, editor, index) -> None: + value = index.data(QtCore.Qt.DisplayRole) + editor.setCurrentText(value) + + def setModelData(self, editor, model, index) -> None: + model.setData(index, editor.currentText(), QtCore.Qt.EditRole) + self.typeChanged.emit(index, editor.currentText()) + + +class SpinBoxDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, parent=None): + super().__init__(parent) + + def createEditor(self, parent, option, index) -> QtWidgets.QWidget: + spinbox = QtWidgets.QSpinBox(parent) + spinbox.setRange(0, 10000000) + return spinbox + + def setEditorData(self, editor, index) -> None: + value = int(float(index.data(QtCore.Qt.DisplayRole).split()[0].strip())) + editor.setValue(value) + + def setModelData(self, editor, model, index) -> None: + model.setData(index, f"{editor.value()}", QtCore.Qt.EditRole) + + +class DoubleSpinBoxDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, parent=None): + super().__init__(parent) + self.suffix = "" + + def createEditor(self, parent, option, index) -> QtWidgets.QWidget: + spinbox = QtWidgets.QDoubleSpinBox(parent) + spinbox.setDecimals(3) + spinbox.setRange(-1000000, 1000000) + return spinbox + + def setEditorData(self, editor, index) -> None: + self.suffix = " " + index.data(QtCore.Qt.DisplayRole).split()[-1].strip() + editor.setSuffix(self.suffix) + value = float(index.data(QtCore.Qt.DisplayRole).split()[0].strip()) + editor.setValue(value) + + def setModelData(self, editor, model, index) -> None: + model.setData(index, f"{editor.value():.3f} {self.suffix}", QtCore.Qt.EditRole) + + + class BadStripSelectDialog(QtWidgets.QDialog): - def __init__(self, parent: Optional[QtWidgets.QWidget]) -> None: + def __init__(self, context, parent: Optional[QtWidgets.QWidget]) -> None: super().__init__(parent) self.setObjectName("BadStripSelectDialog") self.setWindowTitle("Select Bad Strips") - self.statistics = None + self.context = context + + self.boxesTreeWidget = QtWidgets.QTreeWidget() + self.boxesTreeWidget.setHeaderLabels(["Type", "First Strip", "Last Strip", "Minimum", "Maximum"]) + + # TODO + self.constraints = ["rpoly", "istrip", "idiel", "cac", "cint", "rint", "idark", ] - self.remeasureCountSpinBox = QtWidgets.QSpinBox(self) - self.remeasureCountSpinBox.setRange(1, 99) - self.remeasureCountSpinBox.valueChanged.connect(self.updatePreview) + typeDelegate = ComboBoxDelegate(self.constraints, self.boxesTreeWidget) + #typeDelegate.typeChanged.connect(self._updateType) + self.boxesTreeWidget.setItemDelegateForColumn(0, typeDelegate) - self.previewTreeWidget = QtWidgets.QTreeWidget(self) - self.previewTreeWidget.setHeaderLabels(["Strip", "Count"]) - self.previewTreeWidget.setRootIsDecorated(False) + self.boxesTreeWidget.setItemDelegateForColumn(1, SpinBoxDelegate(self.boxesTreeWidget)) + self.boxesTreeWidget.setItemDelegateForColumn(2, SpinBoxDelegate(self.boxesTreeWidget)) + self.boxesTreeWidget.setItemDelegateForColumn(3, DoubleSpinBoxDelegate(self.boxesTreeWidget)) + self.boxesTreeWidget.setItemDelegateForColumn(4, DoubleSpinBoxDelegate(self.boxesTreeWidget)) + + self.boxesTreeWidget.itemChanged.connect(self.updateBoxes) + + self.addBoxButton = QtWidgets.QPushButton() + self.addBoxButton.setText("&Add") + self.addBoxButton.clicked.connect(self.newBox) + + self.removeBoxButton = QtWidgets.QPushButton() + self.removeBoxButton.setText("&Remove") + self.removeBoxButton.clicked.connect(self.removeCurrentBox) self.buttonBox = QtWidgets.QDialogButtonBox(self) self.buttonBox.addButton(QtWidgets.QDialogButtonBox.Ok) @@ -27,58 +112,110 @@ def __init__(self, parent: Optional[QtWidgets.QWidget]) -> None: self.buttonBox.accepted.connect(self.accept) self.buttonBox.rejected.connect(self.reject) + boxesLayout = QtWidgets.QGridLayout() + boxesLayout.addWidget(self.boxesTreeWidget, 0, 0, 3, 1) + boxesLayout.addWidget(self.addBoxButton, 0, 1, 1, 1) + boxesLayout.addWidget(self.removeBoxButton, 1, 1, 1, 1) + layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(QtWidgets.QLabel("Minimal Re-Measure Count")) - layout.addWidget(self.remeasureCountSpinBox) - layout.addWidget(QtWidgets.QLabel("Preview")) - layout.addWidget(self.previewTreeWidget) + layout.addWidget(QtWidgets.QLabel("Bounding Boxes")) + layout.addLayout(boxesLayout) layout.addWidget(self.buttonBox) + layout.setStretch(0, 0) + layout.setStretch(1, 1) + layout.setStretch(2, 0) + + def newBox(self) -> None: + item = QtWidgets.QTreeWidgetItem(self.boxesTreeWidget) + item.setText(0, "") + item.setText(1, "0") + item.setText(2, "0") + item.setText(3, "0") + item.setText(4, "0") + item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsUserCheckable) + item.setCheckState(0, QtCore.Qt.Checked) + + def removeCurrentBox(self) -> None: + item = self.boxesTreeWidget.currentItem() + if item: + index = self.boxesTreeWidget.indexOfTopLevelItem(item) + self.boxesTreeWidget.takeTopLevelItem(index) + + def updateBoxes(self) -> None: + boxes = [] + for index in range(self.boxesTreeWidget.topLevelItemCount()): + item = self.boxesTreeWidget.topLevelItem(index) + if item and item.checkState(0) == QtCore.Qt.Checked: + type = item.text(0) + first_strip = safe_float(item.text(1).split()[0]) + last_strip = safe_float(item.text(2).split()[0]) + minimum_value = safe_float(item.text(3).split()[0]) + maximum_value = safe_float(item.text(4).split()[0]) + topLeft = QtCore.QPointF(first_strip, maximum_value) + bottomRight = QtCore.QPointF(last_strip, minimum_value) + boxes.append((type, QtCore.QRectF(topLeft, bottomRight))) + self.context.update_boxes.emit(boxes) + + def addBoundingBox(self, enabled: bool, type: str, first_strip: int, last_strip: int, minimum_value: float, maximum_value: float) -> None: + item = QtWidgets.QTreeWidgetItem(self.boxesTreeWidget) + item.setText(0, str(type)) + item.setText(1, str(first_strip)) + item.setText(2, str(last_strip)) + item.setText(3, str(minimum_value)) + item.setText(4, str(maximum_value)) + item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsUserCheckable) + item.setCheckState(0, QtCore.Qt.Checked if enabled else QtCore.Qt.Unchecked) + + def clearBoundingBoxes(self) -> None: + while self.boxesTreeWidget.topLevelItemCount(): + self.boxesTreeWidget.takeTopLevelItem(0) + + def boundingBoxes(self) -> list[dict]: + bboxes = [] + for index in range(self.boxesTreeWidget.topLevelItemCount()): + item = self.boxesTreeWidget.topLevelItem(index) + if item: + bboxes.append({ + "enabled": item.checkState(0) == QtCore.Qt.Checked, + "type": item.text(0), + "first_strip": int(safe_float(item.text(1).split()[0])), + "last_strip": int(safe_float(item.text(2).split()[0])), + "minimum_value": safe_float(item.text(3).split()[0]), + "maximum_value": safe_float(item.text(4).split()[0]), + }) + return bboxes def readSettings(self) -> None: settings = QtCore.QSettings() settings.beginGroup(self.objectName()) geometry = settings.value("geometry", QtCore.QByteArray(), QtCore.QByteArray) - remeasureCount = settings.value("remeasureCount", 1, int) + boundingBoxes = settings.value("boundingBoxes", [], list) settings.endGroup() self.restoreGeometry(geometry) - self.setRemeasureCount(remeasureCount) + self.clearBoundingBoxes() + for boundingBox in boundingBoxes: + if isinstance(boundingBox, dict): + try: + self.addBoundingBox( + enabled=boundingBox.get("enabled", True), + type=boundingBox.get("type", ""), + first_strip=int(safe_float(boundingBox.get("first_strip", 0))), + last_strip=int(safe_float(boundingBox.get("last_strip", 0))), + minimum_value=safe_float(boundingBox.get("minimum_value", 0.0)), + maximum_value=safe_float(boundingBox.get("maximum_value", 0.0)), + ) + except Exception as exc: + logging.exception(exc) def writeSettings(self) -> None: settings = QtCore.QSettings() settings.beginGroup(self.objectName()) settings.setValue("geometry", self.saveGeometry()) - settings.setValue("remeasureCount", self.remeasureCount()) + settings.setValue("boundingBoxes", self.boundingBoxes()) settings.endGroup() - def setRemeasureCount(self, count: int) -> None: - self.remeasureCountSpinBox.setValue(count) - - def remeasureCount(self) -> int: - return self.remeasureCountSpinBox.value() - - def setStatistics(self, statistics) -> None: - self.statistics = statistics - self.updatePreview() - def selectedStrips(self) -> str: - threshold = self.remeasureCount() - strips = self.filterStrips(threshold).keys() - return ", ".join([format(strip) for strip in strips]) + return "1-42" # TODO def updatePreview(self) -> None: - threshold = self.remeasureCount() - self.previewTreeWidget.clear() - root = self.previewTreeWidget.invisibleRootItem() - for strip, count in self.filterStrips(threshold).items(): - item = QtWidgets.QTreeWidgetItem([str(strip), str(count)]) - root.addChild(item) - - def filterStrips(self, threshold: int) -> Dict[str, int]: - strips: Dict[str, int] = {} - if self.statistics is not None: - for strip, counter in self.statistics.remeasure_counter.items(): - values = counter.values() - max_value = max(values or [0]) - if max_value >= threshold: - strips[strip] = max_value - return strips + ... diff --git a/src/sqc/gui/dashboard.py b/src/sqc/gui/dashboard.py index 0ab5cf4..4948fc8 100644 --- a/src/sqc/gui/dashboard.py +++ b/src/sqc/gui/dashboard.py @@ -433,6 +433,8 @@ def __init__(self, context, parent: Optional[QtWidgets.QWidget] = None) -> None: self.environUpdateTimer.timeout.connect(self.updateEnvironData) self.environUpdateTimer.start(500) + self.context.update_boxes.connect(self.stripscanPlotAreaWidget.updateBoxes) + def readSettings(self) -> None: settings = QtCore.QSettings() diff --git a/src/sqc/gui/plotarea.py b/src/sqc/gui/plotarea.py index ea3a854..aea20dd 100644 --- a/src/sqc/gui/plotarea.py +++ b/src/sqc/gui/plotarea.py @@ -168,3 +168,20 @@ def addScatterSeries(self, type: str, name: str) -> object: def setStrips(self, strips: Dict[int, str]) -> None: for widget in self.plotWidgets(): widget.setStrips(strips) + + def clearBoxes(self) -> None: + for widget in self.plotWidgets(): + widget.clearBoxes() + widget.updateBoxes() + + def updateBoxes(self, boxes) -> None: + self.clearBoxes() + sortedRects = {} + for name, rect in boxes: + sortedRects.setdefault(name, []).append(rect) + for name, rects in sortedRects.items(): + widget = self.plotWidget(name) + if widget: + for rect in rects: + widget.addBox(rect) + widget.updateBoxes() diff --git a/src/sqc/gui/plotwidget.py b/src/sqc/gui/plotwidget.py index 9d96a1c..b8241b9 100644 --- a/src/sqc/gui/plotwidget.py +++ b/src/sqc/gui/plotwidget.py @@ -464,6 +464,30 @@ def __init__(self, title: str, parent: Optional[QtWidgets.QWidget] = None) -> No self._chartView.setRubberBand(self._chartView.HorizontalRubberBand) self._chartView.marker().setTextFormatter(self.formatMarkerText) + self._boxes: dict[QtWidgets.QGraphicsRectItem, QtCore.QRectF] = {} + + def addBox(self, rect: QtCore.QRectF) -> None: + item = QtWidgets.QGraphicsRectItem() + item.setPen(QtGui.QPen(QtCore.Qt.red)) + item.setBrush(QtGui.QBrush(QtGui.QColor(255, 0, 0, 50))) + self._boxes.update({item: rect}) + self._chart.scene().addItem(item) + self.updateBoxes() + + def clearBoxes(self) -> None: + for item in self._boxes.keys(): + self._chart.scene().removeItem(item) + self._boxes.clear() + + def updateBoxes(self): + series = self._chart.series() + if series: + for item, box in self._boxes.items(): + topLeft = self._chart.mapToPosition(box.topLeft(), series[0]) + bottomRight = self._chart.mapToPosition(box.bottomRight(), series[0]) + item.setRect(QtCore.QRectF(topLeft, bottomRight)) + self.update() + def strips(self) -> dict: return self._strips diff --git a/src/sqc/gui/sequence.py b/src/sqc/gui/sequence.py index 82ee8b3..4503307 100644 --- a/src/sqc/gui/sequence.py +++ b/src/sqc/gui/sequence.py @@ -334,12 +334,12 @@ def contextMenuEvent(self, event): if res == restoreStripsAction: item.setStrips(item.defaultStrips()) elif res == selectBadStripsAction: - dialog = BadStripSelectDialog(self) - dialog.setStatistics(self.context.statistics) + dialog = BadStripSelectDialog(self.context, self) dialog.readSettings() if dialog.exec() == dialog.Accepted: item.setStrips(dialog.selectedStrips()) dialog.writeSettings() + self.context.update_boxes.emit([]) elif res == resetIntervalsAction: for child in item.allChildren(): child.setInterval(1) From 1100545cfd98bfe5034b7bef565bfa5fcc2bfba7 Mon Sep 17 00:00:00 2001 From: Bernhard Arnold Date: Mon, 16 Sep 2024 17:08:51 +0200 Subject: [PATCH 07/14] refs #25 wip fixes --- src/sqc/gui/plotarea.py | 2 +- src/sqc/gui/plotwidget.py | 48 +++++++++++++++++++-------------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/sqc/gui/plotarea.py b/src/sqc/gui/plotarea.py index aea20dd..331f782 100644 --- a/src/sqc/gui/plotarea.py +++ b/src/sqc/gui/plotarea.py @@ -176,7 +176,7 @@ def clearBoxes(self) -> None: def updateBoxes(self, boxes) -> None: self.clearBoxes() - sortedRects = {} + sortedRects: dict = {} for name, rect in boxes: sortedRects.setdefault(name, []).append(rect) for name, rects in sortedRects.items(): diff --git a/src/sqc/gui/plotwidget.py b/src/sqc/gui/plotwidget.py index b8241b9..941ad7b 100644 --- a/src/sqc/gui/plotwidget.py +++ b/src/sqc/gui/plotwidget.py @@ -298,6 +298,30 @@ def __init__(self, title: str, parent: Optional[QtWidgets.QWidget] = None) -> No layout.addWidget(self._chartView) layout.setContentsMargins(0, 0, 0, 0) + self._boxes: dict[QtWidgets.QGraphicsRectItem, QtCore.QRectF] = {} + + def addBox(self, rect: QtCore.QRectF) -> None: + item = QtWidgets.QGraphicsRectItem() + item.setPen(QtGui.QPen(QtCore.Qt.red)) + item.setBrush(QtGui.QBrush(QtGui.QColor(255, 0, 0, 50))) + self._boxes.update({item: rect}) + self._chart.scene().addItem(item) + self.updateBoxes() + + def clearBoxes(self) -> None: + for item in self._boxes.keys(): + self._chart.scene().removeItem(item) + self._boxes.clear() + + def updateBoxes(self): + series = self._chart.series() + if series: + for item, box in self._boxes.items(): + topLeft = self._chart.mapToPosition(box.topLeft(), series[0]) + bottomRight = self._chart.mapToPosition(box.bottomRight(), series[0]) + item.setRect(QtCore.QRectF(topLeft, bottomRight)) + self.updateGeometry() + def title(self) -> str: return self._chart.title() @@ -464,30 +488,6 @@ def __init__(self, title: str, parent: Optional[QtWidgets.QWidget] = None) -> No self._chartView.setRubberBand(self._chartView.HorizontalRubberBand) self._chartView.marker().setTextFormatter(self.formatMarkerText) - self._boxes: dict[QtWidgets.QGraphicsRectItem, QtCore.QRectF] = {} - - def addBox(self, rect: QtCore.QRectF) -> None: - item = QtWidgets.QGraphicsRectItem() - item.setPen(QtGui.QPen(QtCore.Qt.red)) - item.setBrush(QtGui.QBrush(QtGui.QColor(255, 0, 0, 50))) - self._boxes.update({item: rect}) - self._chart.scene().addItem(item) - self.updateBoxes() - - def clearBoxes(self) -> None: - for item in self._boxes.keys(): - self._chart.scene().removeItem(item) - self._boxes.clear() - - def updateBoxes(self): - series = self._chart.series() - if series: - for item, box in self._boxes.items(): - topLeft = self._chart.mapToPosition(box.topLeft(), series[0]) - bottomRight = self._chart.mapToPosition(box.bottomRight(), series[0]) - item.setRect(QtCore.QRectF(topLeft, bottomRight)) - self.update() - def strips(self) -> dict: return self._strips From 321a531ab647421e63c000d5c3f9f890080c770b Mon Sep 17 00:00:00 2001 From: Bernhard Arnold Date: Wed, 18 Sep 2024 18:20:56 +0200 Subject: [PATCH 08/14] added --import-json option --- src/sqc/__main__.py | 7 +++++++ src/sqc/context.py | 11 +++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/sqc/__main__.py b/src/sqc/__main__.py index 62763b1..06530d0 100644 --- a/src/sqc/__main__.py +++ b/src/sqc/__main__.py @@ -1,4 +1,5 @@ import argparse +import json import logging import os import sys @@ -28,6 +29,7 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(prog="sqc", description="Sensor Quality Control (SQC) for CMS Outer Tracker.") parser.add_argument('--browser', metavar="", nargs="?", const=os.path.expanduser("~"), help="run data browser") + parser.add_argument('--import-json', metavar="", help="import JSON measurement file (testing)") parser.add_argument('--debug-no-table', action='store_true', help="disable sequence table movements") parser.add_argument('--debug-no-tango', action='store_true', help="disable sequence tango movements") parser.add_argument('--debug', action='store_true', help="show debug messages") @@ -109,6 +111,11 @@ def run_main_window(args): window.readSettings() window.show() + if args.import_json: + with open(args.import_json, "rt") as fp: + data = json.load(fp) + context.import_data(data.get("namespace", ""), data.get("data", {})) + QtWidgets.QApplication.exec() window.writeSettings() diff --git a/src/sqc/context.py b/src/sqc/context.py index af101d3..f86c219 100644 --- a/src/sqc/context.py +++ b/src/sqc/context.py @@ -73,8 +73,6 @@ class Context(QtCore.QObject): exception_raised = QtCore.pyqtSignal(Exception) - update_boxes = QtCore.pyqtSignal(list) - def __init__(self, station: Station, parent: Optional[QtCore.QObject] = None) -> None: super().__init__(parent) self._station: Station = station @@ -178,6 +176,15 @@ def insert_data(self, namespace: str, type: str, name: str, data: dict, sortkey: logger.debug("inserted data: namespace=%r, type=%r, name=%r, data=%r", namespace, type, name, data) self.data_changed.emit(namespace, type, name) + def import_data(self, namespace: str, data: dict) -> None: + for type, type_data in data.items(): + for name, name_data in type_data.items(): + for item in name_data: + items: Dict[str, Dict] = self._data.setdefault(namespace, {}).setdefault(type, {}).get(name, []) + self._data.setdefault(namespace, {}).setdefault(type, {})[name] = auto_insert_item(items, item, sortkey="strip_index") + self.data_changed.emit(namespace, type, name) + logger.debug("imported data: namespace=%r, type=%r, name=%r", namespace, type, name) + # Statistics @property From 4434cfb257fe4be23444a922735b86397e40a883 Mon Sep 17 00:00:00 2001 From: Bernhard Arnold Date: Wed, 18 Sep 2024 18:21:28 +0200 Subject: [PATCH 09/14] refs #25 added bounding boxes and markers for bad strip detection --- src/sqc/gui/badstrips.py | 235 ++++++++++++++++++++++++--------- src/sqc/gui/dashboard.py | 30 ++++- src/sqc/gui/plotarea.py | 19 ++- src/sqc/gui/plothighlighter.py | 82 ++++++++++++ src/sqc/gui/plotwidget.py | 61 +++++---- src/sqc/gui/sequence.py | 11 +- 6 files changed, 336 insertions(+), 102 deletions(-) create mode 100644 src/sqc/gui/plothighlighter.py diff --git a/src/sqc/gui/badstrips.py b/src/sqc/gui/badstrips.py index 69ae692..583508c 100644 --- a/src/sqc/gui/badstrips.py +++ b/src/sqc/gui/badstrips.py @@ -3,6 +3,8 @@ from PyQt5 import QtCore, QtWidgets +from comet.utils import ureg + def safe_float(text: str) -> float: try: @@ -11,16 +13,36 @@ def safe_float(text: str) -> float: return 0.0 +def compress_strips(strips: list) -> str: + if not len(strips): + return "" + values = sorted(strips) + ranges = [] + start = values[0] + end = values[0] + for i in range(1, len(values)): + if values[i] == end + 1: + end = values[i] + else: + if start == end: + ranges.append(f"{start}") + else: + ranges.append(f"{start}-{end}") + start = end = values[i] + ranges.append(f"{start}" if start == end else f"{start}-{end}") + return ", ".join(ranges) + + class ComboBoxDelegate(QtWidgets.QStyledItemDelegate): - typeChanged = QtCore.pyqtSignal(object, str) + typeChanged = QtCore.pyqtSignal(object) - def __init__(self, constraints, parent=None): + def __init__(self, units, parent=None): super().__init__(parent) - self.constraints = constraints + self.units = units def createEditor(self, parent, option, index) -> QtWidgets.QWidget: combo = QtWidgets.QComboBox(parent) - combo.addItems(self.constraints) + combo.addItems(list(self.units.keys())) return combo def setEditorData(self, editor, index) -> None: @@ -29,7 +51,7 @@ def setEditorData(self, editor, index) -> None: def setModelData(self, editor, model, index) -> None: model.setData(index, editor.currentText(), QtCore.Qt.EditRole) - self.typeChanged.emit(index, editor.currentText()) + self.typeChanged.emit(index) class SpinBoxDelegate(QtWidgets.QStyledItemDelegate): @@ -50,51 +72,71 @@ def setModelData(self, editor, model, index) -> None: class DoubleSpinBoxDelegate(QtWidgets.QStyledItemDelegate): - def __init__(self, parent=None): - super().__init__(parent) - self.suffix = "" - def createEditor(self, parent, option, index) -> QtWidgets.QWidget: spinbox = QtWidgets.QDoubleSpinBox(parent) spinbox.setDecimals(3) - spinbox.setRange(-1000000, 1000000) + spinbox.setRange(-1e12, 1e12) return spinbox def setEditorData(self, editor, index) -> None: - self.suffix = " " + index.data(QtCore.Qt.DisplayRole).split()[-1].strip() - editor.setSuffix(self.suffix) - value = float(index.data(QtCore.Qt.DisplayRole).split()[0].strip()) + value = float(index.data(QtCore.Qt.DisplayRole)) editor.setValue(value) def setModelData(self, editor, model, index) -> None: - model.setData(index, f"{editor.value():.3f} {self.suffix}", QtCore.Qt.EditRole) + model.setData(index, f"{editor.value():.3f}", QtCore.Qt.EditRole) + + +class UnitDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, units: dict, parent=None): + super().__init__(parent) + self.units = units + + def createEditor(self, parent, option, index) -> QtWidgets.QWidget: + return QtWidgets.QComboBox(parent) + def setEditorData(self, editor, index) -> None: + type_ = index.sibling(index.row(), 0).data(QtCore.Qt.DisplayRole) + units = self.units.get(type_, []) + editor.addItems(units) + value = index.data(QtCore.Qt.DisplayRole) + editor.setCurrentText(value) + + def setModelData(self, editor, model, index) -> None: + model.setData(index, editor.currentText(), QtCore.Qt.EditRole) class BadStripSelectDialog(QtWidgets.QDialog): + boxesChanged = QtCore.pyqtSignal(list) # list[BoundingBox] + markersChanged = QtCore.pyqtSignal(list) # list[Marker] - def __init__(self, context, parent: Optional[QtWidgets.QWidget]) -> None: + def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None: super().__init__(parent) + self._data: dict = {} + self.units: dict = {} + self.fields: dict = {} + self.setObjectName("BadStripSelectDialog") self.setWindowTitle("Select Bad Strips") - self.context = context + self.boxesLabel = QtWidgets.QLabel(self) + self.boxesLabel.setText("Bounding Boxes") self.boxesTreeWidget = QtWidgets.QTreeWidget() - self.boxesTreeWidget.setHeaderLabels(["Type", "First Strip", "Last Strip", "Minimum", "Maximum"]) + self.boxesTreeWidget.setRootIsDecorated(False) + self.boxesTreeWidget.setHeaderLabels(["Type", "First Strip", "Last Strip", "Minimum", "Maximum", "Unit"]) + self.boxesTreeWidget.setSortingEnabled(True) + self.boxesTreeWidget.sortByColumn(0, QtCore.Qt.AscendingOrder) - # TODO - self.constraints = ["rpoly", "istrip", "idiel", "cac", "cint", "rint", "idark", ] + typeDelegate = ComboBoxDelegate(self.units, self.boxesTreeWidget) + typeDelegate.typeChanged.connect(self.updateType) - typeDelegate = ComboBoxDelegate(self.constraints, self.boxesTreeWidget) - #typeDelegate.typeChanged.connect(self._updateType) self.boxesTreeWidget.setItemDelegateForColumn(0, typeDelegate) - self.boxesTreeWidget.setItemDelegateForColumn(1, SpinBoxDelegate(self.boxesTreeWidget)) self.boxesTreeWidget.setItemDelegateForColumn(2, SpinBoxDelegate(self.boxesTreeWidget)) self.boxesTreeWidget.setItemDelegateForColumn(3, DoubleSpinBoxDelegate(self.boxesTreeWidget)) self.boxesTreeWidget.setItemDelegateForColumn(4, DoubleSpinBoxDelegate(self.boxesTreeWidget)) + self.boxesTreeWidget.setItemDelegateForColumn(5, UnitDelegate(self.units, self.boxesTreeWidget)) self.boxesTreeWidget.itemChanged.connect(self.updateBoxes) @@ -106,34 +148,52 @@ def __init__(self, context, parent: Optional[QtWidgets.QWidget]) -> None: self.removeBoxButton.setText("&Remove") self.removeBoxButton.clicked.connect(self.removeCurrentBox) + self.previewLabel = QtWidgets.QLabel(self) + self.previewLabel.setText("Preview") + + self.previewLineEdit = QtWidgets.QLineEdit(self) + self.previewLineEdit.setReadOnly(True) + self.buttonBox = QtWidgets.QDialogButtonBox(self) self.buttonBox.addButton(QtWidgets.QDialogButtonBox.Ok) self.buttonBox.addButton(QtWidgets.QDialogButtonBox.Cancel) self.buttonBox.accepted.connect(self.accept) self.buttonBox.rejected.connect(self.reject) - boxesLayout = QtWidgets.QGridLayout() - boxesLayout.addWidget(self.boxesTreeWidget, 0, 0, 3, 1) - boxesLayout.addWidget(self.addBoxButton, 0, 1, 1, 1) - boxesLayout.addWidget(self.removeBoxButton, 1, 1, 1, 1) + self.boxesLayout = QtWidgets.QGridLayout() + self.boxesLayout.addWidget(self.boxesTreeWidget, 0, 0, 3, 1) + self.boxesLayout.addWidget(self.addBoxButton, 0, 1, 1, 1) + self.boxesLayout.addWidget(self.removeBoxButton, 1, 1, 1, 1) layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(QtWidgets.QLabel("Bounding Boxes")) - layout.addLayout(boxesLayout) + layout.addWidget(self.boxesLabel) + layout.addLayout(self.boxesLayout) + layout.addWidget(self.previewLabel) + layout.addWidget(self.previewLineEdit) layout.addWidget(self.buttonBox) - layout.setStretch(0, 0) layout.setStretch(1, 1) - layout.setStretch(2, 0) + + def addType(self, type: str, field: str, units: list[str]) -> None: + self.units[type] = units + self.fields[type] = field + + def setData(self, data: dict) -> None: + self._data = data + + def updateType(self, index) -> None: + item = self.boxesTreeWidget.itemFromIndex(index) + if item: + type_ = item.text(0) + unit = self.units.get(type_, [""])[0] + item.setText(3, "0") + item.setText(4, "0") + item.setText(5, unit) def newBox(self) -> None: - item = QtWidgets.QTreeWidgetItem(self.boxesTreeWidget) - item.setText(0, "") - item.setText(1, "0") - item.setText(2, "0") - item.setText(3, "0") - item.setText(4, "0") - item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsUserCheckable) - item.setCheckState(0, QtCore.Qt.Checked) + for type_, units in self.units.items(): + if units: + self.addBoundingBox(True, type_, 0, 0, 0, 0, units[0]) + break def removeCurrentBox(self) -> None: item = self.boxesTreeWidget.currentItem() @@ -142,27 +202,38 @@ def removeCurrentBox(self) -> None: self.boxesTreeWidget.takeTopLevelItem(index) def updateBoxes(self) -> None: - boxes = [] + boundingBoxes: list = [] + markers: list = [] + strips: set = set() for index in range(self.boxesTreeWidget.topLevelItemCount()): item = self.boxesTreeWidget.topLevelItem(index) if item and item.checkState(0) == QtCore.Qt.Checked: - type = item.text(0) - first_strip = safe_float(item.text(1).split()[0]) - last_strip = safe_float(item.text(2).split()[0]) - minimum_value = safe_float(item.text(3).split()[0]) - maximum_value = safe_float(item.text(4).split()[0]) + type_ = item.text(0) + unit = item.text(5) + first_strip = int(safe_float(item.text(1))) + last_strip = int(safe_float(item.text(2))) + minimum_value = (safe_float(item.text(3)) * ureg(unit)).to_base_units().m + maximum_value = (safe_float(item.text(4)) * ureg(unit)).to_base_units().m topLeft = QtCore.QPointF(first_strip, maximum_value) bottomRight = QtCore.QPointF(last_strip, minimum_value) - boxes.append((type, QtCore.QRectF(topLeft, bottomRight))) - self.context.update_boxes.emit(boxes) - - def addBoundingBox(self, enabled: bool, type: str, first_strip: int, last_strip: int, minimum_value: float, maximum_value: float) -> None: + boundingBoxes.append((type_, QtCore.QRectF(topLeft, bottomRight))) + self.boxesChanged.emit(boundingBoxes) + for type_ in self.fields: + for badStrip in self.filterBadStrips(type_): + strip, value = badStrip + strips.add(strip) + markers.append((type_, QtCore.QPointF(strip - 1, value))) # HACK lazy: strip -> strip_index + self.markersChanged.emit(markers) + self.updatePreview(list(strips)) + + def addBoundingBox(self, enabled: bool, type: str, first_strip: int, last_strip: int, minimum_value: float, maximum_value: float, unit: str) -> None: item = QtWidgets.QTreeWidgetItem(self.boxesTreeWidget) item.setText(0, str(type)) item.setText(1, str(first_strip)) item.setText(2, str(last_strip)) item.setText(3, str(minimum_value)) item.setText(4, str(maximum_value)) + item.setText(5, str(unit)) item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsUserCheckable) item.setCheckState(0, QtCore.Qt.Checked if enabled else QtCore.Qt.Unchecked) @@ -178,10 +249,11 @@ def boundingBoxes(self) -> list[dict]: bboxes.append({ "enabled": item.checkState(0) == QtCore.Qt.Checked, "type": item.text(0), - "first_strip": int(safe_float(item.text(1).split()[0])), - "last_strip": int(safe_float(item.text(2).split()[0])), - "minimum_value": safe_float(item.text(3).split()[0]), - "maximum_value": safe_float(item.text(4).split()[0]), + "first_strip": int(safe_float(item.text(1))), + "last_strip": int(safe_float(item.text(2))), + "minimum_value": safe_float(item.text(3)), + "maximum_value": safe_float(item.text(4)), + "unit": item.text(5), }) return bboxes @@ -196,14 +268,18 @@ def readSettings(self) -> None: for boundingBox in boundingBoxes: if isinstance(boundingBox, dict): try: - self.addBoundingBox( - enabled=boundingBox.get("enabled", True), - type=boundingBox.get("type", ""), - first_strip=int(safe_float(boundingBox.get("first_strip", 0))), - last_strip=int(safe_float(boundingBox.get("last_strip", 0))), - minimum_value=safe_float(boundingBox.get("minimum_value", 0.0)), - maximum_value=safe_float(boundingBox.get("maximum_value", 0.0)), - ) + type_ = boundingBox.get("type") + unit = boundingBox.get("unit") + if unit in self.units.get(type_): + self.addBoundingBox( + enabled=boundingBox.get("enabled", True), + type=type_, + first_strip=int(safe_float(boundingBox.get("first_strip", 0))), + last_strip=int(safe_float(boundingBox.get("last_strip", 0))), + minimum_value=safe_float(boundingBox.get("minimum_value", 0.0)), + maximum_value=safe_float(boundingBox.get("maximum_value", 0.0)), + unit=unit, + ) except Exception as exc: logging.exception(exc) @@ -215,7 +291,40 @@ def writeSettings(self) -> None: settings.endGroup() def selectedStrips(self) -> str: - return "1-42" # TODO + return self.previewLineEdit.text() + + def updatePreview(self, strips: list) -> None: + self.previewLineEdit.setText(compress_strips(strips)) - def updatePreview(self) -> None: - ... + def filterBadStrips(self, type_: str) -> dict: + badStrips: list = [] + boxes = [] + try: + for box in self.boundingBoxes(): + if box.get("enabled") and box.get("type") == type_: + boxes.append(( + box.get("first_strip"), + box.get("last_strip"), + (box.get("minimum_value") * ureg(box.get("unit"))).to_base_units().m, + (box.get("maximum_value") * ureg(box.get("unit"))).to_base_units().m, + )) + field = self.fields.get(type_) + for name, items in self._data.get(type_, {}).items(): + for item in items: + strip = int(item.get("strip")) + value = item.get(field) + if value is None: + continue + found_match = False + found_box = False + for first_strip, last_strip, minimum_value, maximum_value in boxes: + if first_strip <= strip <= last_strip: + found_box = True + if minimum_value <= value <= maximum_value: + found_match = True + break + if found_box and not found_match: + badStrips.append((strip, value)) + except Exception as exc: + logging.exception(exc) + return badStrips diff --git a/src/sqc/gui/dashboard.py b/src/sqc/gui/dashboard.py index 4948fc8..123e8c7 100644 --- a/src/sqc/gui/dashboard.py +++ b/src/sqc/gui/dashboard.py @@ -24,6 +24,7 @@ from .sequence import SequenceItem, SequenceWidget, loadSequenceItems from .utils import setForeground, setBackground, Colors from .profiles import readProfiles, padfileType, padfileCategory, padfileName +from .badstrips import BadStripSelectDialog __all__ = ["DashboardWidget"] @@ -415,6 +416,8 @@ def __init__(self, context, parent: Optional[QtWidgets.QWidget] = None) -> None: self.contentTabWidget = QtWidgets.QTabWidget(self) self.contentTabWidget.addTab(self.ivcPlotAreaWidget, "IV/CV") self.contentTabWidget.addTab(self.stripscanPlotAreaWidget, "Stripscan") + self.contentTabWidget.setCurrentWidget(self.stripscanPlotAreaWidget) + self.contentTabWidget.setCurrentWidget(self.ivcPlotAreaWidget) # Splitters @@ -433,7 +436,32 @@ def __init__(self, context, parent: Optional[QtWidgets.QWidget] = None) -> None: self.environUpdateTimer.timeout.connect(self.updateEnvironData) self.environUpdateTimer.start(500) - self.context.update_boxes.connect(self.stripscanPlotAreaWidget.updateBoxes) + self.sequenceWidget.selectBadStrips.connect(self.selectBadStrips) + + def selectBadStrips(self, item) -> None: + try: + dialog = BadStripSelectDialog(self) + dialog.addType("rpoly", "rpoly_r", ["Mohm", "kohm"]) + dialog.addType("istrip", "istrip_i", ["pA", "nA"]) + dialog.addType("idiel", "idiel_i", ["pA", "nA"]) + dialog.addType("cac", "cac_cp", ["pF", "nF"]) + dialog.addType("cint", "cint_cp", ["pF", "nF"]) + dialog.addType("rint", "rint_r", ["Gohm", "Mohm"]) + dialog.addType("idark", "idark_i", ["nA", "pA"]) + dialog.setData(self.context.data.get(item.namespace(), {})) + dialog.boxesChanged.connect(self.stripscanPlotAreaWidget.setBoxes) + dialog.markersChanged.connect(self.stripscanPlotAreaWidget.setMarkers) + dialog.readSettings() + + if dialog.exec() == dialog.Accepted: + item.setStrips(dialog.selectedStrips()) + + dialog.writeSettings() + except Exception as exc: + logger.exception(exc) + + self.stripscanPlotAreaWidget.clearBoxes() + self.stripscanPlotAreaWidget.clearMarkers() def readSettings(self) -> None: settings = QtCore.QSettings() diff --git a/src/sqc/gui/plotarea.py b/src/sqc/gui/plotarea.py index 331f782..295ab15 100644 --- a/src/sqc/gui/plotarea.py +++ b/src/sqc/gui/plotarea.py @@ -172,9 +172,8 @@ def setStrips(self, strips: Dict[int, str]) -> None: def clearBoxes(self) -> None: for widget in self.plotWidgets(): widget.clearBoxes() - widget.updateBoxes() - def updateBoxes(self, boxes) -> None: + def setBoxes(self, boxes) -> None: self.clearBoxes() sortedRects: dict = {} for name, rect in boxes: @@ -184,4 +183,18 @@ def updateBoxes(self, boxes) -> None: if widget: for rect in rects: widget.addBox(rect) - widget.updateBoxes() + + def clearMarkers(self) -> None: + for widget in self.plotWidgets(): + widget.clearMarkers() + + def setMarkers(self, markers: list) -> None: + self.clearMarkers() + sortedPoints = {} + for name, point in markers: + sortedPoints.setdefault(name, []).append(point) + for name, points in sortedPoints.items(): + widget = self.plotWidget(name) + if widget: + for point in points: + widget.addMarker(point) diff --git a/src/sqc/gui/plothighlighter.py b/src/sqc/gui/plothighlighter.py new file mode 100644 index 0000000..526370e --- /dev/null +++ b/src/sqc/gui/plothighlighter.py @@ -0,0 +1,82 @@ +from PyQt5 import QtCore, QtGui, QtWidgets, QtChart + + +class PlotHighlighter: + """Draw colored highligh boxes inside a plot area.""" + + def __init__(self, chart: QtChart.QChart, xAxis: QtChart.QAbstractAxis, yAxis: QtChart.QAbstractAxis) -> None: + self.chart: QtChart.QChart = chart + self.xAxis: QtChart.QAbstractAxis = xAxis + self.yAxis: QtChart.QAbstractAxis = yAxis + self.highlightBoxes: list[QtChart.QAreaSeries] = [] + + def addBox(self, rect: QtCore.QRectF, color: QtGui.QColor) -> None: + """Adds a highlight box to the chart.""" + # Ensure coordinates are correctly ordered + xStart = min(rect.left(), rect.right()) + xEnd = max(rect.left(), rect.right()) + yStart = min(rect.top(), rect.bottom()) + yEnd = max(rect.top(), rect.bottom()) + + # Create lower and upper boundary series + lowerSeries = QtChart.QLineSeries() + lowerSeries.append(xStart, yStart) + lowerSeries.append(xEnd, yStart) + + upperSeries = QtChart.QLineSeries() + upperSeries.append(xStart, yEnd) + upperSeries.append(xEnd, yEnd) + + # Create the area series (highlight box) + areaSeries = QtChart.QAreaSeries(upperSeries, lowerSeries) + areaSeries.setBrush(QtGui.QBrush(color)) + areaSeries.setPen(QtGui.QPen(QtCore.Qt.NoPen)) # Remove border + + # Add the area series to the chart + self.chart.addSeries(areaSeries) + + # Attach axes to the area series + areaSeries.attachAxis(self.xAxis) + areaSeries.attachAxis(self.yAxis) + + # Hide the legend marker for this series + for marker in self.chart.legend().markers(areaSeries): + marker.setVisible(False) + + # Keep track of the area series + self.highlightBoxes.append(areaSeries) + + def clear(self) -> None: + """Removes all highlight boxes from the chart.""" + for areaSeries in self.highlightBoxes: + self.chart.removeSeries(areaSeries) + self.highlightBoxes.clear() + + +class PlotMarkers: + """Draw colored markers inside a plot area.""" + + def __init__(self, chart: QtChart.QChart, xAxis: QtChart.QAbstractAxis, yAxis: QtChart.QAbstractAxis) -> None: + self.chart: QtChart.QChart = chart + self.xAxis: QtChart.QAbstractAxis = xAxis + self.yAxis: QtChart.QAbstractAxis = yAxis + + self.markerSeries = None + + def addMarker(self, point: QtCore.QPointF) -> None: + if not self.markerSeries: + self.markerSeries = QtChart.QScatterSeries() + self.markerSeries.setColor(QtGui.QColor("red")) + self.markerSeries.setMarkerSize(8) + self.chart.addSeries(self.markerSeries) + self.markerSeries.attachAxis(self.xAxis) + self.markerSeries.attachAxis(self.yAxis) + for marker in self.chart.legend().markers(self.markerSeries): + marker.setVisible(False) + self.markerSeries.append(point) + + def clear(self) -> None: + """Removes all markers from the chart.""" + if self.markerSeries in self.chart.series(): + self.chart.removeSeries(self.markerSeries) + self.markerSeries = None diff --git a/src/sqc/gui/plotwidget.py b/src/sqc/gui/plotwidget.py index 941ad7b..5a01fdf 100644 --- a/src/sqc/gui/plotwidget.py +++ b/src/sqc/gui/plotwidget.py @@ -1,9 +1,10 @@ import logging -from typing import Dict, Optional +from typing import Callable, Dict, Iterator, Optional from PyQt5 import QtChart, QtCore, QtGui, QtWidgets from ..core.limits import LimitsAggregator +from .plothighlighter import PlotHighlighter, PlotMarkers __all__ = [ "DataMapper", @@ -44,20 +45,20 @@ def auto_scale(value): class DataMapper: - def __init__(self): - self._mapping = {} - self._transformation = {} + def __init__(self) -> None: + self._mapping: dict[str, tuple[str, str]] = {} + self._transformation: dict[str, Callable] = {} - def setMapping(self, name, x, y): + def setMapping(self, name: str, x: str, y: str) -> None: self._mapping[name] = x, y - def setTransformation(self, name, f): + def setTransformation(self, name: str, f: Callable[[float, float], tuple[float, float]]) -> None: self._transformation[name] = f - def __call__(self, name, items): + def __call__(self, name: str, items: list) -> Iterator: if name not in self._mapping: raise KeyError(f"No such series: {name!r}") - x, y = self._mapping.get(name) + x, y = self._mapping[name] tr = self._transformation.get(name, lambda x, y: (x, y)) return (tr(item.get(x), item.get(y)) for item in items) @@ -293,6 +294,9 @@ def __init__(self, title: str, parent: Optional[QtWidgets.QWidget] = None) -> No self._yAxis = QtChart.QValueAxis() self._chart.addAxis(self._yAxis, QtCore.Qt.AlignRight) + self._highlighter = PlotHighlighter(self._chart, self._xAxis, self._yAxis) + self._markers = PlotMarkers(self._chart, self._xAxis, self._yAxis) + layout = QtWidgets.QVBoxLayout(self) layout.addWidget(self._chartView) @@ -301,26 +305,16 @@ def __init__(self, title: str, parent: Optional[QtWidgets.QWidget] = None) -> No self._boxes: dict[QtWidgets.QGraphicsRectItem, QtCore.QRectF] = {} def addBox(self, rect: QtCore.QRectF) -> None: - item = QtWidgets.QGraphicsRectItem() - item.setPen(QtGui.QPen(QtCore.Qt.red)) - item.setBrush(QtGui.QBrush(QtGui.QColor(255, 0, 0, 50))) - self._boxes.update({item: rect}) - self._chart.scene().addItem(item) - self.updateBoxes() + self._highlighter.addBox(rect, QtGui.QColor(0, 255, 0, 50)) def clearBoxes(self) -> None: - for item in self._boxes.keys(): - self._chart.scene().removeItem(item) - self._boxes.clear() - - def updateBoxes(self): - series = self._chart.series() - if series: - for item, box in self._boxes.items(): - topLeft = self._chart.mapToPosition(box.topLeft(), series[0]) - bottomRight = self._chart.mapToPosition(box.bottomRight(), series[0]) - item.setRect(QtCore.QRectF(topLeft, bottomRight)) - self.updateGeometry() + self._highlighter.clear() + + def addMarker(self, point: QtCore.QPointF) -> None: + self._markers.addMarker(point) + + def clearMarkers(self) -> None: + self._markers.clear() def title(self) -> str: return self._chart.title() @@ -536,7 +530,6 @@ def __init__(self, title: str, parent: Optional[QtWidgets.QWidget] = None) -> No self._yAxis.setRange(0, 1e-06) - class RStripPlotWidget(StripPlotWidget): def __init__(self, title: str, parent: Optional[QtWidgets.QWidget] = None) -> None: @@ -583,6 +576,20 @@ def transform(cls, x, y): # TODO return x, y * 1e12 # pF + @classmethod + def transformPoint(cls, point: QtCore.QPointF) -> QtCore.QPointF: + x, y = cls.transform(point.x(), point.y()) + return QtCore.QPointF(x, y) + + def addBox(self, rect: QtCore.QRectF) -> None: + topLeft = self.transformPoint(rect.topLeft()) + bottomRight = self.transformPoint(rect.bottomRight()) + self._highlighter.addBox(QtCore.QRectF(topLeft, bottomRight), QtGui.QColor(0, 255, 0, 50)) + super().addBox(rect) + + def addMarker(self, point: QtCore.QPointF) -> None: + super().addMarker(self.transformPoint(point)) + class RecontactPlotWidget(StripPlotWidget): """Histogram of recontacts and remeasurements.""" diff --git a/src/sqc/gui/sequence.py b/src/sqc/gui/sequence.py index 4503307..6bfa11d 100644 --- a/src/sqc/gui/sequence.py +++ b/src/sqc/gui/sequence.py @@ -7,8 +7,6 @@ from ..core.utils import parse_strip_expression, normalize_strip_expression from ..strategy import SequenceStrategy -from .badstrips import BadStripSelectDialog - __all__ = [ "SequenceItem", "SequenceWidget", @@ -225,6 +223,8 @@ def allChildren(self) -> list: class SequenceWidget(QtWidgets.QTreeWidget): + selectBadStrips = QtCore.pyqtSignal(SequenceItem) + def __init__(self, context, parent: Optional[QtWidgets.QWidget] = None) -> None: super().__init__(parent) self.context = context @@ -334,12 +334,7 @@ def contextMenuEvent(self, event): if res == restoreStripsAction: item.setStrips(item.defaultStrips()) elif res == selectBadStripsAction: - dialog = BadStripSelectDialog(self.context, self) - dialog.readSettings() - if dialog.exec() == dialog.Accepted: - item.setStrips(dialog.selectedStrips()) - dialog.writeSettings() - self.context.update_boxes.emit([]) + self.selectBadStrips.emit(item) elif res == resetIntervalsAction: for child in item.allChildren(): child.setInterval(1) From 75f882e8dbb04b960e7697e4569bfc20fbe44a67 Mon Sep 17 00:00:00 2001 From: Bernhard Arnold Date: Wed, 18 Sep 2024 18:43:27 +0200 Subject: [PATCH 10/14] refs #25 fixes --- src/sqc/gui/badstrips.py | 14 +++++++++----- src/sqc/gui/plotarea.py | 2 +- src/sqc/gui/plothighlighter.py | 4 +++- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/sqc/gui/badstrips.py b/src/sqc/gui/badstrips.py index 583508c..3a6daa9 100644 --- a/src/sqc/gui/badstrips.py +++ b/src/sqc/gui/badstrips.py @@ -269,8 +269,12 @@ def readSettings(self) -> None: if isinstance(boundingBox, dict): try: type_ = boundingBox.get("type") + if type_ is None: + continue unit = boundingBox.get("unit") - if unit in self.units.get(type_): + if unit is None: + continue + if unit in self.units.get(type_, []): self.addBoundingBox( enabled=boundingBox.get("enabled", True), type=type_, @@ -296,9 +300,9 @@ def selectedStrips(self) -> str: def updatePreview(self, strips: list) -> None: self.previewLineEdit.setText(compress_strips(strips)) - def filterBadStrips(self, type_: str) -> dict: - badStrips: list = [] - boxes = [] + def filterBadStrips(self, type_: str) -> list[tuple]: + badStrips: list[tuple] = [] + boxes: list = [] try: for box in self.boundingBoxes(): if box.get("enabled") and box.get("type") == type_: @@ -313,7 +317,7 @@ def filterBadStrips(self, type_: str) -> dict: for item in items: strip = int(item.get("strip")) value = item.get(field) - if value is None: + if strip is None or value is None: continue found_match = False found_box = False diff --git a/src/sqc/gui/plotarea.py b/src/sqc/gui/plotarea.py index 295ab15..5e96a42 100644 --- a/src/sqc/gui/plotarea.py +++ b/src/sqc/gui/plotarea.py @@ -190,7 +190,7 @@ def clearMarkers(self) -> None: def setMarkers(self, markers: list) -> None: self.clearMarkers() - sortedPoints = {} + sortedPoints: dict = {} for name, point in markers: sortedPoints.setdefault(name, []).append(point) for name, points in sortedPoints.items(): diff --git a/src/sqc/gui/plothighlighter.py b/src/sqc/gui/plothighlighter.py index 526370e..f945e11 100644 --- a/src/sqc/gui/plothighlighter.py +++ b/src/sqc/gui/plothighlighter.py @@ -1,3 +1,5 @@ +from typing import Optional + from PyQt5 import QtCore, QtGui, QtWidgets, QtChart @@ -61,7 +63,7 @@ def __init__(self, chart: QtChart.QChart, xAxis: QtChart.QAbstractAxis, yAxis: Q self.xAxis: QtChart.QAbstractAxis = xAxis self.yAxis: QtChart.QAbstractAxis = yAxis - self.markerSeries = None + self.markerSeries: Optional[QtChart.QScatterSeries] = None def addMarker(self, point: QtCore.QPointF) -> None: if not self.markerSeries: From 05b938591b78600749c5a264f56598603df791db Mon Sep 17 00:00:00 2001 From: Bernhard Arnold Date: Thu, 19 Sep 2024 14:52:39 +0200 Subject: [PATCH 11/14] refs #25 refactored bad strip selection, added import/export JSON --- src/sqc/gui/badstrips.py | 325 +++++++++++++++++++++++++++++---------- src/sqc/gui/dashboard.py | 5 +- 2 files changed, 249 insertions(+), 81 deletions(-) diff --git a/src/sqc/gui/badstrips.py b/src/sqc/gui/badstrips.py index 3a6daa9..c51542a 100644 --- a/src/sqc/gui/badstrips.py +++ b/src/sqc/gui/badstrips.py @@ -1,11 +1,29 @@ +import hashlib +import json import logging -from typing import Dict, Optional +import os +from typing import Any, Dict, Optional from PyQt5 import QtCore, QtWidgets +from schema import Schema, And, Use, SchemaError from comet.utils import ureg +def ensure_int(value: Any, default: int = 0) -> int: + try: + return int(float(value)) + except (ValueError, TypeError): + return int(default) + + +def ensure_float(value: Any, default: float = 0) -> float: + try: + return float(value) + except (ValueError, TypeError): + return float(default) + + def safe_float(text: str) -> float: try: return float(text) @@ -33,6 +51,59 @@ def compress_strips(strips: list) -> str: return ", ".join(ranges) +class BoundingBoxItem(QtWidgets.QTreeWidgetItem): + def __init__(self) -> None: + super().__init__() + self.setFlags(self.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsUserCheckable) + self.setEnabled(True) + self.setFirstStrip(0) + self.setLastStrip(0) + self.setMinimumValue(0) + self.setMaximumValue(0) + + def isEnabled(self) -> bool: + return self.checkState(0) == QtCore.Qt.Checked + + def setEnabled(self, enabled: bool) -> None: + self.setCheckState(0, QtCore.Qt.Checked if enabled else QtCore.Qt.Unchecked) + + def typename(self) -> str: + return self.text(0) + + def setTypename(self, typename: str) -> None: + self.setText(0, typename) + + def firstStrip(self) -> int: + return int(self.text(1) or "0") + + def setFirstStrip(self, strip: int) -> None: + return self.setText(1, format(strip)) + + def lastStrip(self) -> int: + return int(self.text(2) or "0") + + def setLastStrip(self, strip: int) -> None: + return self.setText(2, format(strip)) + + def minimumValue(self) -> float: + return float(self.text(3) or "0") + + def setMinimumValue(self, value: float) -> None: + return self.setText(3, format(value)) + + def maximumValue(self) -> float: + return float(self.text(4) or "0") + + def setMaximumValue(self, value: float) -> None: + return self.setText(4, format(value)) + + def unit(self) -> str: + return self.text(5) + + def setUnit(self, unit: str) -> None: + return self.setText(5, unit) + + class ComboBoxDelegate(QtWidgets.QStyledItemDelegate): typeChanged = QtCore.pyqtSignal(object) @@ -64,11 +135,11 @@ def createEditor(self, parent, option, index) -> QtWidgets.QWidget: return spinbox def setEditorData(self, editor, index) -> None: - value = int(float(index.data(QtCore.Qt.DisplayRole).split()[0].strip())) + value = int(float(index.data(QtCore.Qt.DisplayRole))) editor.setValue(value) def setModelData(self, editor, model, index) -> None: - model.setData(index, f"{editor.value()}", QtCore.Qt.EditRole) + model.setData(index, format(editor.value()), QtCore.Qt.EditRole) class DoubleSpinBoxDelegate(QtWidgets.QStyledItemDelegate): @@ -83,7 +154,7 @@ def setEditorData(self, editor, index) -> None: editor.setValue(value) def setModelData(self, editor, model, index) -> None: - model.setData(index, f"{editor.value():.3f}", QtCore.Qt.EditRole) + model.setData(index, format(editor.value()), QtCore.Qt.EditRole) class UnitDelegate(QtWidgets.QStyledItemDelegate): @@ -148,8 +219,16 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None: self.removeBoxButton.setText("&Remove") self.removeBoxButton.clicked.connect(self.removeCurrentBox) + self.importButton = QtWidgets.QPushButton() + self.importButton.setText("&Import...") + self.importButton.clicked.connect(self.importFile) + + self.exportButton = QtWidgets.QPushButton() + self.exportButton.setText("&Export...") + self.exportButton.clicked.connect(self.exportFile) + self.previewLabel = QtWidgets.QLabel(self) - self.previewLabel.setText("Preview") + self.previewLabel.setText("Selected Strips") self.previewLineEdit = QtWidgets.QLineEdit(self) self.previewLineEdit.setReadOnly(True) @@ -161,9 +240,11 @@ def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None: self.buttonBox.rejected.connect(self.reject) self.boxesLayout = QtWidgets.QGridLayout() - self.boxesLayout.addWidget(self.boxesTreeWidget, 0, 0, 3, 1) + self.boxesLayout.addWidget(self.boxesTreeWidget, 0, 0, 5, 1) self.boxesLayout.addWidget(self.addBoxButton, 0, 1, 1, 1) self.boxesLayout.addWidget(self.removeBoxButton, 1, 1, 1, 1) + self.boxesLayout.addWidget(self.importButton, 3, 1, 1, 1) + self.boxesLayout.addWidget(self.exportButton, 4, 1, 1, 1) layout = QtWidgets.QVBoxLayout(self) layout.addWidget(self.boxesLabel) @@ -190,33 +271,124 @@ def updateType(self, index) -> None: item.setText(5, unit) def newBox(self) -> None: - for type_, units in self.units.items(): + for typename, units in self.units.items(): if units: - self.addBoundingBox(True, type_, 0, 0, 0, 0, units[0]) + item = BoundingBoxItem() + item.setEnabled(True) + item.setTypename(typename) + item.setUnit(units[0]) + self.addBoundingBox(item) + self.boxesTreeWidget.setCurrentItem(item) break def removeCurrentBox(self) -> None: item = self.boxesTreeWidget.currentItem() - if item: + if item is not None: index = self.boxesTreeWidget.indexOfTopLevelItem(item) self.boxesTreeWidget.takeTopLevelItem(index) + def importFile(self) -> None: + # Define the schema for validation + bounding_box_schema = Schema({ + "enabled": bool, + "type": str, + "first_strip": And(Use(int), lambda n: n >= 0), + "last_strip": And(Use(int), lambda n: n >= 0), + "minimum_value": Use(float), + "maximum_value": Use(float), + "unit": str, + }) + + data_schema = Schema({ + "bounding_boxes": [bounding_box_schema], + }) + + # Open file dialog for selecting a JSON file + filename, _ = QtWidgets.QFileDialog.getOpenFileName( + self, + "Import JSON", + os.path.expanduser("~"), + "JSON (*.json)" + ) + + # If a file is selected + if filename: + try: + # Open the selected file and load the JSON data + with open(filename, "rt") as fp: + data = json.load(fp) + + # Validate the data structure using schema + validated_data = data_schema.validate(data) + + # Extract validated bounding boxes + bounding_boxes = validated_data["bounding_boxes"] + + self.clearBoundingBoxes() + for box in bounding_boxes: + item = BoundingBoxItem() + item.setEnabled(box["enabled"]), + item.setTypename(box["type"]) + item.setFirstStrip(box["first_strip"]) + item.setLastStrip(box["last_strip"]) + item.setMinimumValue(box["minimum_value"]) + item.setMaximumValue(box["maximum_value"]) + item.setUnit(box["unit"]), + self.addBoundingBox(item) + + except (json.JSONDecodeError, SchemaError) as e: + # Handle JSON decoding and schema validation errors + logging.error(f"Failed to import file: {str(e)}") + QtWidgets.QMessageBox.critical(self, "Error", f"Failed to import file: {str(e)}") + except Exception as exc: + # Log any other unexpected exceptions + logging.exception(exc) + QtWidgets.QMessageBox.critical(self, "Error", f"An unexpected error occurred: {str(exc)}") + + def exportFile(self) -> None: + filename, _ = QtWidgets.QFileDialog.getSaveFileName( + self, + "Export JSON", + os.path.expanduser("~"), + "JSON (*.json)", + ) + if filename: + try: + boundingBoxes: list = [] + for item in self.boundingBoxes(): + boundingBoxes.append({ + "enabled": item.isEnabled(), + "type": item.typename(), + "first_strip": item.firstStrip(), + "last_strip": item.lastStrip(), + "minimum_value": item.minimumValue(), + "maximum_value": item.maximumValue(), + "unit": item.unit(), + }) + data = { + "bounding_boxes": boundingBoxes, + } + with open(filename, "wt") as fp: + json.dump(data, fp) + except Exception as exc: + logging.exception(exc) + QtWidgets.QMessageBox.critical(self, "Error", f"An unexpected error occurred: {exc}") + def updateBoxes(self) -> None: boundingBoxes: list = [] markers: list = [] strips: set = set() for index in range(self.boxesTreeWidget.topLevelItemCount()): item = self.boxesTreeWidget.topLevelItem(index) - if item and item.checkState(0) == QtCore.Qt.Checked: - type_ = item.text(0) - unit = item.text(5) - first_strip = int(safe_float(item.text(1))) - last_strip = int(safe_float(item.text(2))) - minimum_value = (safe_float(item.text(3)) * ureg(unit)).to_base_units().m - maximum_value = (safe_float(item.text(4)) * ureg(unit)).to_base_units().m - topLeft = QtCore.QPointF(first_strip, maximum_value) - bottomRight = QtCore.QPointF(last_strip, minimum_value) - boundingBoxes.append((type_, QtCore.QRectF(topLeft, bottomRight))) + if isinstance(item, BoundingBoxItem): + if not item.isEnabled(): + continue + unit = ureg(item.unit()) + minimumValue = (item.minimumValue() * unit).to_base_units().m + maximumValue = (item.maximumValue() * unit).to_base_units().m + topLeft = QtCore.QPointF(item.firstStrip(), maximumValue) + bottomRight = QtCore.QPointF(item.lastStrip(), minimumValue) + boundingBoxes.append((item.typename(), QtCore.QRectF(topLeft, bottomRight))) self.boxesChanged.emit(boundingBoxes) for type_ in self.fields: for badStrip in self.filterBadStrips(type_): @@ -226,72 +398,65 @@ def updateBoxes(self) -> None: self.markersChanged.emit(markers) self.updatePreview(list(strips)) - def addBoundingBox(self, enabled: bool, type: str, first_strip: int, last_strip: int, minimum_value: float, maximum_value: float, unit: str) -> None: - item = QtWidgets.QTreeWidgetItem(self.boxesTreeWidget) - item.setText(0, str(type)) - item.setText(1, str(first_strip)) - item.setText(2, str(last_strip)) - item.setText(3, str(minimum_value)) - item.setText(4, str(maximum_value)) - item.setText(5, str(unit)) - item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsUserCheckable) - item.setCheckState(0, QtCore.Qt.Checked if enabled else QtCore.Qt.Unchecked) + def addBoundingBox(self, item: BoundingBoxItem) -> None: + self.boxesTreeWidget.addTopLevelItem(item) def clearBoundingBoxes(self) -> None: while self.boxesTreeWidget.topLevelItemCount(): self.boxesTreeWidget.takeTopLevelItem(0) - def boundingBoxes(self) -> list[dict]: - bboxes = [] + def boundingBoxes(self) -> list[BoundingBoxItem]: + boundingBoxes = [] for index in range(self.boxesTreeWidget.topLevelItemCount()): item = self.boxesTreeWidget.topLevelItem(index) - if item: - bboxes.append({ - "enabled": item.checkState(0) == QtCore.Qt.Checked, - "type": item.text(0), - "first_strip": int(safe_float(item.text(1))), - "last_strip": int(safe_float(item.text(2))), - "minimum_value": safe_float(item.text(3)), - "maximum_value": safe_float(item.text(4)), - "unit": item.text(5), - }) - return bboxes - - def readSettings(self) -> None: + if isinstance(item, BoundingBoxItem): + boundingBoxes.append(item) + return boundingBoxes + + def readSettings(self, namespace: str) -> None: + hashedNamespace = hashlib.sha256(namespace.encode()).hexdigest() settings = QtCore.QSettings() settings.beginGroup(self.objectName()) geometry = settings.value("geometry", QtCore.QByteArray(), QtCore.QByteArray) - boundingBoxes = settings.value("boundingBoxes", [], list) + settings.beginGroup("boundingBoxes") + size = settings.beginReadArray(hashedNamespace) + self.clearBoundingBoxes() + for index in range(size): + settings.setArrayIndex(index) + item = BoundingBoxItem() + item.setEnabled(settings.value("enabled", True, bool)) + item.setTypename(settings.value("type", "", str)) + item.setFirstStrip(settings.value("firstStrip", 0, int)) + item.setLastStrip(settings.value("lastStrip", 0, int)) + item.setMinimumValue(settings.value("minimumValue", 0, float)) + item.setMaximumValue(settings.value("maximumValue", 0, float)) + item.setUnit(settings.value("unit", "", str)) + self.addBoundingBox(item) + settings.endArray() + settings.endGroup() settings.endGroup() self.restoreGeometry(geometry) - self.clearBoundingBoxes() - for boundingBox in boundingBoxes: - if isinstance(boundingBox, dict): - try: - type_ = boundingBox.get("type") - if type_ is None: - continue - unit = boundingBox.get("unit") - if unit is None: - continue - if unit in self.units.get(type_, []): - self.addBoundingBox( - enabled=boundingBox.get("enabled", True), - type=type_, - first_strip=int(safe_float(boundingBox.get("first_strip", 0))), - last_strip=int(safe_float(boundingBox.get("last_strip", 0))), - minimum_value=safe_float(boundingBox.get("minimum_value", 0.0)), - maximum_value=safe_float(boundingBox.get("maximum_value", 0.0)), - unit=unit, - ) - except Exception as exc: - logging.exception(exc) - - def writeSettings(self) -> None: + self.updateBoxes() + + def writeSettings(self, namespace: str) -> None: + boundingBoxes = self.boundingBoxes() + hashedNamespace = hashlib.sha256(namespace.encode()).hexdigest() settings = QtCore.QSettings() settings.beginGroup(self.objectName()) settings.setValue("geometry", self.saveGeometry()) - settings.setValue("boundingBoxes", self.boundingBoxes()) + settings.beginGroup("boundingBoxes") + settings.beginWriteArray(hashedNamespace, len(boundingBoxes)) + for index, item in enumerate(boundingBoxes): + settings.setArrayIndex(index) + settings.setValue("enabled", item.isEnabled()) + settings.setValue("type", item.typename()) + settings.setValue("firstStrip", item.firstStrip()) + settings.setValue("lastStrip", item.lastStrip()) + settings.setValue("minimumValue", item.minimumValue()) + settings.setValue("maximumValue", item.maximumValue()) + settings.setValue("unit", item.unit()) + settings.endArray() + settings.endGroup() settings.endGroup() def selectedStrips(self) -> str: @@ -299,21 +464,23 @@ def selectedStrips(self) -> str: def updatePreview(self, strips: list) -> None: self.previewLineEdit.setText(compress_strips(strips)) + self.previewLineEdit.setCursorPosition(0) - def filterBadStrips(self, type_: str) -> list[tuple]: + def filterBadStrips(self, typename: str) -> list[tuple]: badStrips: list[tuple] = [] boxes: list = [] try: - for box in self.boundingBoxes(): - if box.get("enabled") and box.get("type") == type_: + for item in self.boundingBoxes(): + if item.isEnabled() and item.typename() == typename: + unit = ureg(item.unit()) boxes.append(( - box.get("first_strip"), - box.get("last_strip"), - (box.get("minimum_value") * ureg(box.get("unit"))).to_base_units().m, - (box.get("maximum_value") * ureg(box.get("unit"))).to_base_units().m, + item.firstStrip(), + item.lastStrip(), + (item.minimumValue() * unit).to_base_units().m, + (item.maximumValue() * unit).to_base_units().m, )) - field = self.fields.get(type_) - for name, items in self._data.get(type_, {}).items(): + field = self.fields.get(typename) + for name, items in self._data.get(typename, {}).items(): for item in items: strip = int(item.get("strip")) value = item.get(field) diff --git a/src/sqc/gui/dashboard.py b/src/sqc/gui/dashboard.py index 123e8c7..9093797 100644 --- a/src/sqc/gui/dashboard.py +++ b/src/sqc/gui/dashboard.py @@ -439,6 +439,7 @@ def __init__(self, context, parent: Optional[QtWidgets.QWidget] = None) -> None: self.sequenceWidget.selectBadStrips.connect(self.selectBadStrips) def selectBadStrips(self, item) -> None: + namespace = self.sensorProfileName() try: dialog = BadStripSelectDialog(self) dialog.addType("rpoly", "rpoly_r", ["Mohm", "kohm"]) @@ -451,12 +452,12 @@ def selectBadStrips(self, item) -> None: dialog.setData(self.context.data.get(item.namespace(), {})) dialog.boxesChanged.connect(self.stripscanPlotAreaWidget.setBoxes) dialog.markersChanged.connect(self.stripscanPlotAreaWidget.setMarkers) - dialog.readSettings() + dialog.readSettings(namespace) if dialog.exec() == dialog.Accepted: item.setStrips(dialog.selectedStrips()) - dialog.writeSettings() + dialog.writeSettings(namespace) except Exception as exc: logger.exception(exc) From 6717728779d199bf9e99f22a4d6f4148f42a5b5e Mon Sep 17 00:00:00 2001 From: Bernhard Arnold Date: Thu, 19 Sep 2024 15:05:47 +0200 Subject: [PATCH 12/14] refs #25 fixes --- src/sqc/gui/badstrips.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/sqc/gui/badstrips.py b/src/sqc/gui/badstrips.py index c51542a..30cb929 100644 --- a/src/sqc/gui/badstrips.py +++ b/src/sqc/gui/badstrips.py @@ -335,6 +335,7 @@ def importFile(self) -> None: item.setMaximumValue(box["maximum_value"]) item.setUnit(box["unit"]), self.addBoundingBox(item) + self.updateBoxes() except (json.JSONDecodeError, SchemaError) as e: # Handle JSON decoding and schema validation errors @@ -353,6 +354,9 @@ def exportFile(self) -> None: "JSON (*.json)", ) if filename: + # Add .json extension if not already present + if not filename.endswith(".json"): + filename = f"{filename}.json" try: boundingBoxes: list = [] for item in self.boundingBoxes(): From 1bf35c5572f7773739a24d13d7444fb62ecd5925 Mon Sep 17 00:00:00 2001 From: Bernhard Arnold Date: Thu, 19 Sep 2024 16:37:12 +0200 Subject: [PATCH 13/14] updated edit strips dialog (non-modal) --- changelog | 7 +++- src/sqc/context.py | 2 ++ src/sqc/core/utils.py | 4 ++- src/sqc/gui/dashboard.py | 48 ++++++++++++++----------- src/sqc/gui/mainwindow.py | 6 ++-- src/sqc/gui/sequence.py | 76 ++++++++++++++++++++++++++++++++------- 6 files changed, 105 insertions(+), 38 deletions(-) diff --git a/changelog b/changelog index 0e32860..43a7535 100644 --- a/changelog +++ b/changelog @@ -9,7 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.11.0] - 2024-09-16 +### Added +- New bad strip detection (#25). + ### Changed +- Improved non-modal edit strips dialog. - Switched to pyproject.toml config. - Switched to ruff linter. @@ -171,7 +175,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Documentation of measurement configuration parameters. -[Unreleased]: https://github.com/hephy-dd/sqc/compare/0.10.2...HEAD +[Unreleased]: https://github.com/hephy-dd/sqc/compare/0.11.0...HEAD +[0.11.0]: https://github.com/hephy-dd/sqc/compare/0.10.2...0.11.0 [0.10.2]: https://github.com/hephy-dd/sqc/compare/0.10.1...0.10.2 [0.10.1]: https://github.com/hephy-dd/sqc/compare/0.10.0...0.10.1 [0.10.0]: https://github.com/hephy-dd/sqc/compare/0.9.1...0.10.0 diff --git a/src/sqc/context.py b/src/sqc/context.py index f86c219..dcd3495 100644 --- a/src/sqc/context.py +++ b/src/sqc/context.py @@ -73,6 +73,8 @@ class Context(QtCore.QObject): exception_raised = QtCore.pyqtSignal(Exception) + lock_profile = QtCore.pyqtSignal(bool) + def __init__(self, station: Station, parent: Optional[QtCore.QObject] = None) -> None: super().__init__(parent) self._station: Station = station diff --git a/src/sqc/core/utils.py b/src/sqc/core/utils.py index caedb62..133f136 100644 --- a/src/sqc/core/utils.py +++ b/src/sqc/core/utils.py @@ -57,7 +57,9 @@ def create_slices(all: List[str], selected: List[str]) -> List[List[str]]: def normalize_strip_expression(expression: str) -> str: """Return normalized version of strip expression.""" - return re.sub(r"\s*", "", expression).strip().replace(",", ", ") + expression = re.sub(r'\s+', " ", expression.strip()) + tokens = re.split(r'[,\s]+', expression) + return ", ".join(list(filter(None, tokens))) def parse_strip_expression(expression: str) -> Generator[Tuple[str, str], None, None]: diff --git a/src/sqc/gui/dashboard.py b/src/sqc/gui/dashboard.py index 9093797..049c923 100644 --- a/src/sqc/gui/dashboard.py +++ b/src/sqc/gui/dashboard.py @@ -96,6 +96,8 @@ def __init__(self, context, parent: Optional[QtWidgets.QWidget] = None) -> None: self.profileComboBox.currentIndexChanged.connect(self.profileChanged) self.profileComboBoxPreviousIndex = -1 + self.context.lock_profile.connect(lambda locked: self.profileComboBox.setEnabled(not locked)) # TODO! + # Sequence self.sequenceLabel = QtWidgets.QLabel("Sequence") @@ -439,27 +441,28 @@ def __init__(self, context, parent: Optional[QtWidgets.QWidget] = None) -> None: self.sequenceWidget.selectBadStrips.connect(self.selectBadStrips) def selectBadStrips(self, item) -> None: - namespace = self.sensorProfileName() - try: - dialog = BadStripSelectDialog(self) - dialog.addType("rpoly", "rpoly_r", ["Mohm", "kohm"]) - dialog.addType("istrip", "istrip_i", ["pA", "nA"]) - dialog.addType("idiel", "idiel_i", ["pA", "nA"]) - dialog.addType("cac", "cac_cp", ["pF", "nF"]) - dialog.addType("cint", "cint_cp", ["pF", "nF"]) - dialog.addType("rint", "rint_r", ["Gohm", "Mohm"]) - dialog.addType("idark", "idark_i", ["nA", "pA"]) - dialog.setData(self.context.data.get(item.namespace(), {})) - dialog.boxesChanged.connect(self.stripscanPlotAreaWidget.setBoxes) - dialog.markersChanged.connect(self.stripscanPlotAreaWidget.setMarkers) - dialog.readSettings(namespace) - - if dialog.exec() == dialog.Accepted: - item.setStrips(dialog.selectedStrips()) - - dialog.writeSettings(namespace) - except Exception as exc: - logger.exception(exc) + if isinstance(item, SequenceItem): + namespace = self.sensorProfileName() + try: + dialog = BadStripSelectDialog(self) + dialog.addType("rpoly", "rpoly_r", ["Mohm", "kohm"]) + dialog.addType("istrip", "istrip_i", ["pA", "nA"]) + dialog.addType("idiel", "idiel_i", ["pA", "nA"]) + dialog.addType("cac", "cac_cp", ["pF", "nF"]) + dialog.addType("cint", "cint_cp", ["pF", "nF"]) + dialog.addType("rint", "rint_r", ["Gohm", "Mohm"]) + dialog.addType("idark", "idark_i", ["nA", "pA"]) + dialog.setData(self.context.data.get(item.namespace(), {})) + dialog.boxesChanged.connect(self.stripscanPlotAreaWidget.setBoxes) + dialog.markersChanged.connect(self.stripscanPlotAreaWidget.setMarkers) + dialog.readSettings(namespace) + + if dialog.exec() == dialog.Accepted: + item.setStrips(dialog.selectedStrips()) + + dialog.writeSettings(namespace) + except Exception as exc: + logger.exception(exc) self.stripscanPlotAreaWidget.clearBoxes() self.stripscanPlotAreaWidget.clearMarkers() @@ -623,6 +626,9 @@ def profileChanged(self, index: int) -> None: def reset(self) -> None: self.clearReadings() + self.context.reset() + self.context.reset_data() + data = self.profileComboBox.currentData() or {} filename = data.get("padfile") if filename: diff --git a/src/sqc/gui/mainwindow.py b/src/sqc/gui/mainwindow.py index d76aa5f..5a6009c 100644 --- a/src/sqc/gui/mainwindow.py +++ b/src/sqc/gui/mainwindow.py @@ -330,8 +330,6 @@ def newMeasurement(self): "Do you want to prepare a new measurement?\n\nThis will clear all plots and restore default sequence configuration." ) if result == QtWidgets.QMessageBox.Yes: - self.context.reset() - self.context.reset_data() self.dashboardWidget.reset() self.dashboardWidget.updateContext() @@ -573,6 +571,10 @@ def requestStart(self): if not self.dashboardWidget.operatorName().strip(): QtWidgets.QMessageBox.warning(self, "Missing Operator Name", "No operator name is set.") return + # TODO + if self.dashboardWidget.sequenceWidget.editStripsDialog.isVisible(): + QtWidgets.QMessageBox.warning(self, "Editing Strips", "Close Edit Strips dialog to proceed.") + return # Check environment limits data = self.context.station.box_environment() if not self.checkEnvironment(data): diff --git a/src/sqc/gui/sequence.py b/src/sqc/gui/sequence.py index 6bfa11d..2cb770a 100644 --- a/src/sqc/gui/sequence.py +++ b/src/sqc/gui/sequence.py @@ -48,6 +48,33 @@ def loadStripItems(measurements): return items +class EditStripsDialog(QtWidgets.QDialog): + def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None: + super().__init__(parent) + + self.stripsLabel = QtWidgets.QLabel(self) + self.stripsLabel.setText("Strips") + + self.stripsTextEdit = QtWidgets.QPlainTextEdit(self) + + self.buttonBox = QtWidgets.QDialogButtonBox(self) + self.buttonBox.addButton(QtWidgets.QDialogButtonBox.Ok) + self.buttonBox.addButton(QtWidgets.QDialogButtonBox.Cancel) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self.stripsLabel) + layout.addWidget(self.stripsTextEdit) + layout.addWidget(self.buttonBox) + + def strips(self) -> str: + return self.stripsTextEdit.toPlainText() + + def setStrips(self, strips: str) -> None: + self.stripsTextEdit.setPlainText(strips) + + class SequenceItemState: """Represents a sequence item state with name and color.""" @@ -239,10 +266,41 @@ def __init__(self, context, parent: Optional[QtWidgets.QWidget] = None) -> None: self.setExpandsOnDoubleClick(False) self.itemDoubleClicked.connect(self.editItem) - def showEditStripsDialog(self, item: SequenceItem) -> Optional[str]: - value, success = QtWidgets.QInputDialog.getText(self, item.fullName(), - "Strips", text=item.strips()) - return value if success else None + self.editStripsItem = None + + self.editStripsDialog = EditStripsDialog(self) + self.editStripsDialog.setModal(False) + self.editStripsDialog.hide() + self.editStripsDialog.accepted.connect(self.updateStripItem) + self.editStripsDialog.rejected.connect(lambda: self.context.lock_profile.emit(False)) + + def showEditStripsDialog(self, item: SequenceItem) -> None: + if isinstance(item, SequenceItem): + if item not in self.allItems(): + return None + if self.editStripsItem is not item or self.editStripsItem is None: + self.updateStripItem() + self.editStripsItem = item + self.editStripsDialog.setWindowTitle(f"Edit Strips / {item.fullName()}") + self.editStripsDialog.setStrips(item.strips()) + self.editStripsDialog.show() + self.context.lock_profile.emit(True) + + def updateStripItem(self) -> None: + item = self.editStripsItem + if isinstance(item, SequenceItem): + if item not in self.allItems(): + return None + strips = self.editStripsDialog.strips() + strips = normalize_strip_expression(strips) + try: + parse_strip_expression(strips) + except Exception as exc: + QtWidgets.QMessageBox.warning(self, "Invalid strips", f"Invalid strips: {strips} ({exc})") + else: + item.setStrips(strips) + self.editStripsItem = None + self.context.lock_profile.emit(False) def showEditIntervalDialog(self, item: SequenceItem) -> Optional[int]: value, success = QtWidgets.QInputDialog.getInt(self, item.fullName(), @@ -251,15 +309,7 @@ def showEditIntervalDialog(self, item: SequenceItem) -> Optional[int]: def editItemStrips(self, item): if item.allChildren(): - strips = self.showEditStripsDialog(item) - if strips is not None: - strips = normalize_strip_expression(strips) - try: - parse_strip_expression(strips) - except Exception as exc: - QtWidgets.QMessageBox.warning(self, "Invalid strips", f"Invalid strips: {strips} ({exc})") - else: - item.setStrips(strips) + self.showEditStripsDialog(item) def editItemInterval(self, item): if item.interval(): From b37999b969ac41001eb206b425cec336c12e108e Mon Sep 17 00:00:00 2001 From: Bernhard Arnold Date: Thu, 19 Sep 2024 16:47:43 +0200 Subject: [PATCH 14/14] fixes --- src/sqc/core/utils.py | 7 ++++--- src/sqc/gui/badstrips.py | 12 ++++++------ src/sqc/gui/sequence.py | 2 +- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/sqc/core/utils.py b/src/sqc/core/utils.py index 133f136..f343e87 100644 --- a/src/sqc/core/utils.py +++ b/src/sqc/core/utils.py @@ -57,9 +57,10 @@ def create_slices(all: List[str], selected: List[str]) -> List[List[str]]: def normalize_strip_expression(expression: str) -> str: """Return normalized version of strip expression.""" - expression = re.sub(r'\s+', " ", expression.strip()) - tokens = re.split(r'[,\s]+', expression) - return ", ".join(list(filter(None, tokens))) + tokens = re.findall(r'\b\d+\s*-\s*\d+\b|[^\s,]+', expression) + tokens = [re.sub(r'\s*-\s*', "-", token).strip() for token in tokens] + tokens = [token.strip("-") for token in tokens] # strip open ranges + return ", ".join(filter(None, tokens)) def parse_strip_expression(expression: str) -> Generator[Tuple[str, str], None, None]: diff --git a/src/sqc/gui/badstrips.py b/src/sqc/gui/badstrips.py index 30cb929..d4117ba 100644 --- a/src/sqc/gui/badstrips.py +++ b/src/sqc/gui/badstrips.py @@ -327,13 +327,13 @@ def importFile(self) -> None: self.clearBoundingBoxes() for box in bounding_boxes: item = BoundingBoxItem() - item.setEnabled(box["enabled"]), + item.setEnabled(box["enabled"]) item.setTypename(box["type"]) item.setFirstStrip(box["first_strip"]) item.setLastStrip(box["last_strip"]) item.setMinimumValue(box["minimum_value"]) item.setMaximumValue(box["maximum_value"]) - item.setUnit(box["unit"]), + item.setUnit(box["unit"]) self.addBoundingBox(item) self.updateBoxes() @@ -484,10 +484,10 @@ def filterBadStrips(self, typename: str) -> list[tuple]: (item.maximumValue() * unit).to_base_units().m, )) field = self.fields.get(typename) - for name, items in self._data.get(typename, {}).items(): - for item in items: - strip = int(item.get("strip")) - value = item.get(field) + for name, entires in self._data.get(typename, {}).items(): + for entry in entires: + strip = int(entry.get("strip")) + value = entry.get(field) if strip is None or value is None: continue found_match = False diff --git a/src/sqc/gui/sequence.py b/src/sqc/gui/sequence.py index 2cb770a..84d02b9 100644 --- a/src/sqc/gui/sequence.py +++ b/src/sqc/gui/sequence.py @@ -266,7 +266,7 @@ def __init__(self, context, parent: Optional[QtWidgets.QWidget] = None) -> None: self.setExpandsOnDoubleClick(False) self.itemDoubleClicked.connect(self.editItem) - self.editStripsItem = None + self.editStripsItem: Optional[SequenceItem] = None self.editStripsDialog = EditStripsDialog(self) self.editStripsDialog.setModal(False)