From 17b8defab14f8086b4b11d49741143675f107654 Mon Sep 17 00:00:00 2001 From: zhujun Date: Mon, 2 Mar 2020 17:16:52 +0100 Subject: [PATCH 01/23] Fix display bug in ImageTool --- extra_foam/gui/image_tool/corrected_view.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/extra_foam/gui/image_tool/corrected_view.py b/extra_foam/gui/image_tool/corrected_view.py index 0795dd08c..f710ac842 100644 --- a/extra_foam/gui/image_tool/corrected_view.py +++ b/extra_foam/gui/image_tool/corrected_view.py @@ -13,7 +13,7 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( - QHBoxLayout, QSplitter, QVBoxLayout + QHBoxLayout, QSplitter, QVBoxLayout, QWidget ) from .simple_image_data import _SimpleImageData @@ -110,6 +110,7 @@ def __init__(self, *args, **kwargs): def initUI(self): """Override.""" + ctrl_widget = QWidget() ctrl_layout = QHBoxLayout() AT = Qt.AlignTop ctrl_layout.addWidget(self._roi_ctrl_widget) @@ -117,6 +118,9 @@ def initUI(self): ctrl_layout.addWidget(self._roi_hist_ctrl_widget, alignment=AT) ctrl_layout.addWidget(self._roi_norm_ctrl_widget, alignment=AT) ctrl_layout.addWidget(self._roi_proj_ctrl_widget, alignment=AT) + ctrl_widget.setLayout(ctrl_layout) + ctrl_widget.setFixedHeight( + self._roi_proj_ctrl_widget.minimumSizeHint().height()) subview_splitter = QSplitter(Qt.Vertical) subview_splitter.setHandleWidth(9) @@ -132,7 +136,7 @@ def initUI(self): layout = QVBoxLayout() layout.addWidget(view_splitter) - layout.addLayout(ctrl_layout) + layout.addWidget(ctrl_widget) self.setLayout(layout) def initConnections(self): From f5686f2e409805b3de5509117768cc15786b4d77 Mon Sep 17 00:00:00 2001 From: zhujun Date: Tue, 3 Mar 2020 10:48:10 +0100 Subject: [PATCH 02/23] Add benchmark_demo --- benchmarks/benchmark_demo.ipynb | 381 ++++++++++++++++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 benchmarks/benchmark_demo.ipynb diff --git a/benchmarks/benchmark_demo.ipynb b/benchmarks/benchmark_demo.ipynb new file mode 100644 index 000000000..f24ac95b7 --- /dev/null +++ b/benchmarks/benchmark_demo.ipynb @@ -0,0 +1,381 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Benchmark of EXtra-foam\n", + "\n", + "Author: Jun Zhu, March 03, 2020" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from extra_foam.algorithms import mask_image_data, nanmean_image_data, nansum\n", + "import random\n", + "import numpy as np" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "72" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import multiprocessing as mp\n", + "\n", + "mp.cpu_count()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Assume we want to process a train of detector images." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "images = np.random.randn(128, 1200, 1200).astype(np.float32)\n", + "images[:, ::2, ::2] = np.nan" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Benchmark nanmean" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1.21 s ± 2.58 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)\n" + ] + } + ], + "source": [ + "%timeit np.nanmean(images, axis=0)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "13.5 ms ± 759 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)\n" + ] + } + ], + "source": [ + "%timeit nanmean_image_data(images)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A common use case is to apply `nanmean` to a list of selected images." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "# select 120 images out of 128 ones\n", + "selected = random.sample(range(images.shape[0]), 120)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1.43 s ± 1.66 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)\n" + ] + } + ], + "source": [ + "# numpy is slower than operating on all the images since it copies the data when 'selected' is a list.\n", + "%timeit np.nanmean(images[selected], axis=0)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "12.1 ms ± 117 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)\n" + ] + } + ], + "source": [ + "%timeit nanmean_image_data(images, kept=selected)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Benchmark masking" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "threshold mask" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "353 ms ± 216 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)\n" + ] + } + ], + "source": [ + "imgs = images.copy()\n", + "%timeit imgs[(imgs > 1) | (imgs < -1)] = np.nan" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "19.6 ms ± 533 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)\n" + ] + } + ], + "source": [ + "imgs = images.copy()\n", + "%timeit mask_image_data(imgs, threshold_mask=(-1, 1))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "image mask" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "image_mask = np.ones((1200, 1200), dtype=bool)\n", + "image_mask[::3, ::3] = np.nan" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "723 ms ± 424 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)\n" + ] + } + ], + "source": [ + "imgs = images.copy()\n", + "%timeit imgs[:, image_mask] = np.nan" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "27.5 ms ± 71.5 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)\n" + ] + } + ], + "source": [ + "imgs = images.copy()\n", + "%timeit mask_image_data(imgs, image_mask=image_mask)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "threshold mask and image mask" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "637 ms ± 595 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)\n" + ] + } + ], + "source": [ + "imgs = images.copy()\n", + "%timeit imgs[(image_mask) | (imgs > 1) | (imgs < -1)] = np.nan" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "27.5 ms ± 37.6 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)\n" + ] + } + ], + "source": [ + "imgs = images.copy()\n", + "%timeit mask_image_data(imgs, image_mask=image_mask, threshold_mask=(-1, 1))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Benchmark nan-statistics" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Non-paralleled funtion can be also much faster than `numpy`." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "images = np.random.randn(1200, 1200).astype(np.float32)\n", + "images[::2, ::2] = np.nan" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "4.64 ms ± 4.84 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)\n" + ] + } + ], + "source": [ + "%timeit np.nansum(images)" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1.48 ms ± 60.1 ns per loop (mean ± std. dev. of 7 runs, 1000 loops each)\n" + ] + } + ], + "source": [ + "%timeit nansum(images)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.7.3" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} From ea9b08c79a5e3ae08c6e4531935c002e7c1a2cf3 Mon Sep 17 00:00:00 2001 From: zhujun Date: Tue, 3 Mar 2020 08:44:37 +0100 Subject: [PATCH 03/23] Hotfix 0.8.0.1 --- docs/changelog.rst | 8 ++++++++ extra_foam/__init__.py | 2 +- setup.py | 4 +++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index d63c042db..a02b132a2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,14 @@ CHANGELOG ========= +0.8.0.1 (3 March 2020) +------------------------ + +- **Bug Fix** + + - Fix display bug in ImageTool #85 + + 0.8.0 (2 March 2020) ------------------------ diff --git a/extra_foam/__init__.py b/extra_foam/__init__.py index 8e3b2f3a9..f0bb0ede0 100644 --- a/extra_foam/__init__.py +++ b/extra_foam/__init__.py @@ -37,7 +37,7 @@ import os -__version__ = "0.8.0" +__version__ = "0.8.0.1" # root path for storing config and log files ROOT_PATH = os.path.join(os.path.expanduser("~"), ".EXtra-foam") diff --git a/setup.py b/setup.py index 7b8a2bdd5..ad11fbb2c 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,9 @@ def find_version(): with open(osp.join('extra_foam', '__init__.py')) as fp: for line in fp: - m = re.search(r'^__version__ = "(\d+\.\d+\.\d[a-z]*\d*)"', line, re.M) + # FIXME: a better version parser + # m = re.search(r'^__version__ = "(\d+\.\d+\.\d[a-z]*\d*)"', line, re.M) + m = re.search(r'^__version__ = "*([\d.]+)"', line, re.M) if m is not None: return m.group(1) raise RuntimeError("Unable to find version string.") From d3c2b480c13114c6f0d3ebbe6346d6ad046f2801 Mon Sep 17 00:00:00 2001 From: Robert Rosca <32569096+RobertRosca@users.noreply.github.com> Date: Fri, 6 Mar 2020 13:45:22 +0100 Subject: [PATCH 04/23] Add branch-based CI and Singularity image deployment --- .travis.yml | 125 ++++++++++++++++++++++++++++++------------------- extra-foam.def | 87 ++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 49 deletions(-) create mode 100644 extra-foam.def diff --git a/.travis.yml b/.travis.yml index c72172289..f554b6514 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,71 +1,99 @@ -dist: xenial -sudo: required language: cpp +stages: + - name: basic + if: branch != master + - name: full linux + if: branch = master + - name: singularity build push + if: branch = master OR tag IS present + allow_failures: true + +env: + global: + - DISPLAY=":99.0" + services: - xvfb -matrix: +jobs: include: - - os: linux - addons: - apt: - sources: - - ubuntu-toolchain-r-test - packages: - - g++-6 - env: COMPILER=gcc GCC=6 - - os: linux - if: branch = master + - stage: basic + env: PYTHON_VERSION="3.7.5" COMPILER="gcc" GCCv="7" + - stage: full linux + os: linux + env: PYTHON_VERSION="3.7.5" COMPILER="gcc" GCCv="7" + - stage: full linux + os: linux + env: PYTHON_VERSION="3.7.5" COMPILER="gcc" GCCv="6" + - stage: full linux + os: linux + env: PYTHON_VERSION="3.6.9" COMPILER="gcc" GCCv="7" + - stage: full linux + os: linux + env: PYTHON_VERSION="3.6.9" COMPILER="gcc" GCCv="6" + - stage: singularity build push + env: PYTHON_VERSION="3.7.5" + language: go + go: "1.13" + git: { submodules: false, depth: 1 } addons: apt: - sources: - - ubuntu-toolchain-r-test - packages: - - g++-7 - env: COMPILER=gcc GCC=7 - -env: - global: - - DISPLAY=":99.0" - - MINCONDA_VERSION="latest" - - MINCONDA_LINUX="Linux-x86_64" + packages: [ flawfinder, squashfs-tools, uuid-dev, libuuid1, libffi-dev, libssl-dev, libssl1.0.0, libarchive-dev, libgpgme11-dev, libseccomp-dev ] + homebrew: { packages: [ squashfs ], update: true } + before_install: skip + install: + - SINGULARITY_BASE="${GOPATH}/src/github.com/sylabs/singularity" + - export PATH="${GOPATH}/bin:${PATH}" + - mkdir -p "${GOPATH}/src/github.com/sylabs" + - cd "${GOPATH}/src/github.com/sylabs" + - git clone -b v3.5.0 https://github.com/sylabs/singularity + - cd singularity + - ./mconfig -v -p /usr/local + - make -j `nproc 2>/dev/null || echo 1` -C ./builddir all + - sudo make -C ./builddir install + before_script: + # token used for push commnad + - echo -e "$SYLABS_TOKEN" > token + - singularity remote login --tokenfile ./token || exit 1 + # key used for image signing + # for travis ci pgp key block you must replace newline with '\\n', and replace spaces with '\ ' + - echo -e "$SINGULARITY_KEY" > skey + - head -n 4 ./skey + - echo $SINGULARITY_KEY_PW | singularity key import ./skey || exit 1 + script: + - cd $TRAVIS_BUILD_DIR + - export SINGULARITYENV_PYTHON_VERSION=$PYTHON_VERSION + - export SINGULARITYENV_GIT_COMMIT_HASH=$TRAVIS_COMMIT + - sudo singularity build ./extra-foam.sif extra-foam.def + after_script: + - echo $SINGULARITY_KEY_PW | singularity sign ./extra-foam.sif + - singularity push ./extra-foam.sif library://robert.rosca/default/extra-foam:$TRAVIS_BRANCH before_install: - - | - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then - if [[ "$COMPILER" == "gcc" ]]; then - export CXX=g++-$GCC CC=gcc-$GCC; - fi - fi - -install: - - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then - MINCONDA_OS=$MINCONDA_LINUX; - fi - - wget "http://repo.continuum.io/miniconda/Miniconda3-$MINCONDA_VERSION-$MINCONDA_OS.sh" -O miniconda.sh + - export GXX="g++-$GCCv" GCC="gcc-$GCCv" + - sudo -E apt-add-repository -y "ppa:ubuntu-toolchain-r/test" + - sudo apt-get -q update + - sudo -E apt-get -yq --no-install-suggests --no-install-recommends $(travis_apt_get_options) install libxkbcommon-x11-0 $GXX + - sudo update-alternatives --install /usr/bin/g++ g++ /usr/bin/$GXX 0 + - sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/$GCC 0 + - g++ --version + - gcc --version + - wget "http://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh" -O miniconda.sh - bash miniconda.sh -b -p $HOME/miniconda - export PATH="$HOME/miniconda/bin:$PATH" +install: + - conda install -y python=$PYTHON_VERSION + - echo $PYTHON_VERSION - conda install -y cmake -c conda-forge - - which python - - python --version - - which pip - - pip --version - # QT_DEBUG_PLUGINS - - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then - sudo apt install -y libxkbcommon-x11-0; - pip install -e .[test]; - fi - + - pip install -e .[test] before_script: - /sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -ac -screen 0 1400x900x24 - sleep 3 - script: # test parallel version - python setup.py build_ext --with-tests - python setup.py test -v - - python setup.py benchmark -v # test series version - export FOAM_WITH_TBB=0 @@ -74,4 +102,3 @@ script: - export XTENSOR_WITH_XSIMD=0 - python setup.py build_ext --with-tests - python setup.py test -v - - python setup.py benchmark -v diff --git a/extra-foam.def b/extra-foam.def new file mode 100644 index 000000000..26c5b7be1 --- /dev/null +++ b/extra-foam.def @@ -0,0 +1,87 @@ +Bootstrap: library +From: centos:7.6.1810 + +%help + EXtra-foam (previously known as karaboFAI) is an application that provides + super fast on-line (real-time) and off-line data analysis and visualization + for experiments at European XFEL that using 2D detectors, namely AGIPD, DSSC, + LPD, FastCCD, JungFrau, etc., together with other 1D detectors (e.g. XGM, + digitizer, etc.) and various control data. + + GitHub: https://github.com/European-XFEL/EXtra-foam + Docs: https://extra-foam.readthedocs.io/en/latest/ + +%environment + __conda_setup="$('/usr/local/bin/conda' 'shell.bash' 'hook' 2> /dev/null)" + if [ $? -eq 0 ]; then + eval "$__conda_setup" + else + if [ -f "/usr/local/etc/profile.d/conda.sh" ]; then + . "/usr/local/etc/profile.d/conda.sh" + else + export PATH="/usr/local/bin:$PATH" + fi + fi + unset __conda_setup + + conda activate base + +%runscript + extra-foam "$@" + +%post + export logpath=/.singularity.d/logs/ + mkdir -p $logpath + + # Set default python version if missing + PYTHON_VERSION="${PYTHON_VERSION:-3.7.5}" + echo "Building for python version: $PYTHON_VERSION" + + # Basic yum dependencies + ## Install and update + yum update -y | tee $logpath/00-yum-update.log + yum install -y epel-release | tee $logpath/01-yum-epel-release.log + yum groupinstall -y 'Development Tools' | tee $logpath/02-yum-dev-tools.log + yum install -y \ + nano \ + curl \ + wget \ + tar \ + bzip2 \ + git \ + e4fsprogs \ + xeyes \ + mesa-libGL \ + qt5-qtbase \ + libxkbcommon-x11 \ + | tee $logpath/03-yum-install.log + + ## Log yum packages + yum list installed | tee $logpath/04-yum-list-installed.log + + # Install and setup miniconda + ## Download and install minconda + curl -sSL http://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh -o /tmp/miniconda.sh + chmod +x /tmp/miniconda.sh + /tmp/miniconda.sh -bfp /usr/local/ + rm -f /tmp/miniconda.sh + source /usr/local/bin/activate + + ## Install python dependencies + conda install -y python=$PYTHON_VERSION + conda install -y -c anaconda cmake numpy + conda install -y -c omgarcia gcc-6 + + ## Download EXtra-foam source + mkdir -p /usr/local/src/ + cd /usr/local/src/ + git clone https://github.com/European-XFEL/EXtra-foam + cd EXtra-foam + git checkout $GIT_COMMIT_HASH + git submodule update --init | tee -a $logpath/05-extra-foam-submodules.log + git log -n 1 | tee -a $logpath/06-extra-foam-clone.log + + pip install . + + ## Export environment to concretise + conda env export -n base -f /.singularity.d/logs/07-conda-env-export.log From d59b58386010d9cc6b480ae15196c1ffb5980787 Mon Sep 17 00:00:00 2001 From: zhujun Date: Thu, 5 Mar 2020 15:40:25 +0100 Subject: [PATCH 05/23] Support ePix100 detector in EXtra-foam --- README.md | 8 +- docs/introduction.rst | 4 +- extra_foam/config.py | 10 +++ extra_foam/configs/mid.config.yaml | 11 +++ extra_foam/offline/file_server.py | 4 +- .../pipeline/processors/image_assembler.py | 32 ++++++++ .../processors/tests/test_image_assembler.py | 79 +++++++++++++++++++ 7 files changed, 142 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e8b6ebb54..67edfda89 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ EXtra-foam [![Build Status](https://travis-ci.org/European-XFEL/EXtra-foam.svg?branch=master)](https://travis-ci.org/European-XFEL/EXtra-foam) -*EXtra-foam* (previously known as *[karaboFAI](https://in.xfel.eu/readthedocs/docs/karabofai/en/latest/)*) is an application that provides -super fast on-line (real-time) and off-line data analysis and visualization for experiments at European XFEL that using 2D -detectors, namely AGIPD, DSSC, LPD, FastCCD, JungFrau, etc., together with other 1D detectors (e.g. -XGM, digitizer, etc.) and various control data. +*EXtra-foam* (previously known as *[karaboFAI](https://in.xfel.eu/readthedocs/docs/karabofai/en/latest/)*) is a +framework that provides super fast on-line (real-time) and off-line data analysis and visualization for +experiments at European XFEL that using 2D detectors (e.g. AGIPD, DSSC, LPD, ePix100, FastCCD, JungFrau, +etc.), together with other 1D detectors (e.g. XGM, digitizer, etc.) and various control data. [Documentation](https://extra-foam.readthedocs.io/en/latest/) diff --git a/docs/introduction.rst b/docs/introduction.rst index a16b12818..9114a2e8f 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -4,7 +4,7 @@ INTRODUCTION **EXtra-foam** ( **F**\ ast **O**\ nline **A**\ nalysis **M**\ onitor) is a tool that provides real-time and off-line data analysis (**azimuthal integration**, **ROI**, **correlation**, **binning**, etc.) and visualization for experiments using **2D area detectors** (*AGIPD*, -*DSSC* , *FastCCD*, *JungFrau*, *LPD*, etc.) at European XFEL. +*LPD*, *DSSC* , *FastCCD*, *JungFrau*, *ePix100*, etc.) at European XFEL. +------------+-------------------------+-------------------------+ @@ -22,6 +22,8 @@ real-time and off-line data analysis (**azimuthal integration**, **ROI**, **corr +------------+------------+------------+------------+------------+ | FastCCD | True | True | True | True | +------------+------------+------------+------------+------------+ +| ePix100 | True | True | True | True | +------------------------------------------------------------------ Why use **EXtra-foam** diff --git a/extra_foam/config.py b/extra_foam/config.py index c88eda7c9..43b7bec1a 100644 --- a/extra_foam/config.py +++ b/extra_foam/config.py @@ -341,6 +341,13 @@ class _Config(dict): NUMBER_OF_MODULES=1, MODULE_SHAPE=(1934, 960), PIXEL_SIZE=0.030e-3), + "ePix100": _AreaDetectorConfig( + REDIS_PORT=6384, + PULSE_RESOLVED=False, + REQUIRE_GEOMETRY=False, + NUMBER_OF_MODULES=1, + MODULE_SHAPE=(708, 768), + PIXEL_SIZE=0.110e-3), "BaslerCamera": _AreaDetectorConfig( REDIS_PORT=6389, PULSE_RESOLVED=False, @@ -546,6 +553,9 @@ def parse_detector_name(detector): if detector == 'JUNGFRAUPR': return 'JungFrauPR' + if detector == 'EPIX100': + return 'ePix100' + return detector.upper() diff --git a/extra_foam/configs/mid.config.yaml b/extra_foam/configs/mid.config.yaml index c3c58cdd1..7244d6659 100644 --- a/extra_foam/configs/mid.config.yaml +++ b/extra_foam/configs/mid.config.yaml @@ -8,6 +8,17 @@ SOURCE: - image.data MID_DET_AGIPD1M-1/DET/*CH0:xtdf: - image.data + ePix100: + PIPELINE: + # the property name for the raw data is 'data.image.data' + MID_EXP_EPIX-1/DET/RECEIVER:output: + - data.image + MID_EXP_EPIX-2/DET/RECEIVER:output: + - data.image + MID_EXP_EPIX-1/DET/RECEIVER:daqOutput: + - data.image.pixels + MID_EXP_EPIX-2/DET/RECEIVER:daqOutput: + - data.image.pixels DETECTOR: AGIPD: diff --git a/extra_foam/offline/file_server.py b/extra_foam/offline/file_server.py index 3ef685c1f..34fd62c1b 100644 --- a/extra_foam/offline/file_server.py +++ b/extra_foam/offline/file_server.py @@ -225,8 +225,10 @@ def run(self): fast_devices = [("*DET/*CH0:xtdf", "image.data")] elif detector == "JungFrau": fast_devices = [("*/DET/*:daqOutput", "data.adc")] - elif detector == "FastCCD": + elif detector in ["FastCCD"]: fast_devices = [("*/DAQ/*:daqOutput", "data.image.pixels")] + elif detector in ["ePix100"]: + fast_devices = [("*/DET/*:daqOutput", "data.image.pixels")] else: raise NotImplementedError(f"Unknown Detector: {detector}") diff --git a/extra_foam/pipeline/processors/image_assembler.py b/extra_foam/pipeline/processors/image_assembler.py index 2bd2be942..27fcc341a 100644 --- a/extra_foam/pipeline/processors/image_assembler.py +++ b/extra_foam/pipeline/processors/image_assembler.py @@ -639,6 +639,35 @@ def _get_modules_file(self, data, src): # return modules_data raise NotImplementedError + class EPix100ImageAssembler(BaseAssembler): + def _get_modules_bridge(self, data, src): + """Override. + + - calibrated, "data.image", (y, x, 1) + - raw, "data.image.data", (1, y, x) + -> (y, x) + """ + img_data = data[src] + dtype = img_data.dtype + + if dtype == _IMAGE_DTYPE: + return img_data.squeeze(axis=-1) + + # raw data of ePix100 has an unexpected dtype int16 + if dtype == np.int16: + return img_data.squeeze(axis=0) + + raise AssemblingError(f"Unknown detector data type: {dtype}!") + + def _get_modules_file(self, data, src): + """Override. + + - calibrated, "data.image.pixels", (y, x) + - raw, "data.image.pixels", (y, x) + -> (y, x) + """ + return data[src] + class FastCCDImageAssembler(BaseAssembler): def _get_modules_bridge(self, data, src): """Override. @@ -703,6 +732,9 @@ def create(cls, detector): if detector == 'FastCCD': return cls.FastCCDImageAssembler() + if detector == 'ePix100': + return cls.EPix100ImageAssembler() + if detector == 'BaslerCamera': return cls.BaslerCameraImageAssembler() diff --git a/extra_foam/pipeline/processors/tests/test_image_assembler.py b/extra_foam/pipeline/processors/tests/test_image_assembler.py index e5c0c4603..86e6f01de 100644 --- a/extra_foam/pipeline/processors/tests/test_image_assembler.py +++ b/extra_foam/pipeline/processors/tests/test_image_assembler.py @@ -818,6 +818,85 @@ def _runAssembleBridgeTest(self, shape, dtype, key): self._assembler.process(data) +class TestEPix100Assembler(unittest.TestCase): + @classmethod + def setUpClass(cls): + config.load('ePix100', 'MID') + + @classmethod + def tearDownClass(cls): + os.remove(config.config_file) + + def setUp(self): + self._assembler = ImageAssemblerFactory.create("ePix100") + + def _create_catalog(self, src_name, key_name): + catalog = SourceCatalog() + src = f'{src_name} {key_name}' + catalog.add_item(SourceItem('ePix100', src_name, [], key_name, None, None)) + return src, catalog + + def testAssembleFileCal(self): + self._runAssembleFileTest((708, 768), _IMAGE_DTYPE, 'data.image.pixels') + + def testAssembleFileRaw(self): + self._runAssembleFileTest((708, 768), np.int16, 'data.image.pixels') + + def _runAssembleFileTest(self, shape, dtype, key): + src, catalog = self._create_catalog('MID_EXP_EPIX-1/DET/RECEIVER:daqOutput', key) + + data = { + 'catalog': catalog, + 'meta': { + src: { + 'tid': 10001, + 'source_type': DataSource.FILE, + } + }, + 'raw': { + src: np.ones(shape, dtype=dtype) + }, + } + self._assembler.process(data) + self.assertIsNone(data['raw'][src]) + + with self.assertRaisesRegex(AssemblingError, 'Expected module shape'): + data['raw'][src] = np.ones((100, 100)) + self._assembler.process(data) + + def testAssembleBridgeCal(self): + self._runAssembleBridgeTest((708, 768, 1), _IMAGE_DTYPE, "data.image") + + def testAssembleBridgeRaw(self): + self._runAssembleBridgeTest((1, 708, 768), np.int16, "data.image.data") + + def _runAssembleBridgeTest(self, shape, dtype, key): + src, catalog = self._create_catalog('MID_EXP_EPIX-1/DET/RECEIVER:daqOutput', key) + + data = { + 'catalog': catalog, + 'meta': { + src: { + 'tid': 10001, + 'source_type': DataSource.BRIDGE, + } + }, + 'raw': { + src: np.ones(shape, dtype=dtype) + }, + } + + self._assembler.process(data) + self.assertIsNone(data['raw'][src]) + + with self.assertRaisesRegex(AssemblingError, 'Expected module shape'): + if dtype == _IMAGE_DTYPE: + data['raw'][src] = np.ones((100, 100, 1), dtype=dtype) + else: + data['raw'][src] = np.ones((1, 100, 100), dtype=np.int16) + self._assembler.process(data) + + class TestBaslerCameraAssembler(unittest.TestCase): @classmethod def setUpClass(cls): From adf3f5d945ff75ec30c1880e3ef47ce94d30423d Mon Sep 17 00:00:00 2001 From: zhujun Date: Fri, 6 Mar 2020 13:35:25 +0100 Subject: [PATCH 06/23] Implement Master and Slave ROI FOM scan Separate CorrelationProcessor into two. This is a prestep for the major upgrade of correlation analysis. --- docs/image_tool.rst | 6 + docs/statistics.rst | 3 + extra_foam/config.py | 4 +- .../gui/ctrl_widgets/roi_fom_ctrl_widget.py | 20 ++- .../gui/image_tool/tests/test_image_tool.py | 13 +- extra_foam/gui/mediator.py | 6 +- extra_foam/gui/plot_widgets/plot_items.py | 3 + .../graphicsItems/ViewBox/ViewBox.py | 10 +- extra_foam/gui/tests/test_main_gui.py | 94 ++++++------ extra_foam/gui/windows/correlation_w.py | 31 +++- .../gui/windows/tests/test_plot_windows.py | 12 +- extra_foam/pipeline/data_model.py | 9 +- extra_foam/pipeline/processors/correlation.py | 127 ++++++++-------- extra_foam/pipeline/processors/image_roi.py | 9 ++ .../processors/tests/test_correlation.py | 142 ++++++++++++------ extra_foam/pipeline/worker.py | 6 +- 16 files changed, 324 insertions(+), 171 deletions(-) diff --git a/docs/image_tool.rst b/docs/image_tool.rst index 860db210d..34c8e852d 100644 --- a/docs/image_tool.rst +++ b/docs/image_tool.rst @@ -78,6 +78,12 @@ ROI FOM setup +----------------------------+--------------------------------------------------------------------+ | ``FOM`` | ROI FOM type, e.g. *SUM*, *MEAN*, *MEDIAN*, *MIN*, *MAX*. | +----------------------------+--------------------------------------------------------------------+ +| ``Master-slave`` | Check to activate the *master-slave* model. This model is used | +| | exclusively in correlation plots (see :ref:`statistics analysis`). | +| | When it is activated, FOMs of ROI1 (master) and ROI2 (slave) will | +| | be plotted in the same correlation plot. For other statistics | +| | analysis like binning and histogram, only ROI1 FOM will be used. | ++----------------------------+--------------------------------------------------------------------+ ROI histogram setup """"""""""""""""""" diff --git a/docs/statistics.rst b/docs/statistics.rst index 57731ca9f..305180b1b 100644 --- a/docs/statistics.rst +++ b/docs/statistics.rst @@ -123,3 +123,6 @@ Setup the visualization of correlations of a given FOM with various slow data. .. image:: images/correlation_window.png :width: 800 + +One can also plot FOMs of ROI1 and ROI2 together when the *master-slave* mode is activated in +:ref:`ROI FOM setup`. diff --git a/extra_foam/config.py b/extra_foam/config.py index 43b7bec1a..76216460e 100644 --- a/extra_foam/config.py +++ b/extra_foam/config.py @@ -274,8 +274,8 @@ class _Config(dict): "GUI_BACKGROUND_COLOR": (225, 225, 225, 255), # colors of for ROI bounding boxes 1 to 4 "GUI_ROI_COLORS": ('b', 'r', 'g', 'o'), - # colors for correlation plots 1 to 4 - "GUI_CORRELATION_COLORS": ('b', 'o', 'g', 'r'), + # colors (master, slave) for correlation plots 1 and 2 + "GUI_CORRELATION_COLORS": (('b', 'r'), ('g', 'p')), # color of the image mask bounding box while drawing "GUI_MASK_BOUNDING_BOX_COLOR": 'b', # ------------------------------------------------------------- diff --git a/extra_foam/gui/ctrl_widgets/roi_fom_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/roi_fom_ctrl_widget.py index 72d6cbcd6..c256f6aa7 100644 --- a/extra_foam/gui/ctrl_widgets/roi_fom_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/roi_fom_ctrl_widget.py @@ -10,7 +10,7 @@ from collections import OrderedDict from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QComboBox, QGridLayout, QLabel +from PyQt5.QtWidgets import QCheckBox, QComboBox, QGridLayout, QLabel from .base_ctrl_widgets import _AbstractCtrlWidget, _AbstractGroupBoxCtrlWidget from ...config import RoiCombo, RoiFom @@ -54,6 +54,8 @@ def __init__(self, *args, **kwargs): # TODO: implement self._norm_cb.setDisabled(True) + self._master_slave_cb = QCheckBox("Master-slave") + self.initUI() self.initConnections() @@ -76,6 +78,9 @@ def initUI(self): layout.addWidget(QLabel("Norm: "), row, 0, AR) layout.addWidget(self._norm_cb, row, 1) + row += 1 + layout.addWidget(self._master_slave_cb, row, 1) + self.setLayout(layout) def initConnections(self): @@ -91,9 +96,22 @@ def initConnections(self): self._norm_cb.currentTextChanged.connect( lambda x: mediator.onRoiFomNormChange(self._available_norms[x])) + self._master_slave_cb.toggled.connect( + mediator.onRoiFomMasterSlaveModeChange) + self._master_slave_cb.toggled.connect(self.onMasterSlaveModeToggled) + def updateMetaData(self): """Overload.""" self._combo_cb.currentTextChanged.emit(self._combo_cb.currentText()) self._type_cb.currentTextChanged.emit(self._type_cb.currentText()) self._norm_cb.currentTextChanged.emit(self._norm_cb.currentText()) + self._master_slave_cb.toggled.emit( + self._master_slave_cb.isChecked()) return True + + def onMasterSlaveModeToggled(self, state): + if state: + self._combo_cb.setCurrentText("ROI1") + self._combo_cb.setEnabled(False) + else: + self._combo_cb.setEnabled(True) diff --git a/extra_foam/gui/image_tool/tests/test_image_tool.py b/extra_foam/gui/image_tool/tests/test_image_tool.py index c8db0da80..7bbc4cfca 100644 --- a/extra_foam/gui/image_tool/tests/test_image_tool.py +++ b/extra_foam/gui/image_tool/tests/test_image_tool.py @@ -534,13 +534,21 @@ def testRoiFomCtrlWidget(self): widget._combo_cb.setCurrentText(avail_combos[RoiCombo.ROI1_SUB_ROI2]) widget._type_cb.setCurrentText(avail_types[RoiFom.MEDIAN]) widget._norm_cb.setCurrentText(avail_norms[Normalizer.ROI]) - proc.update() - self.assertEqual(RoiCombo.ROI1_SUB_ROI2, proc._fom_combo) self.assertEqual(RoiFom.MEDIAN, proc._fom_type) self.assertEqual(Normalizer.ROI, proc._fom_norm) + # test activate/deactivate master-slave mode + self.assertFalse(widget._master_slave_cb.isChecked()) + widget._master_slave_cb.setChecked(True) + proc.update() + self.assertTrue(proc._roi_fom_master_slave) + self.assertFalse(widget._combo_cb.isEnabled()) + self.assertEqual("ROI1", widget._combo_cb.currentText()) + widget._master_slave_cb.setChecked(False) + self.assertTrue(widget._combo_cb.isEnabled()) + def testRoiHistCtrl(self): widget = self.image_tool._corrected_view._roi_hist_ctrl_widget avail_combos = {value: key for key, value in widget._available_combos.items()} @@ -730,5 +738,6 @@ def testCalibrationCtrlWidget(self): self.assertFalse(widget._gain_slicer_le.isEnabled()) self.assertFalse(widget._offset_slicer_le.isEnabled()) + if __name__ == '__main__': unittest.main() diff --git a/extra_foam/gui/mediator.py b/extra_foam/gui/mediator.py index d0396f68c..009c28711 100644 --- a/extra_foam/gui/mediator.py +++ b/extra_foam/gui/mediator.py @@ -186,6 +186,9 @@ def onRoiFomComboChange(self, value: IntEnum): def onRoiFomNormChange(self, value: IntEnum): self._meta.hset(mt.ROI_PROC, "fom:norm", int(value)) + def onRoiFomMasterSlaveModeChange(self, value: bool): + self._meta.hset(mt.ROI_PROC, "fom:master_slave", str(value)) + def onRoiHistComboChange(self, value: IntEnum): self._meta.hset(mt.ROI_PROC, "hist:combo", int(value)) @@ -232,7 +235,8 @@ def onCorrelationParamChange(self, value: tuple): pipe.execute() def onCorrelationReset(self): - self._meta.hset(mt.CORRELATION_PROC, "reset", 1) + self._meta.hset(mt.CORRELATION_PROC, "reset1", 1) + self._meta.hset(mt.CORRELATION_PROC, "reset2", 1) def onBinParamChange(self, value: tuple): # index, source, bin_range, number of bins, diff --git a/extra_foam/gui/plot_widgets/plot_items.py b/extra_foam/gui/plot_widgets/plot_items.py index a76000bcc..c0779d45b 100644 --- a/extra_foam/gui/plot_widgets/plot_items.py +++ b/extra_foam/gui/plot_widgets/plot_items.py @@ -423,6 +423,9 @@ def setData(self, x, y, y_min=None, y_max=None, beam=None): self.update() self.informViewBoundsChanged() + def isEmptyGraph(self): + return not bool(len(self._x)) + def preparePath(self): p = QPainterPath() diff --git a/extra_foam/gui/pyqtgraph/graphicsItems/ViewBox/ViewBox.py b/extra_foam/gui/pyqtgraph/graphicsItems/ViewBox/ViewBox.py index 0982cb37c..4a37b82c7 100644 --- a/extra_foam/gui/pyqtgraph/graphicsItems/ViewBox/ViewBox.py +++ b/extra_foam/gui/pyqtgraph/graphicsItems/ViewBox/ViewBox.py @@ -1285,7 +1285,15 @@ def childrenBounds(self, frac=None, orthoRange=(None,None), items=None): useX = True useY = True - + + # FIXME: patch in EXtra-foam + try: + if item.isEmptyGraph(): + continue + except AttributeError: + pass + # FIXME + if hasattr(item, 'dataBounds'): if frac is None: frac = (1.0, 1.0) diff --git a/extra_foam/gui/tests/test_main_gui.py b/extra_foam/gui/tests/test_main_gui.py index 4cb2c4678..4a0bd21f2 100644 --- a/extra_foam/gui/tests/test_main_gui.py +++ b/extra_foam/gui/tests/test_main_gui.py @@ -249,74 +249,76 @@ def testCorrelationCtrlWidget(self): combo_lst) train_worker = self.train_worker - proc = train_worker._correlation_proc - - proc.update() + processors = [train_worker._correlation1_proc, train_worker._correlation2_proc] # test default - self.assertEqual(AnalysisType(0), proc.analysis_type) - self.assertEqual([""] * _N_PARAMS, proc._sources) - self.assertEqual([_DEFAULT_RESOLUTION] * _N_PARAMS, proc._resolutions) + for proc in processors: + proc.update() + self.assertEqual(AnalysisType(0), proc.analysis_type) + self.assertEqual("", proc._source) + self.assertEqual(_DEFAULT_RESOLUTION, proc._resolution) # set new FOM - proc._resets = [False] * _N_PARAMS widget._analysis_type_cb.setCurrentText(analysis_types[AnalysisType.ROI_PROJ]) - proc.update() - self.assertEqual(AnalysisType.ROI_PROJ, proc.analysis_type) - self.assertTrue(all(proc._resets)) + for proc in processors: + proc._reset = False + proc.update() + self.assertEqual(AnalysisType.ROI_PROJ, proc.analysis_type) + self.assertTrue(proc._reset) - # change source - for i in range(_N_PARAMS): - proc._resets[i] = False + for idx, proc in enumerate(processors): + # change source + proc._reset = False ctg, device_id, ppt = 'Metadata', "META", "timestamp.tid" - widget._table.cellWidget(i, 0).setCurrentText(ctg) - self.assertEqual(device_id, widget._table.cellWidget(i, 1).currentText()) - self.assertEqual(ppt, widget._table.cellWidget(i, 2).currentText()) + widget._table.cellWidget(idx, 0).setCurrentText(ctg) + self.assertEqual(device_id, widget._table.cellWidget(idx, 1).currentText()) + self.assertEqual(ppt, widget._table.cellWidget(idx, 2).currentText()) proc.update() src = f"{device_id} {ppt}" if device_id and ppt else "" - self.assertEqual(src, proc._sources[i]) - self.assertTrue(proc._resets[i]) + self.assertEqual(src, proc._source) + self.assertTrue(proc._reset) # just test we can set a motor source - proc._resets[i] = False - widget._table.cellWidget(i, 0).setCurrentText("Motor") + proc._reset = False + widget._table.cellWidget(idx, 0).setCurrentText("Motor") proc.update() - self.assertTrue(proc._resets[i]) + self.assertTrue(proc._reset) - proc._resets[i] = False + proc._reset = False ctg, device_id, ppt = USER_DEFINED_KEY, "ABC", "efg" - widget._table.cellWidget(i, 0).setCurrentText(ctg) - self.assertEqual('', widget._table.cellWidget(i, 1).text()) - self.assertEqual('', widget._table.cellWidget(i, 2).text()) - widget._table.cellWidget(i, 1).setText(device_id) - widget._table.cellWidget(i, 2).setText(ppt) + widget._table.cellWidget(idx, 0).setCurrentText(ctg) + self.assertEqual('', widget._table.cellWidget(idx, 1).text()) + self.assertEqual('', widget._table.cellWidget(idx, 2).text()) + widget._table.cellWidget(idx, 1).setText(device_id) + widget._table.cellWidget(idx, 2).setText(ppt) self.assertEqual(device_id, widget._table.cellWidget(0, 1).text()) self.assertEqual(ppt, widget._table.cellWidget(0, 2).text()) proc.update() src = f"{device_id} {ppt}" if device_id and ppt else "" - self.assertEqual(src, proc._sources[i]) - self.assertTrue(proc._resets[i]) - - # change resolution - proc._resets = [False] * _N_PARAMS - for i in range(_N_PARAMS): - self.assertIsInstance(proc._correlations[i], SimplePairSequence) - widget._table.cellWidget(i, 3).setText(str(1.0)) + self.assertEqual(src, proc._source) + self.assertTrue(proc._reset) + + # change resolution + proc._reset = False + self.assertIsInstance(proc._correlation, SimplePairSequence) + self.assertIsInstance(proc._correlation_slave, SimplePairSequence) + widget._table.cellWidget(idx, 3).setText(str(1.0)) proc.update() - self.assertEqual(1.0, proc._resolutions[i]) - self.assertIsInstance(proc._correlations[i], OneWayAccuPairSequence) + self.assertEqual(1.0, proc._resolution) + self.assertIsInstance(proc._correlation, OneWayAccuPairSequence) + self.assertIsInstance(proc._correlation_slave, OneWayAccuPairSequence) # sequence type change will not have 'reset' - self.assertFalse(proc._resets[i]) - widget._table.cellWidget(i, 3).setText(str(2.0)) + self.assertFalse(proc._reset) + widget._table.cellWidget(idx, 3).setText(str(2.0)) proc.update() - self.assertEqual(2.0, proc._resolutions[i]) - self.assertTrue(proc._resets[i]) + self.assertEqual(2.0, proc._resolution) + self.assertTrue(proc._reset) - # test reset button - proc._resets = [False] * _N_PARAMS - widget._reset_btn.clicked.emit() - proc.update() - self.assertTrue(all(proc._resets)) + # test reset button + proc._reset = False + widget._reset_btn.clicked.emit() + proc.update() + self.assertTrue(proc._reset) def testBinCtrlWidget(self): from extra_foam.gui.ctrl_widgets.bin_ctrl_widget import ( diff --git a/extra_foam/gui/windows/correlation_w.py b/extra_foam/gui/windows/correlation_w.py index c585bb09b..f76dd7061 100644 --- a/extra_foam/gui/windows/correlation_w.py +++ b/extra_foam/gui/windows/correlation_w.py @@ -22,9 +22,9 @@ class CorrelationPlot(TimedPlotWidgetF): Widget for displaying correlations between FOM and different parameters. """ _colors = config["GUI_CORRELATION_COLORS"] - _pens = [FColor.mkPen(color) for color in _colors] - _brushes = [FColor.mkBrush(color, alpha=120) for color in _colors] - _opaque_brushes = [FColor.mkBrush(color) for color in _colors] + _pens = [(FColor.mkPen(pair[0]), FColor.mkPen(pair[1])) for pair in _colors] + _brushes = [(FColor.mkBrush(pair[0], alpha=120), + FColor.mkBrush(pair[1], alpha=120)) for pair in _colors] def __init__(self, idx, *, parent=None): """Initialization.""" @@ -41,7 +41,9 @@ def __init__(self, idx, *, parent=None): self.updateLabel() - self._plot = self.plotScatter(brush=self._brushes[self._idx-1]) + brush_pair = self._brushes[self._idx] + self._plot = self.plotScatter(brush=brush_pair[0]) + self._plot_slave = self.plotScatter(brush=brush_pair[1]) def refresh(self): """Override.""" @@ -54,6 +56,7 @@ def refresh(self): resolution = item.resolution y = item.y + y_slave = item.y_slave if resolution == 0: # SimplePairSequence if self._resolution != 0: @@ -61,12 +64,18 @@ def refresh(self): self._resolution = 0 self._plot.setData(item.x, y) + if y_slave is not None: + self._plot_slave.setData(item.x_slave, y_slave) else: # OneWayAccuPairSequence if self._resolution == 0: self._newStatisticsBarPlot(resolution) self._resolution = resolution self._plot.setData(item.x, y.avg, y_min=y.min, y_max=y.max) + if y_slave is not None: + self._plot_slave.setData( + item.x_slave, y_slave.avg, + y_min=y_slave.min, y_max=y_slave.max) def updateLabel(self): src = self._source @@ -80,12 +89,20 @@ def updateLabel(self): def _newScatterPlot(self): self.removeItem(self._plot) - self._plot = self.plotScatter(brush=self._brushes[self._idx-1]) + self.removeItem(self._plot_slave) + + brush_pair = self._brushes[self._idx] + self._plot = self.plotScatter(brush=brush_pair[0]) + self._plot_slave = self.plotScatter(brush=brush_pair[1]) def _newStatisticsBarPlot(self, resolution): self.removeItem(self._plot) - self._plot = self.plotStatisticsBar(beam=resolution, - pen=self._pens[self._idx-1]) + self.removeItem(self._plot_slave) + + pen_pair = self._pens[self._idx] + self._plot = self.plotStatisticsBar(beam=resolution, pen=pen_pair[0]) + self._plot_slave = self.plotStatisticsBar(beam=resolution, + pen=pen_pair[1]) class CorrelationWindow(_AbstractPlotWindow): diff --git a/extra_foam/gui/windows/tests/test_plot_windows.py b/extra_foam/gui/windows/tests/test_plot_windows.py index 9de243c39..fd97e5e62 100644 --- a/extra_foam/gui/windows/tests/test_plot_windows.py +++ b/extra_foam/gui/windows/tests/test_plot_windows.py @@ -235,24 +235,30 @@ def testResolutionSwitch(self): from extra_foam.gui.windows.correlation_w import CorrelationPlot from extra_foam.gui.plot_widgets.plot_items import StatisticsBarItem, pg + # resolution1 = 0.0 and resolution2 > 0.0 data = self.processed_data(1001, (4, 2, 2), correlation=True) widget = CorrelationPlot(0) widget._data = data widget.refresh() - plot_item = widget._plot + plot_item, plot_item_slave = widget._plot, widget._plot_slave self.assertIsInstance(plot_item, pg.ScatterPlotItem) + self.assertIsInstance(plot_item_slave, pg.ScatterPlotItem) widget._idx = 1 # a trick widget.refresh() self.assertNotIn(plot_item, widget._plot_item.items) # being deleted - plot_item = widget._plot + self.assertNotIn(plot_item_slave, widget._plot_item.items) # being deleted + plot_item, plot_item_slave = widget._plot, widget._plot_slave self.assertIsInstance(plot_item, StatisticsBarItem) + self.assertIsInstance(plot_item_slave, StatisticsBarItem) widget._idx = 0 # a trick widget.refresh() self.assertNotIn(plot_item, widget._plot_item.items) # being deleted - self.assertIsInstance(widget._plot, pg.ScatterPlotItem) + self.assertNotIn(plot_item_slave, widget._plot_item.items) # being deleted + self.assertIsInstance(widget._plot, pg.ScatterPlotItem) + self.assertIsInstance(widget._plot_slave, pg.ScatterPlotItem) class testHistogramWidgets(_TestDataMixin, unittest.TestCase): diff --git a/extra_foam/pipeline/data_model.py b/extra_foam/pipeline/data_model.py index 246a1db3c..dae369fee 100644 --- a/extra_foam/pipeline/data_model.py +++ b/extra_foam/pipeline/data_model.py @@ -329,6 +329,7 @@ class RoiDataTrain(DataItem): Attributes: geom1, geom2, geom3, geom4 (RectRoiGeom): ROI geometry. + fom_slave (float): ROI slave FOM. norm (float): ROI normalizer. proj (RoiProjData): ROI projection data item hist (_HistogramDataItem): ROI histogram data item. @@ -337,7 +338,7 @@ class RoiDataTrain(DataItem): N_ROIS = len(config['GUI_ROI_COLORS']) __slots__ = ['geom1', 'geom2', 'geom3', 'geom4', - 'norm', 'proj', 'hist'] + 'fom_slave', 'norm', 'proj', 'hist'] def __init__(self): super().__init__() @@ -347,6 +348,8 @@ def __init__(self): self.geom3 = RectRoiGeom() self.geom4 = RectRoiGeom() + self.fom_slave = None + self.norm = None self.proj = DataItem() self.hist = _HistogramDataItem() @@ -601,11 +604,13 @@ class CorrelationData(collections.abc.Mapping): class CorrelationDataItem: - __slots__ = ['x', 'y', 'source', 'resolution'] + __slots__ = ['x', 'y', 'x_slave', 'y_slave', 'source', 'resolution'] def __init__(self): self.x = None self.y = None # FOM + self.x_slave = None + self.y_slave = None # FOM slave self.source = "" self.resolution = 0.0 diff --git a/extra_foam/pipeline/processors/correlation.py b/extra_foam/pipeline/processors/correlation.py index f9626053a..d1322f5fb 100644 --- a/extra_foam/pipeline/processors/correlation.py +++ b/extra_foam/pipeline/processors/correlation.py @@ -21,15 +21,18 @@ class CorrelationProcessor(_BaseProcessor): """Add correlation information into processed data. Attributes: + _idx (int): Index of correlation starting from 1. analysis_type (AnalysisType): analysis type. _pp_analysis_type (AnalysisType): pump-probe analysis type. - _n_params (int): number of correlators. - _correlations (list): a list of pair sequences (SimplePairSequence, + _correlation (Sequence): pair sequences (SimplePairSequence, OneWayAccuPairSequence) for storing the history of (correlator, FOM). - _sources (list): a list of sources for slow data correlators. - _resolutions (list): a list of resolutions for correlations. - _resets (list): reset flags for correlation data. + _correlation_slave (Sequence): pair sequences (SimplePairSequence, + OneWayAccuPairSequence) for storing the history of + (correlator, FOM slave). + _source: source of slow data. + _resolution: resolution of correlation. + _reset: reset flag for correlation data. _correlation_pp (SimplePairSequence): store the history of (correlator, FOM) which is displayed in PumpProbeWindow. _pp_fail_flag (int): a flag used to check whether pump-probe FOM is @@ -39,61 +42,65 @@ class CorrelationProcessor(_BaseProcessor): # 10 pulses/train * 60 seconds * 5 minutes = 3000 _MAX_POINTS = 10 * 60 * 5 - def __init__(self): + def __init__(self, index): super().__init__() + self._idx = index + self.analysis_type = AnalysisType.UNDEFINED self._pp_analysis_type = AnalysisType.UNDEFINED - self._n_params = 2 - self._correlations = [] - for i in range(self._n_params): - self._correlations.append( - SimplePairSequence(max_len=self._MAX_POINTS)) - self._sources = [""] * self._n_params - self._resolutions = [0.0] * self._n_params - self._resets = [False] * self._n_params + self._correlation = SimplePairSequence(max_len=self._MAX_POINTS) + self._correlation_slave = SimplePairSequence(max_len=self._MAX_POINTS) + self._source = "" + self._resolution = 0.0 + self._reset = False self._correlation_pp = SimplePairSequence(max_len=self._MAX_POINTS) self._pp_fail_flag = 0 def update(self): """Override.""" + idx = self._idx cfg = self._meta.hget_all(mt.CORRELATION_PROC) if self._update_analysis(AnalysisType(int(cfg['analysis_type']))): - for i in range(self._n_params): - self._resets[i] = True - - for i in range(len(self._correlations)): - src = cfg[f'source{i+1}'] - if self._sources[i] != src: - self._sources[i] = src - self._resets[i] = True - - resolution = float(cfg[f'resolution{i+1}']) - if self._resolutions[i] != 0 and resolution == 0: - self._correlations[i] = SimplePairSequence( - max_len=self._MAX_POINTS) - elif self._resolutions[i] == 0 and resolution != 0: - self._correlations[i] = OneWayAccuPairSequence( - resolution, max_len=self._MAX_POINTS) - elif self._resolutions[i] != resolution: - # In the above two cases, we do not need 'reset' since - # new Sequence object will be constructed. - self._resets[i] = True - self._resolutions[i] = resolution - - if 'reset' in cfg: - self._meta.hdel(mt.CORRELATION_PROC, 'reset') - for i in range(self._n_params): - self._resets[i] = True + self._reset = True + + src = cfg[f'source{idx}'] + if self._source != src: + self._source = src + self._reset = True + + resolution = float(cfg[f'resolution{idx}']) + if self._resolution != 0 and resolution == 0: + self._correlation = SimplePairSequence( + max_len=self._MAX_POINTS) + self._correlation_slave = SimplePairSequence( + max_len=self._MAX_POINTS) + elif self._resolution == 0 and resolution != 0: + self._correlation = OneWayAccuPairSequence( + resolution, max_len=self._MAX_POINTS) + self._correlation_slave = OneWayAccuPairSequence( + resolution, max_len=self._MAX_POINTS) + elif self._resolution != resolution: + # In the above two cases, we do not need 'reset' since + # new Sequence object will be constructed. + self._reset = True + self._resolution = resolution + + reset_key = f'reset{idx}' + if reset_key in cfg: + self._meta.hdel(mt.CORRELATION_PROC, reset_key) + self._reset = True @profiler("Correlation Processor") def process(self, data): """Override.""" self._process_general(data) - self._process_pump_probe(data) + if self._idx == 1: + # process only once + self._process_pump_probe(data) def _process_general(self, data): if self.analysis_type == AnalysisType.UNDEFINED: @@ -104,25 +111,25 @@ def _process_general(self, data): if self.analysis_type == AnalysisType.PUMP_PROBE: pp_analysis_type = processed.pp.analysis_type if self._pp_analysis_type != pp_analysis_type: - for i in range(self._n_params): - self._resets[i] = True + # reset if pump-pobe analysis type changes + self._reset = True self._pp_analysis_type = pp_analysis_type - for i in range(self._n_params): - if self._resets[i]: - self._correlations[i].reset() - self._resets[i] = False + if self._reset: + self._correlation.reset() + self._correlation_slave.reset() + self._reset = False try: self._update_data_point(processed, raw) except ProcessingError as e: logger.error(f"[Correlation] {str(e)}!") - for i in range(self._n_params): - out = processed.corr[i] - out.x, out.y = self._correlations[i].data() - out.source = self._sources[i] - out.resolution = self._resolutions[i] + out = processed.corr[self._idx - 1] + out.x, out.y = self._correlation.data() + out.x_slave, out.y_slave = self._correlation_slave.data() + out.source = self._source + out.resolution = self._resolution def _process_pump_probe(self, data): """Process the correlation in pump-probe analysis. @@ -146,6 +153,7 @@ def _process_pump_probe(self, data): def _update_data_point(self, processed, raw): analysis_type = self.analysis_type + fom_slave = None if analysis_type == AnalysisType.PUMP_PROBE: fom = processed.pp.fom if fom is None: @@ -160,6 +168,7 @@ def _update_data_point(self, processed, raw): self._pp_fail_flag = 0 elif analysis_type == AnalysisType.ROI_FOM: fom = processed.roi.fom + fom_slave = processed.roi.fom_slave if fom is None: raise ProcessingError("ROI FOM is not available") elif analysis_type == AnalysisType.ROI_PROJ: @@ -175,12 +184,12 @@ def _update_data_point(self, processed, raw): raise UnknownParameterError( f"[Correlation] Unknown analysis type: {self.analysis_type}") - for i in range(self._n_params): - v, err = self._fetch_property_data( - processed.tid, raw, self._sources[i]) + v, err = self._fetch_property_data(processed.tid, raw, self._source) - if err: - logger.error(err) + if err: + logger.error(err) - if v is not None: - self._correlations[i].append((v, fom)) + if v is not None: + self._correlation.append((v, fom)) + if fom_slave is not None: + self._correlation_slave.append((v, fom_slave)) diff --git a/extra_foam/pipeline/processors/image_roi.py b/extra_foam/pipeline/processors/image_roi.py index 45ceec917..111aa45bb 100644 --- a/extra_foam/pipeline/processors/image_roi.py +++ b/extra_foam/pipeline/processors/image_roi.py @@ -283,6 +283,8 @@ class ImageRoiTrain(_RoiProcessorBase): """Train-resolved ROI processor. Attributes: + _roi_fom_master_slave (bool): True for activating the ROI FOM + master-slave mode. _proj_combo (RoiCombo): ROI combination when calculating ROI projection. _proj_type (RoiProjType): ROI projection type. @@ -318,6 +320,8 @@ class ImageRoiTrain(_RoiProcessorBase): def __init__(self): super().__init__() + self._roi_fom_master_slave = False + self._proj_combo = RoiCombo.ROI1 self._proj_type = RoiProjType.SUM self._proj_direct = 'x' @@ -334,6 +338,8 @@ def update(self): cfg = super().update() + self._roi_fom_master_slave = cfg['fom:master_slave'] == 'True' + self._proj_combo = RoiCombo(int(cfg['proj:combo'])) self._proj_type = RoiProjType(int(cfg['proj:type'])) self._proj_direct = cfg['proj:direct'] @@ -499,6 +505,9 @@ def _process_fom(self, processed): raise UnknownParameterError( f"[ROI][FOM] Unknown ROI combo: {self._fom_combo}") + if self._roi_fom_master_slave: + roi.fom_slave = fom2 + # TODO: normalize def _process_norm_pump_probe(self, processed): diff --git a/extra_foam/pipeline/processors/tests/test_correlation.py b/extra_foam/pipeline/processors/tests/test_correlation.py index 6525e728e..d2ff64140 100644 --- a/extra_foam/pipeline/processors/tests/test_correlation.py +++ b/extra_foam/pipeline/processors/tests/test_correlation.py @@ -12,7 +12,10 @@ import numpy as np -from extra_foam.pipeline.processors import CorrelationProcessor +from extra_foam.pipeline.processors.correlation import ( + CorrelationProcessor, SimplePairSequence, OneWayAccuPairSequence +) + from extra_foam.config import AnalysisType from extra_foam.pipeline.tests import _TestDataMixin @@ -40,69 +43,73 @@ def _set_fom(self, processed, analysis_type, fom): @mock.patch('extra_foam.ipc.ProcessLogger.error') @pytest.mark.parametrize("analysis_type", _analysis_types) - def testGeneral(self, error, analysis_type): + @pytest.mark.parametrize("index", [0, 1]) + def testGeneral(self, error, analysis_type, index): data, processed = self.simple_data(1001, (2, 2)) - data['raw'] = {'A ppt': 1} + corr = processed.corr + + slow_src = f'A{index} ppt' + data['raw'] = {slow_src: 1} - proc = CorrelationProcessor() + proc = CorrelationProcessor(index+1) proc.analysis_type = analysis_type - proc._sources = ['A ppt', ''] - proc._resolutions = [0.0, 0.0] + proc._resolution = 0.0 + proc._correlation = SimplePairSequence() + proc._correlation_slave = SimplePairSequence() + + # source is empty + proc._source = '' empty_arr = np.array([], dtype=np.float64) + proc.process(data) + np.testing.assert_array_equal(empty_arr, corr[index].x) + np.testing.assert_array_equal(empty_arr, corr[index].y) + if analysis_type != AnalysisType.PUMP_PROBE: + error.assert_called_once() + error.reset_mock() + # set FOM and source fom_gt = 10. self._set_fom(processed, analysis_type, fom_gt) + proc._source = slow_src proc.process(data) + assert slow_src == corr[index].source + assert 0.0 == corr[index].resolution + np.testing.assert_array_equal(np.array([1], dtype=np.float64), corr[index].x) + np.testing.assert_array_equal(np.array([10], dtype=np.float64), corr[index].y) - corr = processed.corr - assert 'A ppt' == corr[0].source - assert 0.0 == corr[0].resolution - np.testing.assert_array_equal(np.array([1], dtype=np.float64), corr[0].x) - np.testing.assert_array_equal(np.array([10], dtype=np.float64), corr[0].y) - - assert '' == corr[1].source - assert 0.0 == corr[1].resolution - np.testing.assert_array_equal(empty_arr, corr[1].x) - np.testing.assert_array_equal(empty_arr, corr[1].y) - - if analysis_type == AnalysisType.PUMP_PROBE: + if analysis_type == AnalysisType.PUMP_PROBE and index == 0: + # _process_pump_probe is called only once assert '' == corr.pp.source assert 0.0 == corr.pp.resolution np.testing.assert_array_equal(np.array([1001]), corr.pp.x) np.testing.assert_array_equal(np.array([fom_gt]), corr.pp.y) - # --------------- - # new data arrive - # --------------- - proc._sources = ['A ppt', 'B ppt'] - proc._resolutions = [0.0, 0.0] - - fom_gt = 20. + # ------------------- + # slow data not found + # ------------------- + data['raw'] = {} + fom_gt = 20 self._set_fom(processed, analysis_type, fom_gt) proc.process(data) + np.testing.assert_array_equal(np.array([1], dtype=np.float64), corr[index].x) + np.testing.assert_array_equal(np.array([10], dtype=np.float64), corr[index].y) error.assert_called_once() error.reset_mock() - np.testing.assert_array_equal(np.array([1, 1], dtype=np.float64), corr[0].x) - np.testing.assert_array_equal(np.array([10, 20], dtype=np.float64), corr[0].y) - # ------------------------ - # set slow data source 'B' - # ------------------------ - data['raw'] = {'A ppt': 2, 'B ppt': 5} + # --------------- + # new data arrive + # --------------- + data['raw'] = {slow_src: 2} + fom_gt = 20 + self._set_fom(processed, analysis_type, fom_gt) proc.process(data) - np.testing.assert_array_equal(np.array([1, 1, 2], dtype=np.float64), corr[0].x) - np.testing.assert_array_equal(np.array([10, 20, 20], dtype=np.float64), corr[0].y) - - assert 'B ppt' == corr[1].source - assert 0.0 == corr[1].resolution - np.testing.assert_array_equal(np.array([5], dtype=np.float64), corr[1].x) - np.testing.assert_array_equal(np.array([20], dtype=np.float64), corr[1].y) + np.testing.assert_array_equal(np.array([1, 2], dtype=np.float64), corr[index].x) + np.testing.assert_array_equal(np.array([10, 20], dtype=np.float64), corr[index].y) # ----------- # FOM is None # ----------- - proc._resolutions = [1.0, 0.0] fom_gt = None self._set_fom(processed, analysis_type, fom_gt) proc.process(data) @@ -111,12 +118,8 @@ def testGeneral(self, error, analysis_type): error.reset_mock() else: assert 1 == proc._pp_fail_flag - - np.testing.assert_array_equal(np.array([1, 1, 2], dtype=np.float64), corr[0].x) - np.testing.assert_array_equal(np.array([10, 20, 20], dtype=np.float64), corr[0].y) - - np.testing.assert_array_equal(np.array([5], dtype=np.float64), corr[1].x) - np.testing.assert_array_equal(np.array([20], dtype=np.float64), corr[1].y) + np.testing.assert_array_equal(np.array([1, 2], dtype=np.float64), corr[index].x) + np.testing.assert_array_equal(np.array([10, 20], dtype=np.float64), corr[index].y) # again if analysis_type == AnalysisType.PUMP_PROBE: @@ -124,3 +127,52 @@ def testGeneral(self, error, analysis_type): error.assert_called_once() error.reset_mock() assert 0 == proc._pp_fail_flag + + @pytest.mark.parametrize("index", [0, 1]) + def testMaskSlave(self, index): + data, processed = self.simple_data(1001, (2, 2)) + corr = processed.corr + + slow_src = f'A{index} ppt' + data['raw'] = {slow_src: 1} + + proc = CorrelationProcessor(index+1) + proc.analysis_type = AnalysisType.ROI_FOM + + # first data + processed.roi.fom = 10 + proc._source = slow_src + proc.process(data) + np.testing.assert_array_equal(np.array([1], dtype=np.float64), corr[index].x) + np.testing.assert_array_equal(np.array([10], dtype=np.float64), corr[index].y) + + # second data + processed.roi.fom = 20 + processed.roi.fom_slave = 1 + proc._source = slow_src + proc.process(data) + np.testing.assert_array_equal(np.array([1, 1], dtype=np.float64), corr[index].x) + np.testing.assert_array_equal(np.array([10, 20], dtype=np.float64), corr[index].y) + np.testing.assert_array_equal(np.array([1], dtype=np.float64), corr[index].x_slave) + np.testing.assert_array_equal(np.array([1], dtype=np.float64), corr[index].y_slave) + + # third data + processed.roi.fom = 30 + processed.roi.fom_slave = 2 + proc._source = slow_src + proc.process(data) + np.testing.assert_array_equal(np.array([1, 1, 1], dtype=np.float64), corr[index].x) + np.testing.assert_array_equal(np.array([10, 20, 30], dtype=np.float64), corr[index].y) + np.testing.assert_array_equal(np.array([1, 1], dtype=np.float64), corr[index].x_slave) + np.testing.assert_array_equal(np.array([1, 2], dtype=np.float64), corr[index].y_slave) + + # test reset + proc._reset = True + with mock.patch.object(proc._correlation_pp, "reset") as patched_reset: + proc.process(data) + np.testing.assert_array_equal(np.array([1], dtype=np.float64), corr[index].x) + np.testing.assert_array_equal(np.array([30], dtype=np.float64), corr[index].y) + np.testing.assert_array_equal(np.array([1], dtype=np.float64), corr[index].x_slave) + np.testing.assert_array_equal(np.array([2], dtype=np.float64), corr[index].y_slave) + # correlation_pp has another reset flag + patched_reset.assert_not_called() diff --git a/extra_foam/pipeline/worker.py b/extra_foam/pipeline/worker.py index c14a1b22c..06a0972be 100644 --- a/extra_foam/pipeline/worker.py +++ b/extra_foam/pipeline/worker.py @@ -221,7 +221,8 @@ def __init__(self, pause_ev, close_ev): self._filter = FomTrainFilter() self._histogram = HistogramProcessor() - self._correlation_proc = CorrelationProcessor() + self._correlation1_proc = CorrelationProcessor(1) + self._correlation2_proc = CorrelationProcessor(2) self._binning_proc = BinningProcessor() self._tr_xas = TrXasProcessor() @@ -231,7 +232,8 @@ def __init__(self, pause_ev, close_ev): self._ai_proc, self._filter, self._histogram, - self._correlation_proc, + self._correlation1_proc, + self._correlation2_proc, self._binning_proc, self._tr_xas, ] From 427446e1315a81aef438ed648a042fbc4a49df0c Mon Sep 17 00:00:00 2001 From: zhujun98 Date: Mon, 9 Mar 2020 08:19:30 +0100 Subject: [PATCH 07/23] Implement ROI FOM with normalization --- docs/image_tool.rst | 9 ++- .../gui/ctrl_widgets/roi_fom_ctrl_widget.py | 2 - extra_foam/pipeline/processors/image_roi.py | 21 +++--- .../processors/tests/test_image_roi.py | 71 +++++++++++-------- 4 files changed, 58 insertions(+), 45 deletions(-) diff --git a/docs/image_tool.rst b/docs/image_tool.rst index 34c8e852d..81680cf8c 100644 --- a/docs/image_tool.rst +++ b/docs/image_tool.rst @@ -78,6 +78,9 @@ ROI FOM setup +----------------------------+--------------------------------------------------------------------+ | ``FOM`` | ROI FOM type, e.g. *SUM*, *MEAN*, *MEDIAN*, *MIN*, *MAX*. | +----------------------------+--------------------------------------------------------------------+ +| ``Norm`` | Normalizer of ROI FOM. *Only applicable for train-resolved and | +| | pump-probe analysis*. | ++----------------------------+--------------------------------------------------------------------+ | ``Master-slave`` | Check to activate the *master-slave* model. This model is used | | | exclusively in correlation plots (see :ref:`statistics analysis`). | | | When it is activated, FOMs of ROI1 (master) and ROI2 (slave) will | @@ -124,7 +127,8 @@ Define the 1D projection of ROI (region of interest) analysis setup. +----------------------------+--------------------------------------------------------------------+ | ``Direction`` | Direction of 1D projection (x or y). | +----------------------------+--------------------------------------------------------------------+ -| ``Norm`` | Normalizer of the 1D-projection VFOM. | +| ``Norm`` | Normalizer of the 1D-projection VFOM. *Only applicable for | +| | train-resolved and pump-probe analysis*. | +----------------------------+--------------------------------------------------------------------+ | ``AUC range`` | AUC (area under a curve) integration range. | +----------------------------+--------------------------------------------------------------------+ @@ -245,7 +249,8 @@ aforementioned coordinate system, respectively. +----------------------------+--------------------------------------------------------------------+ | ``Integ range (1/A)`` | Azimuthal integration range. | +----------------------------+--------------------------------------------------------------------+ -| ``Norm`` | Normalizer of the azimuthal integration result. | +| ``Norm`` | Normalizer of the scattering curve. *Only applicable for | +| | train-resolved and pump-probe analysis*. | +----------------------------+--------------------------------------------------------------------+ | ``AUC range (1/A)`` | AUC (area under curve) range. | +----------------------------+--------------------------------------------------------------------+ diff --git a/extra_foam/gui/ctrl_widgets/roi_fom_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/roi_fom_ctrl_widget.py index c256f6aa7..eb2752334 100644 --- a/extra_foam/gui/ctrl_widgets/roi_fom_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/roi_fom_ctrl_widget.py @@ -51,8 +51,6 @@ def __init__(self, *args, **kwargs): self._norm_cb = QComboBox() for v in self._available_norms: self._norm_cb.addItem(v) - # TODO: implement - self._norm_cb.setDisabled(True) self._master_slave_cb = QCheckBox("Master-slave") diff --git a/extra_foam/pipeline/processors/image_roi.py b/extra_foam/pipeline/processors/image_roi.py index 111aa45bb..880ca564a 100644 --- a/extra_foam/pipeline/processors/image_roi.py +++ b/extra_foam/pipeline/processors/image_roi.py @@ -490,25 +490,25 @@ def _process_fom(self, processed): fom2 = self._compute_fom(self._roi2, self._fom_type) if self._fom_combo == RoiCombo.ROI1: - roi.fom = fom1 + fom = fom1 elif self._fom_combo == RoiCombo.ROI2: - roi.fom = fom2 + fom = fom2 else: if fom1 is None or fom2 is None: return if self._fom_combo == RoiCombo.ROI1_SUB_ROI2: - roi.fom = fom1 - fom2 + fom = fom1 - fom2 elif self._fom_combo == RoiCombo.ROI1_ADD_ROI2: - roi.fom = fom1 + fom2 + fom = fom1 + fom2 else: raise UnknownParameterError( f"[ROI][FOM] Unknown ROI combo: {self._fom_combo}") - if self._roi_fom_master_slave: - roi.fom_slave = fom2 - - # TODO: normalize + if fom is not None: + roi.fom = self._normalize_fom(processed, fom, self._fom_norm) + if self._roi_fom_master_slave and fom2 is not None: + roi.fom_slave = self._normalize_fom(processed, fom2, self._fom_norm) def _process_norm_pump_probe(self, processed): """Calculate train-resolved pump-probe ROI normalizers.""" @@ -573,9 +573,10 @@ def _process_fom_pump_probe(self, processed): if fom_on is None: return - # TODO: normalize + normalized_on, normalized_off = self._normalize_fom_pp( + processed, fom_on, fom_off, self._fom_norm) - pp.fom = fom_on - fom_off + pp.fom = normalized_on - normalized_off def _compute_proj(self, roi): if roi is None: diff --git a/extra_foam/pipeline/processors/tests/test_image_roi.py b/extra_foam/pipeline/processors/tests/test_image_roi.py index a2785f423..7267bfa92 100644 --- a/extra_foam/pipeline/processors/tests/test_image_roi.py +++ b/extra_foam/pipeline/processors/tests/test_image_roi.py @@ -104,30 +104,28 @@ def testRoiNorm(self, norm_type, fom_handler): @pytest.mark.parametrize("fom_type, fom_handler", [(k, v) for k, v in _roi_fom_handlers.items()]) def testRoiFom(self, fom_type, fom_handler): proc = self._proc + proc._fom_norm = Normalizer.UNDEFINED + proc._fom_type = fom_type with patch.object(proc._meta, 'has_analysis', side_effect=lambda x: True if x == AnalysisType.ROI_FOM_PULSE else False): for combo, geom in zip([RoiCombo.ROI1, RoiCombo.ROI2], ['_geom1', '_geom2']): data, processed = self._get_data() proc._fom_combo = combo - proc._fom_type = fom_type - proc._fom_norm = Normalizer.UNDEFINED proc.process(data) s = self._get_roi_slice(getattr(proc, geom)) fom_gt = fom_handler(data['assembled']['sliced'][:, s[0], s[1]], axis=(-1, -2)) np.testing.assert_array_equal(fom_gt, processed.pulse.roi.fom) - for fom_combo in [RoiCombo.ROI1_SUB_ROI2, RoiCombo.ROI1_ADD_ROI2]: + for combo in [RoiCombo.ROI1_SUB_ROI2, RoiCombo.ROI1_ADD_ROI2]: data, processed = self._get_data() - proc._fom_combo = fom_combo - proc._fom_type = fom_type - proc._fom_norm = Normalizer.UNDEFINED + proc._fom_combo = combo proc.process(data) s1 = self._get_roi_slice(proc._geom1) fom1_gt = fom_handler(data['assembled']['sliced'][:, s1[0], s1[1]], axis=(-1, -2)) s2 = self._get_roi_slice(proc._geom2) fom2_gt = fom_handler(data['assembled']['sliced'][:, s2[0], s2[1]], axis=(-1, -2)) - if fom_combo == RoiCombo.ROI1_SUB_ROI2: + if combo == RoiCombo.ROI1_SUB_ROI2: np.testing.assert_array_equal(fom1_gt - fom2_gt, processed.pulse.roi.fom) else: np.testing.assert_array_equal(fom1_gt + fom2_gt, processed.pulse.roi.fom) @@ -270,34 +268,45 @@ def testRoiNorm(self, norm_type, fom_handler): assert fom3_gt + fom4_gt == processed.roi.norm @pytest.mark.parametrize("fom_type, fom_handler", [(k, v) for k, v in _roi_fom_handlers.items()]) - def testRoiFom(self, fom_type, fom_handler): + @pytest.mark.parametrize("normalizer, norm", [(Normalizer.UNDEFINED, 1), + (Normalizer.ROI, 2.), + (Normalizer.XGM, 4.), + (Normalizer.DIGITIZER, 8.)]) + def testRoiFom(self, fom_type, fom_handler, normalizer, norm): + def mocked_process_norm(p_data): + if normalizer == Normalizer.ROI: + p_data.roi.norm = 2.0 + elif normalizer == Normalizer.XGM: + p_data.pulse.xgm.intensity = np.array([4., 4., 4., 4.]) # mean = 4.0 + elif normalizer == Normalizer.DIGITIZER: + p_data.pulse.digitizer.ch_normalizer = 'A' + p_data.pulse.digitizer['A'].pulse_integral = np.array([8., 8., 8., 8.]) # mean = 8.0 + proc = self._proc + proc._fom_type = fom_type + proc._fom_norm = normalizer # We do not test all the combinations of parameters. + with patch.object(proc, "_process_norm", side_effect=mocked_process_norm): + for combo, geom in zip([RoiCombo.ROI1, RoiCombo.ROI2], ['geom1', 'geom2']): + data, processed = self._get_data() + proc._fom_combo = combo + proc.process(data) + s = self._get_roi_slice(getattr(processed.roi, geom).geometry) + assert fom_handler(processed.image.masked_mean[s[0], s[1]])/norm == processed.roi.fom - for combo, geom in zip([RoiCombo.ROI1, RoiCombo.ROI2], ['geom1', 'geom2']): - data, processed = self._get_data() - proc._fom_combo = combo - proc._fom_type = fom_type - proc._fom_norm = Normalizer.UNDEFINED - proc.process(data) - s = self._get_roi_slice(getattr(processed.roi, geom).geometry) - assert fom_handler(processed.image.masked_mean[s[0], s[1]]) == processed.roi.fom - - for fom_combo in [RoiCombo.ROI1_SUB_ROI2, RoiCombo.ROI1_ADD_ROI2]: - data, processed = self._get_data() - proc._fom_combo = fom_combo - proc._fom_type = fom_type - proc._fom_norm = Normalizer.UNDEFINED - proc.process(data) - s1 = self._get_roi_slice(processed.roi.geom1.geometry) - fom1_gt = fom_handler(processed.image.masked_mean[s1[0], s1[1]]) - s2 = self._get_roi_slice(processed.roi.geom2.geometry) - fom2_gt = fom_handler(processed.image.masked_mean[s2[0], s2[1]]) - if fom_combo == RoiCombo.ROI1_SUB_ROI2: - assert fom1_gt - fom2_gt == processed.roi.fom - else: - assert fom1_gt + fom2_gt == processed.roi.fom + for fom_combo in [RoiCombo.ROI1_SUB_ROI2, RoiCombo.ROI1_ADD_ROI2]: + data, processed = self._get_data() + proc._fom_combo = fom_combo + proc.process(data) + s1 = self._get_roi_slice(processed.roi.geom1.geometry) + fom1_gt = fom_handler(processed.image.masked_mean[s1[0], s1[1]]) + s2 = self._get_roi_slice(processed.roi.geom2.geometry) + fom2_gt = fom_handler(processed.image.masked_mean[s2[0], s2[1]]) + if fom_combo == RoiCombo.ROI1_SUB_ROI2: + assert (fom1_gt - fom2_gt)/norm == processed.roi.fom + else: + assert (fom1_gt + fom2_gt)/norm == processed.roi.fom def testRoiHist(self): proc = self._proc From 7d3222bca06c660f77577163d6c112c264dc6f5f Mon Sep 17 00:00:00 2001 From: zhujun Date: Mon, 9 Mar 2020 08:48:21 +0100 Subject: [PATCH 08/23] Catch ProcessingError from normalization in ImageRoiTrain --- extra_foam/pipeline/processors/image_roi.py | 71 +++++++++++-------- .../processors/tests/test_image_roi.py | 24 +++++++ 2 files changed, 65 insertions(+), 30 deletions(-) diff --git a/extra_foam/pipeline/processors/image_roi.py b/extra_foam/pipeline/processors/image_roi.py index 880ca564a..de067eb8f 100644 --- a/extra_foam/pipeline/processors/image_roi.py +++ b/extra_foam/pipeline/processors/image_roi.py @@ -505,10 +505,13 @@ def _process_fom(self, processed): raise UnknownParameterError( f"[ROI][FOM] Unknown ROI combo: {self._fom_combo}") - if fom is not None: - roi.fom = self._normalize_fom(processed, fom, self._fom_norm) - if self._roi_fom_master_slave and fom2 is not None: - roi.fom_slave = self._normalize_fom(processed, fom2, self._fom_norm) + try: + if fom is not None: + roi.fom = self._normalize_fom(processed, fom, self._fom_norm) + if self._roi_fom_master_slave and fom2 is not None: + roi.fom_slave = self._normalize_fom(processed, fom2, self._fom_norm) + except ProcessingError as e: + logger.error(repr(e)) def _process_norm_pump_probe(self, processed): """Calculate train-resolved pump-probe ROI normalizers.""" @@ -573,10 +576,12 @@ def _process_fom_pump_probe(self, processed): if fom_on is None: return - normalized_on, normalized_off = self._normalize_fom_pp( - processed, fom_on, fom_off, self._fom_norm) - - pp.fom = normalized_on - normalized_off + try: + normalized_on, normalized_off = self._normalize_fom_pp( + processed, fom_on, fom_off, self._fom_norm) + pp.fom = normalized_on - normalized_off + except ProcessingError as e: + logger.error(repr(e)) def _compute_proj(self, roi): if roi is None: @@ -607,14 +612,17 @@ def _process_proj(self, processed): x = np.arange(len(proj)) - normalized_proj = self._normalize_fom( - processed, proj, self._proj_norm, x=x, auc_range=self._proj_auc_range) - fom = np.sum(normalized_proj) - - roi = processed.roi - roi.proj.x = x - roi.proj.y = normalized_proj - roi.proj.fom = fom + try: + normalized_proj = self._normalize_fom( + processed, proj, self._proj_norm, x=x, auc_range=self._proj_auc_range) + fom = np.sum(normalized_proj) + + roi = processed.roi + roi.proj.x = x + roi.proj.y = normalized_proj + roi.proj.fom = fom + except ProcessingError as e: + logger.error(repr(e)) def _process_proj_pump_probe(self, processed): """Calculate train-resolved pump-probe ROI projections.""" @@ -637,20 +645,23 @@ def _process_proj_pump_probe(self, processed): x = np.arange(len(y_on)) - normalized_y_on, normalized_y_off = self._normalize_fom_pp( - processed, y_on, y_off, self._proj_norm, - x=x, auc_range=self._proj_auc_range) + try: + normalized_y_on, normalized_y_off = self._normalize_fom_pp( + processed, y_on, y_off, self._proj_norm, + x=x, auc_range=self._proj_auc_range) - normalized_y = normalized_y_on - normalized_y_off + normalized_y = normalized_y_on - normalized_y_off - sliced = slice_curve(normalized_y, x, *self._proj_fom_integ_range)[0] - if pp.abs_difference: - fom = np.sum(np.abs(sliced)) - else: - fom = np.sum(sliced) + sliced = slice_curve(normalized_y, x, *self._proj_fom_integ_range)[0] + if pp.abs_difference: + fom = np.sum(np.abs(sliced)) + else: + fom = np.sum(sliced) - pp.y_on = normalized_y_on - pp.y_off = normalized_y_off - pp.x = x - pp.y = normalized_y - pp.fom = fom + pp.y_on = normalized_y_on + pp.y_off = normalized_y_off + pp.x = x + pp.y = normalized_y + pp.fom = fom + except ProcessingError as e: + logger.error(repr(e)) diff --git a/extra_foam/pipeline/processors/tests/test_image_roi.py b/extra_foam/pipeline/processors/tests/test_image_roi.py index 7267bfa92..4b4b66a87 100644 --- a/extra_foam/pipeline/processors/tests/test_image_roi.py +++ b/extra_foam/pipeline/processors/tests/test_image_roi.py @@ -14,6 +14,7 @@ import numpy as np +from extra_foam.pipeline.exceptions import ProcessingError from extra_foam.pipeline.processors import ImageRoiTrain, ImageRoiPulse from extra_foam.config import AnalysisType, config, Normalizer, RoiCombo, RoiFom, RoiProjType from extra_foam.pipeline.tests import _TestDataMixin @@ -241,6 +242,29 @@ def _get_data(self): def _get_roi_slice(self, geom): return slice(geom[1], geom[1] + geom[3]), slice(geom[0], geom[0] + geom[2]) + @patch('extra_foam.ipc.ProcessLogger.error') + def testNormalizationError(self, error): + def side_effect(*args, **kwargs): + raise ProcessingError + + proc = self._proc + + with patch.object(proc, "_normalize_fom", side_effect=side_effect): + # let "_process_fom" raise + with patch.object(proc, "_process_proj") as mocked_p_proj: + data, processed = self._get_data() + proc.process(data) + mocked_p_proj.assert_called_once() + + with patch.object(proc, "_process_fom"): + # let "_process_proj" raise + with patch.object(proc, "_normalize_fom", side_effect=side_effect): + with patch.object(proc, "_process_norm_pump_probe") as mocked_p_norm_pp: + data, processed = self._get_data() + processed.pp.analysis_type = AnalysisType.ROI_PROJ + proc.process(data) + mocked_p_norm_pp.assert_called_once() + @pytest.mark.parametrize("norm_type, fom_handler", [(k, v) for k, v in _roi_fom_handlers.items()]) def testRoiNorm(self, norm_type, fom_handler): proc = self._proc From 738855dfd5a0c3ab4ecc6a7c545d7a70e3643ce4 Mon Sep 17 00:00:00 2001 From: zhujun Date: Tue, 10 Mar 2020 16:32:03 +0100 Subject: [PATCH 09/23] Add ROI1_DIV_ROI2 to ROI FOM --- extra_foam/config.py | 1 + .../gui/ctrl_widgets/roi_fom_ctrl_widget.py | 1 + extra_foam/pipeline/processors/fom_filter.py | 8 +- extra_foam/pipeline/processors/image_roi.py | 10 +++ .../processors/tests/test_image_roi.py | 82 +++++++++++++++++-- 5 files changed, 90 insertions(+), 12 deletions(-) diff --git a/extra_foam/config.py b/extra_foam/config.py index 76216460e..64d9dd301 100644 --- a/extra_foam/config.py +++ b/extra_foam/config.py @@ -41,6 +41,7 @@ class RoiCombo(IntEnum): ROI2 = 2 ROI1_SUB_ROI2 = 3 ROI1_ADD_ROI2 = 4 + ROI1_DIV_ROI2 = 5 ROI3 = 11 ROI4 = 12 ROI3_SUB_ROI4 = 13 diff --git a/extra_foam/gui/ctrl_widgets/roi_fom_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/roi_fom_ctrl_widget.py index eb2752334..42f4a90f5 100644 --- a/extra_foam/gui/ctrl_widgets/roi_fom_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/roi_fom_ctrl_widget.py @@ -27,6 +27,7 @@ class RoiFomCtrlWidget(_AbstractGroupBoxCtrlWidget): "ROI2": RoiCombo.ROI2, "ROI1 - ROI2": RoiCombo.ROI1_SUB_ROI2, "ROI1 + ROI2": RoiCombo.ROI1_ADD_ROI2, + "ROI1 / ROI2": RoiCombo.ROI1_DIV_ROI2, }) _available_types = OrderedDict({ diff --git a/extra_foam/pipeline/processors/fom_filter.py b/extra_foam/pipeline/processors/fom_filter.py index c7b4227de..f5b2c2fe6 100644 --- a/extra_foam/pipeline/processors/fom_filter.py +++ b/extra_foam/pipeline/processors/fom_filter.py @@ -53,11 +53,11 @@ def process(self, data): if self.analysis_type == AnalysisType.ROI_FOM_PULSE: fom = processed.pulse.roi.fom if fom is None: - raise ProcessingError(f"[tag] ROI FOM is not available") + raise ProcessingError(f"[{tag}] ROI FOM is not available") else: raise UnknownParameterError( - f"[tag] Unknown analysis type: {self.analysis_type}") + f"[{tag}] Unknown analysis type: {self.analysis_type}") self.filter_pulse_by_vrange(fom, self._fom_range, processed.pidx, tag) @@ -98,9 +98,9 @@ def process(self, data): if self.analysis_type == AnalysisType.ROI_FOM: fom = processed.roi.fom if fom is None: - raise ProcessingError(f"[tag] ROI FOM is not available") + raise ProcessingError(f"[{tag}] ROI FOM is not available") else: raise UnknownParameterError( - f"[tag] Unknown analysis type: {self.analysis_type}") + f"[{tag}] Unknown analysis type: {self.analysis_type}") self.filter_train_by_vrange(fom, self._fom_range, tag) diff --git a/extra_foam/pipeline/processors/image_roi.py b/extra_foam/pipeline/processors/image_roi.py index de067eb8f..c556b3397 100644 --- a/extra_foam/pipeline/processors/image_roi.py +++ b/extra_foam/pipeline/processors/image_roi.py @@ -244,6 +244,9 @@ def _process_fom(self, assembled, processed): processed.pulse.roi.fom = fom1 - fom2 elif self._fom_combo == RoiCombo.ROI1_ADD_ROI2: processed.pulse.roi.fom = fom1 + fom2 + elif self._fom_combo == RoiCombo.ROI1_DIV_ROI2: + # nan and inf will propagate downstream + processed.pulse.roi.fom = fom1 / fom2 else: raise UnknownParameterError( f"[ROI][FOM] Unknown ROI combo: {self._fom_combo}") @@ -501,6 +504,9 @@ def _process_fom(self, processed): fom = fom1 - fom2 elif self._fom_combo == RoiCombo.ROI1_ADD_ROI2: fom = fom1 + fom2 + elif self._fom_combo == RoiCombo.ROI1_DIV_ROI2: + # nan and inf will propagate downstream + fom = np.divide(fom1, fom2) else: raise UnknownParameterError( f"[ROI][FOM] Unknown ROI combo: {self._fom_combo}") @@ -569,6 +575,10 @@ def _process_fom_pump_probe(self, processed): elif self._fom_combo == RoiCombo.ROI1_ADD_ROI2: fom_on = fom1_on + fom2_on fom_off = fom1_off + fom2_off + elif self._fom_combo == RoiCombo.ROI1_DIV_ROI2: + # nan and inf will propagate downstream + fom_on = np.divide(fom1_on, fom2_on) + fom_off = np.divide(fom1_off, fom2_off) else: raise UnknownParameterError( f"[ROI][FOM] Unknown ROI combo: {self._fom_combo}") diff --git a/extra_foam/pipeline/processors/tests/test_image_roi.py b/extra_foam/pipeline/processors/tests/test_image_roi.py index 4b4b66a87..cdcc8779a 100644 --- a/extra_foam/pipeline/processors/tests/test_image_roi.py +++ b/extra_foam/pipeline/processors/tests/test_image_roi.py @@ -118,7 +118,7 @@ def testRoiFom(self, fom_type, fom_handler): fom_gt = fom_handler(data['assembled']['sliced'][:, s[0], s[1]], axis=(-1, -2)) np.testing.assert_array_equal(fom_gt, processed.pulse.roi.fom) - for combo in [RoiCombo.ROI1_SUB_ROI2, RoiCombo.ROI1_ADD_ROI2]: + for combo in [RoiCombo.ROI1_SUB_ROI2, RoiCombo.ROI1_ADD_ROI2, RoiCombo.ROI1_DIV_ROI2]: data, processed = self._get_data() proc._fom_combo = combo proc.process(data) @@ -128,8 +128,34 @@ def testRoiFom(self, fom_type, fom_handler): fom2_gt = fom_handler(data['assembled']['sliced'][:, s2[0], s2[1]], axis=(-1, -2)) if combo == RoiCombo.ROI1_SUB_ROI2: np.testing.assert_array_equal(fom1_gt - fom2_gt, processed.pulse.roi.fom) - else: + elif combo == RoiCombo.ROI1_ADD_ROI2: np.testing.assert_array_equal(fom1_gt + fom2_gt, processed.pulse.roi.fom) + else: + np.testing.assert_array_equal(fom1_gt / fom2_gt, processed.pulse.roi.fom) + + if combo == RoiCombo.ROI1_DIV_ROI2: + with np.warnings.catch_warnings(): + np.warnings.simplefilter("ignore", category=RuntimeWarning) + + # test some of ROI2 FOM are nan + data, processed = self._get_data() + data['assembled']['sliced'][:2, :, :] = np.nan + proc.process(data) + s1 = self._get_roi_slice(proc._geom1) + fom1_gt = fom_handler(data['assembled']['sliced'][:, s1[0], s1[1]], axis=(-1, -2)) + s2 = self._get_roi_slice(proc._geom2) + fom2_gt = fom_handler(data['assembled']['sliced'][:, s2[0], s2[1]], axis=(-1, -2)) + assert np.count_nonzero(~np.isnan(fom1_gt / fom2_gt)) > 0 + np.testing.assert_array_equal(fom1_gt / fom2_gt, processed.pulse.roi.fom) + + # test all of ROI2 FOM are nan + data, processed = self._get_data() + processed.image.image_mask[s2[0], s2[1]] = True + proc.process(data) + if fom_type == RoiFom.SUM: + assert np.count_nonzero(~np.isinf(processed.pulse.roi.fom)) == 0 + else: + assert np.count_nonzero(~np.isnan(processed.pulse.roi.fom)) == 0 with patch.object(proc._meta, 'has_analysis', side_effect=lambda x: False): data, processed = self._get_data() @@ -229,7 +255,8 @@ def _get_data(self): data, processed = self.data_with_assembled(1001, shape) proc = ImageRoiPulse() proc._geom1 = [0, 1, 2, 3] - proc._geom2 = [1, 0, 2, 3] + # In order to pass the test, ROI1 and ROI2 cannot have overlap. + proc._geom2 = [5, 6, 2, 3] proc._geom3 = [1, 2, 2, 3] proc._geom4 = [3, 2, 3, 4] # set processed.roi.geom{1, 2, 3, 4} @@ -319,7 +346,7 @@ def mocked_process_norm(p_data): s = self._get_roi_slice(getattr(processed.roi, geom).geometry) assert fom_handler(processed.image.masked_mean[s[0], s[1]])/norm == processed.roi.fom - for fom_combo in [RoiCombo.ROI1_SUB_ROI2, RoiCombo.ROI1_ADD_ROI2]: + for fom_combo in [RoiCombo.ROI1_SUB_ROI2, RoiCombo.ROI1_ADD_ROI2, RoiCombo.ROI1_DIV_ROI2]: data, processed = self._get_data() proc._fom_combo = fom_combo proc.process(data) @@ -328,9 +355,27 @@ def mocked_process_norm(p_data): s2 = self._get_roi_slice(processed.roi.geom2.geometry) fom2_gt = fom_handler(processed.image.masked_mean[s2[0], s2[1]]) if fom_combo == RoiCombo.ROI1_SUB_ROI2: - assert (fom1_gt - fom2_gt)/norm == processed.roi.fom + assert (fom1_gt - fom2_gt) / norm == processed.roi.fom + elif fom_combo == RoiCombo.ROI1_ADD_ROI2: + assert (fom1_gt + fom2_gt) / norm == processed.roi.fom else: - assert (fom1_gt + fom2_gt)/norm == processed.roi.fom + assert (fom1_gt / fom2_gt) / norm == processed.roi.fom + + if fom_combo == RoiCombo.ROI1_DIV_ROI2: + with np.warnings.catch_warnings(): + np.warnings.simplefilter("ignore", category=RuntimeWarning) + + # test ROI1 FOM is a number and ROI2 FOM equals to 0, which produces inf + s2 = self._get_roi_slice(processed.roi.geom2.geometry) + processed.image.masked_mean[s2[0], s2[1]] = 0 + proc.process(data) + assert np.isinf(processed.roi.fom) + + # both ROI1 FOM and ROI2 FOM are nan + data, processed = self._get_data() + processed.image.masked_mean[:] = np.nan + proc.process(data) + assert np.isnan(processed.roi.fom) def testRoiHist(self): proc = self._proc @@ -509,7 +554,7 @@ def testRoiFomPumpProbe(self, fom_type, fom_handler): fom_off_gt = fom_handler(processed.pp.image_off[s[0], s[1]]) assert fom_on_gt - fom_off_gt == processed.pp.fom - for fom_combo in [RoiCombo.ROI1_SUB_ROI2, RoiCombo.ROI1_ADD_ROI2]: + for fom_combo in [RoiCombo.ROI1_SUB_ROI2, RoiCombo.ROI1_ADD_ROI2, RoiCombo.ROI1_DIV_ROI2]: data, processed = self._get_data() processed.pp.analysis_type = AnalysisType.ROI_FOM proc._fom_combo = fom_combo @@ -524,11 +569,32 @@ def testRoiFomPumpProbe(self, fom_type, fom_handler): if fom_combo == RoiCombo.ROI1_SUB_ROI2: fom_on_gt = fom1_on_gt - fom2_on_gt fom_off_gt = fom1_off_gt - fom2_off_gt - else: + elif fom_combo == RoiCombo.ROI1_ADD_ROI2: fom_on_gt = fom1_on_gt + fom2_on_gt fom_off_gt = fom1_off_gt + fom2_off_gt + else: + fom_on_gt = fom1_on_gt / fom2_on_gt + fom_off_gt = fom1_off_gt / fom2_off_gt assert fom_on_gt - fom_off_gt == processed.pp.fom + if fom_combo == RoiCombo.ROI1_DIV_ROI2: + with np.warnings.catch_warnings(): + np.warnings.simplefilter("ignore", category=RuntimeWarning) + + # test ROI1 FOM is a number and ROI2 FOM equals to 0, which produces inf + s2 = self._get_roi_slice(processed.roi.geom2.geometry) + processed.pp.image_on[s2[0], s2[1]] = 0 + proc.process(data) + assert np.isinf(processed.pp.fom) + + # both ROI1 FOM and ROI2 FOM are nan + data, processed = self._get_data() + processed.pp.analysis_type = AnalysisType.ROI_FOM + processed.pp.image_on[:] = np.nan + processed.pp.image_off[:] = np.nan + proc.process(data) + assert np.isnan(processed.pp.fom) + @patch('extra_foam.ipc.ProcessLogger.error') @pytest.mark.parametrize("proj_type, proj_handler", [(k, v) for k, v in _roi_proj_handlers.items()]) From 219ce1d74af86e0c84f85f3582b92dd40ccb5707 Mon Sep 17 00:00:00 2001 From: zhujun98 Date: Thu, 12 Mar 2020 08:33:29 +0100 Subject: [PATCH 10/23] Handle possible +-inf in FOM histogram --- extra_foam/algorithms/__init__.py | 2 +- extra_foam/algorithms/statistics_py.py | 27 +++++++----- .../algorithms/tests/test_statistics.py | 43 +++++++++++++------ extra_foam/pipeline/processors/histogram.py | 18 +++++--- extra_foam/pipeline/processors/image_roi.py | 14 ++++-- 5 files changed, 70 insertions(+), 34 deletions(-) diff --git a/extra_foam/algorithms/__init__.py b/extra_foam/algorithms/__init__.py index 7017e843d..e000938c2 100644 --- a/extra_foam/algorithms/__init__.py +++ b/extra_foam/algorithms/__init__.py @@ -8,7 +8,7 @@ All rights reserved. """ from .statistics_py import ( - hist_with_stats, nanhist_with_stats, compute_statistics, find_actual_range, + hist_with_stats, nanhist_with_stats, compute_statistics, nanmean, nansum ) diff --git a/extra_foam/algorithms/statistics_py.py b/extra_foam/algorithms/statistics_py.py index 43977ef93..7c9996082 100644 --- a/extra_foam/algorithms/statistics_py.py +++ b/extra_foam/algorithms/statistics_py.py @@ -13,19 +13,20 @@ from .statistics import nanmean, nansum -def find_actual_range(arr, range): - """Find the actual range for an array of data. +def _get_outer_edges(arr, range): + """Determine the outer bin edges to use. - This is a helper function to find the non-infinite range (lb, ub) as - input for some other functions. + From both the data and the range argument. :param numpy.ndarray arr: data. - :param tuple range: desired range. + :param tuple range: desired range (min, max). - :return tuple: actual range. - - Note: the input data is assume to be non-free. + :return tuple: outer edges (min, max). + Note: the input array is assumed to be nan-free but could contain +-inf. + The returned outer edges could be inf or -inf if both the min/max + value of array and the corresponding boundary of the range argument + are inf or -inf. """ v_min, v_max = range assert v_min < v_max @@ -77,6 +78,8 @@ def nanhist_with_stats(roi, bin_range=(-np.inf, np.inf), n_bins=10): :param numpy.ndarray roi: image ROI. :param tuple bin_range: (lb, ub) of histogram. :param int n_bins: number of bins of histogram. + + :raise ValueError: if finite outer edges cannot be found. """ # Note: Since the nan functions in numpy is typically 5-8 slower # than the non-nan counterpart, it is always faster to remove nan @@ -88,8 +91,8 @@ def nanhist_with_stats(roi, bin_range=(-np.inf, np.inf), n_bins=10): mask_image_data(filtered, threshold_mask=bin_range, keep_nan=True) filtered = filtered[~np.isnan(filtered)] - actual_range = find_actual_range(filtered, bin_range) - hist, bin_edges = np.histogram(filtered, range=actual_range, bins=n_bins) + outer_edges = _get_outer_edges(filtered, bin_range) + hist, bin_edges = np.histogram(filtered, range=outer_edges, bins=n_bins) bin_centers = (bin_edges[1:] + bin_edges[:-1]) / 2.0 mean, median, std = compute_statistics(filtered) @@ -102,8 +105,10 @@ def hist_with_stats(data, bin_range=(-np.inf, np.inf), n_bins=10): :param numpy.ndarray data: input data. :param tuple bin_range: (lb, ub) of histogram. :param int n_bins: number of bins of histogram. + + :raise ValueError: if finite outer edges cannot be found. """ - v_min, v_max = find_actual_range(data, bin_range) + v_min, v_max = _get_outer_edges(data, bin_range) filtered = data[(data >= v_min) & (data <= v_max)] hist, bin_edges = np.histogram( diff --git a/extra_foam/algorithms/tests/test_statistics.py b/extra_foam/algorithms/tests/test_statistics.py index 5fb4a562d..49191e98a 100644 --- a/extra_foam/algorithms/tests/test_statistics.py +++ b/extra_foam/algorithms/tests/test_statistics.py @@ -4,8 +4,8 @@ import numpy as np -from extra_foam.algorithms import ( - hist_with_stats, nanhist_with_stats, compute_statistics, find_actual_range, +from extra_foam.algorithms.statistics_py import ( + hist_with_stats, nanhist_with_stats, compute_statistics, _get_outer_edges, nanmean, nansum ) @@ -58,6 +58,11 @@ def testNanhistWithStats(self): assert np.median(arr_gt) == median assert np.std(arr_gt) == pytest.approx(std) + # case 5 (finite outer edges cannot be found) + roi = np.array([[-np.inf, np.nan, 3], [np.nan, 5, 6]], dtype=np.float32) + with pytest.raises(ValueError): + nanhist_with_stats(roi, (-np.inf, np.inf), 4) + def testHistWithStats(self): data = np.array([0, 1, 2, 3, 6, 0], dtype=np.float32) # 1D hist, bin_centers, mean, median, std = hist_with_stats(data, (1, 3), 4) @@ -79,40 +84,54 @@ def testHistWithStats(self): # case 3 (elements have the same value) roi = np.array([[1, 1, 1], [1, 1, 1]], dtype=np.float32) # 2D - hist, bin_centers, mean, median, std = nanhist_with_stats(roi, (0, 3), 4) + hist, bin_centers, mean, median, std = hist_with_stats(roi, (0, 3), 4) np.testing.assert_array_equal([0, 6, 0, 0], hist) np.testing.assert_array_equal([0.375, 1.125, 1.875, 2.625], bin_centers) assert 1 == mean assert 1 == median assert 0 == std + # case 4 (finite outer edges cannot be found) + roi = np.array([[np.inf, 2, 3], [4, 5, 6]], dtype=np.float32) + with pytest.raises(ValueError): + hist_with_stats(roi, (-np.inf, np.inf), 4) + def testFindActualRange(self): arr = np.array([1, 2, 3, 4]) - assert (-1.5, 2.5) == find_actual_range(arr, (-1.5, 2.5)) + assert (-1.5, 2.5) == _get_outer_edges(arr, (-1.5, 2.5)) arr = np.array([1, 2, 3, 4]) - assert (1, 4) == find_actual_range(arr, (-math.inf, math.inf)) + assert (1, 4) == _get_outer_edges(arr, (-math.inf, math.inf)) arr = np.array([1, 1, 1, 1]) - assert (0.5, 1.5) == find_actual_range(arr, (-math.inf, math.inf)) + assert (0.5, 1.5) == _get_outer_edges(arr, (-math.inf, math.inf)) arr = np.array([1, 2, 3, 4]) - assert (3, 4) == find_actual_range(arr, (3, math.inf)) + assert (3, 4) == _get_outer_edges(arr, (3, math.inf)) arr = np.array([1, 2, 3, 4]) - assert (4, 5) == find_actual_range(arr, (4, math.inf)) + assert (4, 5) == _get_outer_edges(arr, (4, math.inf)) arr = np.array([1, 2, 3, 4]) - assert (5, 6) == find_actual_range(arr, (5, math.inf)) + assert (5, 6) == _get_outer_edges(arr, (5, math.inf)) arr = np.array([1, 2, 3, 4]) - assert (0, 1) == find_actual_range(arr, (-math.inf, 1)) + assert (0, 1) == _get_outer_edges(arr, (-math.inf, 1)) arr = np.array([1, 2, 3, 4]) - assert (-1, 0) == find_actual_range(arr, (-math.inf, 0)) + assert (-1, 0) == _get_outer_edges(arr, (-math.inf, 0)) arr = np.array([1, 2, 3, 4]) - assert (-1, 0) == find_actual_range(arr, (-math.inf, 0)) + assert (-1, 0) == _get_outer_edges(arr, (-math.inf, 0)) + + arr = np.array([1, 2, 3, -np.inf]) + assert (-np.inf, 0) == _get_outer_edges(arr, (-math.inf, 0)) + + arr = np.array([1, 2, 3, np.inf]) + assert (0, np.inf) == _get_outer_edges(arr, (0, math.inf)) + + arr = np.array([1, -np.inf, 3, np.inf]) + assert (-np.inf, np.inf) == _get_outer_edges(arr, (-math.inf, math.inf)) def testComputeStatistics(self): with np.warnings.catch_warnings(): diff --git a/extra_foam/pipeline/processors/histogram.py b/extra_foam/pipeline/processors/histogram.py index 424dd4741..1dbb3e1a5 100644 --- a/extra_foam/pipeline/processors/histogram.py +++ b/extra_foam/pipeline/processors/histogram.py @@ -12,8 +12,8 @@ import numpy as np from .base_processor import _BaseProcessor, SimpleSequence -from ..exceptions import UnknownParameterError -from ...algorithms import hist_with_stats, find_actual_range +from ..exceptions import ProcessingError, UnknownParameterError +from ...algorithms import hist_with_stats from ...ipc import process_logger as logger from ...database import Metadata as mt from ...config import AnalysisType @@ -108,8 +108,11 @@ def process(self, data): data = self._fom.data() if data.size != 0: th = processed.hist - th.hist, th.bin_centers, th.mean, th.median, th.std = \ - hist_with_stats(data, self._bin_range, self._n_bins) + try: + th.hist, th.bin_centers, th.mean, th.median, th.std = \ + hist_with_stats(data, self._bin_range, self._n_bins) + except ValueError as e: + raise ProcessingError(f"[Histogram] {str(e)}") def _process_poi(self, processed): """Calculate histograms of FOMs of POI pulses.""" @@ -121,8 +124,11 @@ def _process_poi(self, processed): poi_fom = self._fom.data()[i::n_pulses] if poi_fom.size != 0: - processed.pulse.hist[i] = hist_with_stats( - poi_fom, self._bin_range, self._n_bins) + try: + processed.pulse.hist[i] = hist_with_stats( + poi_fom, self._bin_range, self._n_bins) + except ValueError as e: + raise ProcessingError(f"[Histogram] {str(e)}") if image_data.poi_indices[1] == image_data.poi_indices[0]: # skip the second one if two POIs have the same index diff --git a/extra_foam/pipeline/processors/image_roi.py b/extra_foam/pipeline/processors/image_roi.py index c556b3397..6c96e6353 100644 --- a/extra_foam/pipeline/processors/image_roi.py +++ b/extra_foam/pipeline/processors/image_roi.py @@ -274,8 +274,11 @@ def _process_hist(self, processed): if roi is None: continue - processed.pulse.roi.hist[idx] = nanhist_with_stats( - roi, self._hist_bin_range, self._hist_n_bins) + try: + processed.pulse.roi.hist[idx] = nanhist_with_stats( + roi, self._hist_bin_range, self._hist_n_bins) + except ValueError as e: + raise ProcessingError(f"[ROI][histogram] {str(e)}") if image_data.poi_indices[1] == image_data.poi_indices[0]: # skip the second one if two POIs have the same index @@ -459,8 +462,11 @@ def _process_hist(self, processed): if roi is not None: hist = processed.roi.hist - hist.hist, hist.bin_centers, hist.mean, hist.median, hist.std = \ - nanhist_with_stats(roi, self._hist_bin_range, self._hist_n_bins) + try: + hist.hist, hist.bin_centers, hist.mean, hist.median, hist.std = \ + nanhist_with_stats(roi, self._hist_bin_range, self._hist_n_bins) + except ValueError as e: + raise ProcessingError(f"[ROI][histogram] {str(e)}") def _process_norm(self, processed): """Calculate train-resolved ROI normalizer.""" From 63d05f671c0907180b80c2212ac575cae9793764 Mon Sep 17 00:00:00 2001 From: zhujun Date: Thu, 12 Mar 2020 08:56:14 +0100 Subject: [PATCH 11/23] Reset the image mask in case of different shapes if it is empty --- .../gui/image_tool/tests/test_image_tool.py | 7 +++++- .../pipeline/processors/image_processor.py | 22 +++++++++++-------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/extra_foam/gui/image_tool/tests/test_image_tool.py b/extra_foam/gui/image_tool/tests/test_image_tool.py index 7bbc4cfca..6ae121b25 100644 --- a/extra_foam/gui/image_tool/tests/test_image_tool.py +++ b/extra_foam/gui/image_tool/tests/test_image_tool.py @@ -330,10 +330,15 @@ def testDrawMask(self): np.testing.assert_array_equal(mask_gt, proc._image_mask) # test set a mask which has a different shape from the image - mask_gt = np.zeros((2, 2), dtype=np.bool) + mask_gt = np.ones((2, 2), dtype=np.bool) pub.set(mask_gt) with self.assertRaises(ImageProcessingError): proc.process(data) + # an empty image mask with a different shape will be automatically reset + mask_gt = np.zeros((2, 2), dtype=np.bool) + pub.set(mask_gt) + proc.process(data) + np.testing.assert_array_equal(np.zeros((10, 10), dtype=np.bool), proc._image_mask) def testBulletinView(self): processed = ProcessedData(1357) diff --git a/extra_foam/pipeline/processors/image_processor.py b/extra_foam/pipeline/processors/image_processor.py index 2cff9b593..1eb024c54 100644 --- a/extra_foam/pipeline/processors/image_processor.py +++ b/extra_foam/pipeline/processors/image_processor.py @@ -193,16 +193,20 @@ def _record_dark(self, assembled): def _update_image_mask(self, image_shape): image_mask = self._mask_sub.update(self._image_mask, image_shape) if image_mask is not None and image_mask.shape != image_shape: - # This could only happen when the mask is loaded from the files - # and the image shapes in the ImageTool is different from the - # shape of the live images. - # The original image mask remains the same. - raise ImageProcessingError( - f"[Image processor] The shape of the image mask " - f"{image_mask.shape} is different from the shape of the image " - f"{image_shape}!") + if np.sum(image_mask) == 0: + # reset the empty image mask automatically + image_mask = None + else: + # This could only happen when the mask is loaded from the files + # and the image shapes in the ImageTool is different from the + # shape of the live images. + # The original image mask remains the same. + raise ImageProcessingError( + f"[Image processor] The shape of the image mask " + f"{image_mask.shape} is different from the shape of the image " + f"{image_shape}!") - elif image_mask is None: + if image_mask is None: image_mask = np.zeros(image_shape, dtype=np.bool) self._image_mask = image_mask From d4f7762651d91ad61fcb945e6b14b9a10d835ed7 Mon Sep 17 00:00:00 2001 From: zhujun98 Date: Wed, 4 Mar 2020 22:30:25 +0100 Subject: [PATCH 12/23] Implement AGIPD 1M geometry in C++ --- benchmarks/benchmark_geometry.py | 24 ++- extra_foam/configs/mid.config.yaml | 10 +- extra_foam/configs/spb.config.yaml | 10 +- extra_foam/geometries/__init__.py | 23 +++ .../geometries/tests/test_1M_geometry.py | 20 +- .../gui/ctrl_widgets/geometry_ctrl_widget.py | 15 +- .../pipeline/processors/image_assembler.py | 11 +- .../processors/tests/test_image_assembler.py | 20 +- src/extra_foam/f_geometry.cpp | 2 + src/extra_foam/include/f_geometry.hpp | 186 +++++++++++++++++- test/test_geometry.cpp | 2 +- 11 files changed, 277 insertions(+), 46 deletions(-) diff --git a/benchmarks/benchmark_geometry.py b/benchmarks/benchmark_geometry.py index e52a7241a..24df0ce13 100644 --- a/benchmarks/benchmark_geometry.py +++ b/benchmarks/benchmark_geometry.py @@ -18,7 +18,7 @@ _geom_path = osp.join(osp.dirname(osp.abspath(__file__)), "../extra_foam/geometries") -def _benchmark_1m_imp(geom_fast_cls, geom_cls, geom_file, quad_positions): +def _benchmark_1m_imp(geom_fast_cls, geom_cls, geom_file, quad_positions=None): for from_dtype, from_str in _data_sources: n_pulses = 64 @@ -37,7 +37,10 @@ def _benchmark_1m_imp(geom_fast_cls, geom_cls, geom_file, quad_positions): # assemble with geometry and quad position - geom = geom_fast_cls.from_h5_file_and_quad_positions(geom_file, quad_positions) + if quad_positions is not None: + geom = geom_fast_cls.from_h5_file_and_quad_positions(geom_file, quad_positions) + else: + geom = geom_fast_cls.from_crystfel_geom(geom_file) out = np.full((n_pulses, *geom.assembledShape()), np.nan, dtype=np.float32) t0 = time.perf_counter() geom.position_all_modules(modules, out) @@ -45,8 +48,10 @@ def _benchmark_1m_imp(geom_fast_cls, geom_cls, geom_file, quad_positions): # assemble with geometry and quad position in EXtra-geom - geom = geom_cls.from_h5_file_and_quad_positions( - geom_file, quad_positions) + if quad_positions is not None: + geom = geom_cls.from_h5_file_and_quad_positions(geom_file, quad_positions) + else: + geom = geom_cls.from_crystfel_geom(geom_file) out = geom.output_array_for_position_fast((n_pulses,)) t0 = time.perf_counter() geom.position_all_modules(modules, out=out) @@ -87,6 +92,15 @@ def benchmark_lpd_1m(): _benchmark_1m_imp(LPD_1MGeometryFast, LPD_1MGeometry, geom_file, quad_positions) +def benchmark_agipd_1m(): + from extra_foam.geometries import AGIPD_1MGeometryFast + from extra_geom import AGIPD_1MGeometry + + geom_file = osp.join(_geom_path, "agipd_mar18_v11.geom") + + _benchmark_1m_imp(AGIPD_1MGeometryFast, AGIPD_1MGeometry, geom_file) + + if __name__ == "__main__": print("*" * 80) print("Benchmark geometry") @@ -95,3 +109,5 @@ def benchmark_lpd_1m(): benchmark_dssc_1m() benchmark_lpd_1m() + + benchmark_agipd_1m() diff --git a/extra_foam/configs/mid.config.yaml b/extra_foam/configs/mid.config.yaml index 7244d6659..327738d9e 100644 --- a/extra_foam/configs/mid.config.yaml +++ b/extra_foam/configs/mid.config.yaml @@ -22,16 +22,8 @@ SOURCE: DETECTOR: AGIPD: + # AGIPD uses the CFEL geometry format GEOMETRY_FILE: agipd_mar18_v11.geom - QUAD_POSITIONS: - x1: -526 - y1: 630 - x2: -549 - y2: -4 - x3: 522 - y3: -157 - x4: 543 - y4: 477 BRIDGE_ADDR: 10.253.0.51 BRIDGE_PORT: 45012 LOCAL_ADDR: 127.0.0.1 diff --git a/extra_foam/configs/spb.config.yaml b/extra_foam/configs/spb.config.yaml index c526a48da..43eb3884d 100644 --- a/extra_foam/configs/spb.config.yaml +++ b/extra_foam/configs/spb.config.yaml @@ -11,16 +11,8 @@ SOURCE: DETECTOR: AGIPD: + # AGIPD uses the CFEL geometry format GEOMETRY_FILE: agipd_mar18_v11.geom - QUAD_POSITIONS: - x1: -526 - y1: 630 - x2: -549 - y2: -4 - x3: 522 - y3: -157 - x4: 543 - y4: 477 BRIDGE_ADDR: 10.253.0.51 BRIDGE_PORT: 45012 LOCAL_ADDR: 127.0.0.1 diff --git a/extra_foam/geometries/__init__.py b/extra_foam/geometries/__init__.py index d589e2289..33749a5b7 100644 --- a/extra_foam/geometries/__init__.py +++ b/extra_foam/geometries/__init__.py @@ -12,6 +12,7 @@ import numpy as np import h5py +from ..algorithms.geometry import AGIPD_1MGeometry as _AGIPD_1MGeometry from ..algorithms.geometry import LPD_1MGeometry as _LPD_1MGeometry from ..algorithms.geometry import DSSC_1MGeometry as _DSSC_1MGeometry @@ -105,3 +106,25 @@ def from_h5_file_and_quad_positions(cls, filepath, positions): modules.append(tiles) return cls(modules) + + +class AGIPD_1MGeometryFast(_AGIPD_1MGeometry, _1MGeometryPyMixin): + """AGIPD_1MGeometryFast. + + Extend the functionality of AGIPD_1MGeometry implementation in C++. + """ + @classmethod + def from_crystfel_geom(cls, filename): + from cfelpyutils.crystfel_utils import load_crystfel_geometry + from extra_geom.detectors import GeometryFragment + + geom_dict = load_crystfel_geometry(filename) + modules = [] + for p in range(cls.n_modules): + tiles = [] + modules.append(tiles) + for a in range(cls.n_tiles_per_module): + d = geom_dict['panels']['p{}a{}'.format(p, a)] + tiles.append(GeometryFragment.from_panel_dict(d).corner_pos) + + return cls(modules) diff --git a/extra_foam/geometries/tests/test_1M_geometry.py b/extra_foam/geometries/tests/test_1M_geometry.py index e652a4dfb..41ceffdcb 100644 --- a/extra_foam/geometries/tests/test_1M_geometry.py +++ b/extra_foam/geometries/tests/test_1M_geometry.py @@ -5,7 +5,9 @@ import numpy as np from extra_foam.pipeline.processors.image_assembler import StackView -from extra_foam.geometries import DSSC_1MGeometryFast, LPD_1MGeometryFast +from extra_foam.geometries import ( + DSSC_1MGeometryFast, LPD_1MGeometryFast, AGIPD_1MGeometryFast +) import extra_geom as eg from extra_foam.config import config @@ -60,7 +62,7 @@ def testAssemblingFile(self, dtype): # FIXME for i in range(2): assert abs(out_fast.shape[i] - out_gt.shape[i]) <= 1 - # np.testing.assert_equal(out_fast, out) + # np.testing.assert_equal(out_fast, out_gt) class TestDSSC_1MGeometryFast(_Test1MGeometryMixin): @@ -120,3 +122,17 @@ def setup_class(cls): cls.n_pulses = 2 cls.n_modules = LPD_1MGeometryFast.n_modules cls.module_shape = LPD_1MGeometryFast.module_shape + + +class TestAGIPD_1MGeometryFast(_Test1MGeometryMixin): + @classmethod + def setup_class(cls): + geom_file = osp.join(_geom_path, "agipd_mar18_v11.geom") + + cls.geom_stack = AGIPD_1MGeometryFast() + cls.geom_fast = AGIPD_1MGeometryFast.from_crystfel_geom(geom_file) + cls.geom = eg.AGIPD_1MGeometry.from_crystfel_geom(geom_file) + + cls.n_pulses = 2 + cls.n_modules = AGIPD_1MGeometryFast.n_modules + cls.module_shape = AGIPD_1MGeometryFast.module_shape diff --git a/extra_foam/gui/ctrl_widgets/geometry_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/geometry_ctrl_widget.py index 13cc7e028..26b2dc7fd 100644 --- a/extra_foam/gui/ctrl_widgets/geometry_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/geometry_ctrl_widget.py @@ -41,12 +41,10 @@ def __init__(self, *args, **kwargs): self._stack_only_cb = QCheckBox("Stack only") self._stack_only_cb.setChecked(False) - if config["DETECTOR"] == "AGIPD": - # FIXME: native AGIPD geometry is not implemented yet - self._assembler_cb.removeItem(0) - self._stack_only_cb.setEnabled(False) - self._quad_positions_tb = QTableWidget() + if config["DETECTOR"] == "AGIPD": + self._quad_positions_tb.setEnabled(False) + self._assembler_cb.setCurrentText("EXtra-geom") self._geom_file_le = QLineEdit(config["GEOMETRY_FILE"]) self._geom_file_open_btn = QPushButton("Load geometry file") @@ -105,8 +103,11 @@ def initQuadTable(self): try: for i in range(n_row): for j in range(n_col): - widget.setItem(i, j, QTableWidgetItem( - str(config["QUAD_POSITIONS"][j][i]))) + if config["DETECTOR"] in ["LPD", "DSSC"]: + widget.setItem(i, j, QTableWidgetItem( + str(config["QUAD_POSITIONS"][j][i]))) + else: + widget.setItem(i, j, QTableWidgetItem('0')) except IndexError: pass diff --git a/extra_foam/pipeline/processors/image_assembler.py b/extra_foam/pipeline/processors/image_assembler.py index 27fcc341a..a5dca07c2 100644 --- a/extra_foam/pipeline/processors/image_assembler.py +++ b/extra_foam/pipeline/processors/image_assembler.py @@ -458,7 +458,16 @@ def _get_modules_file(self, data, src): def _load_geometry(self, filename, quad_positions): """Override.""" if self._assembler_type == GeomAssembler.OWN or self._stack_only: - raise AssemblingError("Not implemented!") + from ...geometries import AGIPD_1MGeometryFast + + if self._stack_only: + self._geom = AGIPD_1MGeometryFast() + else: + try: + self._geom = AGIPD_1MGeometryFast.from_crystfel_geom( + filename) + except (ImportError, ModuleNotFoundError, OSError) as e: + raise AssemblingError(e) else: from extra_geom import AGIPD_1MGeometry diff --git a/extra_foam/pipeline/processors/tests/test_image_assembler.py b/extra_foam/pipeline/processors/tests/test_image_assembler.py index 86e6f01de..3afbb1623 100644 --- a/extra_foam/pipeline/processors/tests/test_image_assembler.py +++ b/extra_foam/pipeline/processors/tests/test_image_assembler.py @@ -65,19 +65,19 @@ def teardown_module(module): config.ROOT_PATH = module._backup_ROOT_PATH -class TestAgipdAssembler(_TestDataMixin, unittest.TestCase): +class TestAgipdAssembler: @classmethod - def setUpClass(cls): + def setup_class(cls): config.load('AGIPD', random.choice(['SPB', 'MID'])) cls._geom_file = config["GEOMETRY_FILE"] cls._quad_positions = config["QUAD_POSITIONS"] @classmethod - def tearDownClass(cls): + def teardown_class(cls): os.remove(config.config_file) - def setUp(self): + def setup_method(self, method): self._assembler = ImageAssemblerFactory.create("AGIPD") self._assembler._load_geometry(self._geom_file, self._quad_positions) @@ -108,13 +108,13 @@ def testGeneral(self): }, } - with self.assertRaisesRegex(KeyError, "source_type"): + with pytest.raises(KeyError, match="source_type"): self._assembler.process(copy.deepcopy(data)) data['meta'][src]["source_type"] = DataSource.FILE self._assembler.process(data) - self.assertEqual(10001, data['raw']['META timestamp.tid']) - self.assertIsNone(data['raw'][src]) + assert 10001 == data['raw']['META timestamp.tid'] + assert data['raw'][src] is None def testAssembleFileCal(self): self._runAssembleFileTest((4, 512, 128), _IMAGE_DTYPE) @@ -155,7 +155,7 @@ def testAssembleBridge(self): key_name = 'image.data' src, catalog = self._create_catalog('SPB_DET_AGIPD1M-1/CAL/APPEND_CORRECTED', key_name) - with self.assertRaisesRegex(AssemblingError, 'Expected module shape'): + with pytest.raises(AssemblingError, match='Expected module shape'): data = { 'catalog': catalog, 'meta': { @@ -170,11 +170,11 @@ def testAssembleBridge(self): } self._assembler.process(data) - with self.assertRaisesRegex(AssemblingError, 'modules, but'): + with pytest.raises(AssemblingError, match='modules, but'): data['raw'][src] = np.ones((4, 12, 512, 128), dtype=_IMAGE_DTYPE) self._assembler.process(data) - with self.assertRaisesRegex(AssemblingError, 'Number of memory cells'): + with pytest.raises(AssemblingError, match='Number of memory cells'): data['raw'][src] = np.ones((0, 16, 512, 128), dtype=_IMAGE_DTYPE) self._assembler.process(data) diff --git a/src/extra_foam/f_geometry.cpp b/src/extra_foam/f_geometry.cpp index ea15da750..1b19df443 100644 --- a/src/extra_foam/f_geometry.cpp +++ b/src/extra_foam/f_geometry.cpp @@ -70,6 +70,8 @@ PYBIND11_MODULE(geometry, m) m.doc() = "Detector geometry."; + declare_1MGeometry(m, "AGIPD"); + declare_1MGeometry(m, "LPD"); declare_1MGeometry(m, "DSSC"); diff --git a/src/extra_foam/include/f_geometry.hpp b/src/extra_foam/include/f_geometry.hpp index 96c87c2e6..955f39efe 100644 --- a/src/extra_foam/include/f_geometry.hpp +++ b/src/extra_foam/include/f_geometry.hpp @@ -264,6 +264,180 @@ void Detector1MGeometryBase::positionModule(M&& src, N& dst, T&& pos) const static_cast(this)->positionModuleImp(std::forward(src), dst, std::forward(pos)); } +/** + * AGIPD-1M geometry + * + * + * Layout of AGIPD-1M: Tile layout for each module: + * (looking along the beam) + * + * Q4M1 | Q1M1 Q1 and Q2: T8 T7 T6 T5 T4 T3 T2 T1 + * Q4M2 | Q1M2 Q3 and q4: T1 T2 T3 T4 T5 T6 T7 T8 + * Q4M3 | Q1M3 + * Q4M4 | Q1M4 + * ----------------------- + * Q3M1 | Q2M1 + * Q3M2 | Q2M2 + * Q3M3 | Q2M3 + * Q3M4 | Q2M4 + * + * The quadrant positions refer to the first pixel + * (top-right corners for Q1, Q2 and bottom-left corners for Q3, Q4) + * of the first module in each quadrant. + * + * For details, please see + * https://extra-geom.readthedocs.io/en/latest/geometry.html#agipd-1m + * + */ +class AGIPD_1MGeometry : public Detector1MGeometryBase +{ +public: + + static const shapeType module_shape; + static const shapeType tile_shape; + static const size_t n_tiles_per_module = 8; // number of tiles per module + static const quadOrientType quad_orientations; +private: + + xt::xtensor_fixed> corner_pos_; + + friend Detector1MGeometryBase; + + template + void positionModuleImp(M&& src, N& dst, T&& pos) const; + +public: + + static const vectorType& pixelSize() + { + static const vectorType pixel_size {2e-4, 2e-4, 1.}; + return pixel_size; + } + + AGIPD_1MGeometry(); + + explicit + AGIPD_1MGeometry(const std::array, n_tiles_per_module>, n_modules>& positions); + + ~AGIPD_1MGeometry() = default; +}; + +// (ss/x, fs/y) This should be called 'data_shape' instead of 'module_shape' +const AGIPD_1MGeometry::shapeType AGIPD_1MGeometry::module_shape {512, 128}; +// (fs/y, ss/x) +const AGIPD_1MGeometry::shapeType AGIPD_1MGeometry::tile_shape {128, 64}; +constexpr size_t AGIPD_1MGeometry::n_tiles_per_module; +const AGIPD_1MGeometry::quadOrientType AGIPD_1MGeometry::quad_orientations { + std::array{1, -1}, + std::array{1, -1}, + std::array{-1, 1}, + std::array{-1, 1} +}; + +AGIPD_1MGeometry::AGIPD_1MGeometry() +{ + // first pixel position of each module + // (upper-right for Q1 and Q2, lower-left for Q3 and Q4) positions + xt::xtensor_fixed> m_pos { + { -512, 512, 0}, + { -512, 384, 0}, + { -512, 256, 0}, + { -512, 128, 0}, + { -512, 0, 0}, + { -512, -128, 0}, + { -512, -256, 0}, + { -512, -384, 0}, + { 512, -128, 0}, + { 512, -256, 0}, + { 512, -384, 0}, + { 512, -512, 0}, + { 512, 384, 0}, + { 512, 256, 0}, + { 512, 128, 0}, + { 512, 0, 0} + }; + + xt::xtensor_fixed> positions; + auto ht = static_cast(tile_shape[0]); + auto wt = static_cast(tile_shape[1]); + for (int im = 0; im < n_modules; ++im) + { + auto orient = quad_orientations[im / 4]; + for (int it = 0; it < n_tiles_per_module; ++it) + { + positions(im, it, 0) = m_pos(im, 0) + orient[0] * it * wt; + positions(im, it, 1) = m_pos(im, 1); + positions(im, it, 2) = m_pos(im, 2); + } + } + + positions *= pixelSize(); + + for (int im = 0; im < n_modules; ++im) + { + auto orient = quad_orientations[im / 4]; + for (int it = 0; it < n_tiles_per_module; ++it) + { + for (int j = 0; j < 3; ++j) corner_pos_(im, it, 0, j) = positions(im, it, j); + // calculate the position of the diagonal corner + corner_pos_(im, it, 1, 0) = positions(im, it, 0) + orient[0] * wt * pixelSize()(0); + corner_pos_(im, it, 1, 1) = positions(im, it, 1) + orient[1] * ht * pixelSize()(1); + corner_pos_(im, it, 1, 2) = 0.0; + } + } +} + +AGIPD_1MGeometry::AGIPD_1MGeometry( + const std::array, n_tiles_per_module>, n_modules>& positions) +{ + auto ht = static_cast(tile_shape[0]); + auto wt = static_cast(tile_shape[1]); + for (int im = 0; im < n_modules; ++im) + { + auto orient = quad_orientations[im / 4]; + + for (int it = 0; it < n_tiles_per_module; ++it) + { + for (int j = 0; j < 3; ++j) corner_pos_(im, it, 0, j) = positions[im][it][j]; + // calculate the position of the diagonal corner + corner_pos_(im, it, 1, 0) = positions[im][it][0] + orient[0] * wt * pixelSize()(0); + corner_pos_(im, it, 1, 1) = positions[im][it][1] + orient[1] * ht * pixelSize()(1); + corner_pos_(im, it, 1, 2) = 0.0; + } + } +} + +template +void AGIPD_1MGeometry::positionModuleImp(M&& src, N& dst, T&& pos) const +{ + auto center = assembledDim().second; + auto shape = src.shape(); // caveat: shape has layout (y, x) + size_t n_tiles = n_tiles_per_module; + size_t wt = tile_shape[1]; + size_t ht = tile_shape[0]; + for (int it = 0; it < n_tiles; ++it) + { + auto x0 = pos(it, 0, 0); + auto y0 = pos(it, 0, 1); + + int ix_dir = (pos(it, 1, 0) - x0 > 0) ? 1 : -1; + int iy_dir = (pos(it, 1, 1) - y0 > 0) ? 1 : -1; + + size_t ix0 = it * wt; + size_t iy0 = 0; + + size_t ix0_dst = ix_dir > 0 ? std::floor(x0 + center[0]) : std::ceil(x0 + center[0]) - 1; + size_t iy0_dst = iy_dir > 0 ? std::floor(y0 + center[1]) : std::ceil(y0 + center[1]) - 1; + for (size_t iy = iy0, iy_dst = iy0_dst; iy < iy0 + ht; ++iy, iy_dst += iy_dir) + { + for (size_t ix = ix0, ix_dst = ix0_dst; ix < ix0 + wt; ++ix, ix_dst += ix_dir) + { + dst(iy_dst, ix_dst) = src(ix, iy); // (fs/y, ss/x) + } + } + } +} + /** * LPD-1M geometry * @@ -329,7 +503,10 @@ const LPD_1MGeometry::shapeType LPD_1MGeometry::module_shape {256, 256}; const LPD_1MGeometry::shapeType LPD_1MGeometry::tile_shape {32, 128}; constexpr size_t LPD_1MGeometry::n_tiles_per_module; const LPD_1MGeometry::quadOrientType LPD_1MGeometry::quad_orientations { - std::array{1, 1}, std::array{1, 1}, std::array{1, 1}, std::array{1, 1} + std::array{1, 1}, + std::array{1, 1}, + std::array{1, 1}, + std::array{1, 1} }; LPD_1MGeometry::LPD_1MGeometry() @@ -359,7 +536,7 @@ LPD_1MGeometry::LPD_1MGeometry() auto wt = static_cast(tile_shape[1]); for (size_t im = 0; im < n_modules; ++im) { - for (size_t it = 0; it < 16; ++it) + for (size_t it = 0; it < n_tiles_per_module; ++it) { positions(im, it, 0) = m_pos(im, 0) - (1 - it / 8) * wt; positions(im, it, 1) = m_pos(im, 1) - (it < 8 ? (it % 8) * ht : (7 - it % 8) * ht); @@ -498,7 +675,10 @@ const DSSC_1MGeometry::shapeType DSSC_1MGeometry::module_shape {128, 512}; const DSSC_1MGeometry::shapeType DSSC_1MGeometry::tile_shape {128, 256}; constexpr size_t DSSC_1MGeometry::n_tiles_per_module; const DSSC_1MGeometry::quadOrientType DSSC_1MGeometry::quad_orientations { - std::array{-1, 1}, std::array{-1, 1}, std::array{1, -1}, std::array{1, -1} + std::array{-1, 1}, + std::array{-1, 1}, + std::array{1, -1}, + std::array{1, -1} }; DSSC_1MGeometry::DSSC_1MGeometry() diff --git a/test/test_geometry.cpp b/test/test_geometry.cpp index 2ba43dd29..309d3a4ea 100644 --- a/test/test_geometry.cpp +++ b/test/test_geometry.cpp @@ -39,7 +39,7 @@ class Test1MGeometry : public ::testing::Test -using Geometry1MTypes = ::testing::Types; +using Geometry1MTypes = ::testing::Types; TYPED_TEST_CASE(Test1MGeometry, Geometry1MTypes); From 06df4e6ebe099bf09304a6d80966706e391b303e Mon Sep 17 00:00:00 2001 From: zhujun Date: Thu, 5 Mar 2020 10:15:27 +0100 Subject: [PATCH 13/23] Change ROI geom in metadata --- .../gui/ctrl_widgets/roi_ctrl_widget.py | 33 ++--- .../gui/image_tool/tests/test_image_tool.py | 113 +++++++++--------- extra_foam/gui/mediator.py | 5 +- extra_foam/pipeline/processors/image_roi.py | 12 +- 4 files changed, 82 insertions(+), 81 deletions(-) diff --git a/extra_foam/gui/ctrl_widgets/roi_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/roi_ctrl_widget.py index 7015ba768..2cebfc884 100644 --- a/extra_foam/gui/ctrl_widgets/roi_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/roi_ctrl_widget.py @@ -18,7 +18,6 @@ from ..ctrl_widgets import _AbstractCtrlWidget, SmartLineEdit from ..misc_widgets import FColor from ...config import config -from ...pipeline.data_model import RectRoiGeom class _SingleRoiCtrlWidget(QWidget): @@ -26,14 +25,13 @@ class _SingleRoiCtrlWidget(QWidget): Widget which controls a single ROI. """ - # (idx, x, y, w, h) where idx starts from 1 + # TODO: locked currently is always 0 + # (idx, activated, locked, x, y, w, h) where idx starts from 1 roi_geometry_change_sgn = pyqtSignal(object) _pos_validator = QIntValidator(-10000, 10000) _size_validator = QIntValidator(1, 10000) - INVALID_GEOM = RectRoiGeom.INVALID - def __init__(self, roi: RectROI, *, parent=None): super().__init__(parent=parent) self._roi = roi @@ -102,14 +100,14 @@ def onToggleRoiActivation(self, state): if state == Qt.Checked: self._roi.show() self.enableAllEdit() - x, y = [int(v) for v in self._roi.pos()] - w, h = [int(v) for v in self._roi.size()] else: self._roi.hide() self.disableAllEdit() - x, y, w, h = self.INVALID_GEOM - self.roi_geometry_change_sgn.emit((self._roi.index, x, y, w, h)) + x, y = [int(v) for v in self._roi.pos()] + w, h = [int(v) for v in self._roi.size()] + self.roi_geometry_change_sgn.emit( + (self._roi.index, state == Qt.Checked, 0, x, y, w, h)) @pyqtSlot(object) def onRoiPositionEdited(self, value): @@ -129,10 +127,9 @@ def onRoiPositionEdited(self, value): # otherwise triggers infinite recursion self._roi.stateChanged(finish=False) - if not self._activate_cb.isChecked(): - x, y, w, h = self.INVALID_GEOM - - self.roi_geometry_change_sgn.emit((self._roi.index, x, y, w, h)) + state = self._activate_cb.isChecked() + self.roi_geometry_change_sgn.emit( + (self._roi.index, state, 0, x, y, w, h)) @pyqtSlot(object) def onRoiSizeEdited(self, value): @@ -151,10 +148,8 @@ def onRoiSizeEdited(self, value): # otherwise triggers infinite recursion self._roi.stateChanged(finish=False) - if not self._activate_cb.isChecked(): - x, y, w, h = self.INVALID_GEOM - - self.roi_geometry_change_sgn.emit((self._roi.index, x, y, w, h)) + self.roi_geometry_change_sgn.emit( + (self._roi.index, self._activate_cb.isChecked(), 0, x, y, w, h)) @pyqtSlot(object) def onRoiGeometryChangeFinished(self, roi): @@ -164,10 +159,8 @@ def onRoiGeometryChangeFinished(self, roi): self.updateParameters(x, y, w, h) # inform widgets outside this window - if not self._activate_cb.isChecked(): - x, y, w, h = self.INVALID_GEOM - - self.roi_geometry_change_sgn.emit((roi.index, x, y, w, h)) + self.roi_geometry_change_sgn.emit( + (roi.index, self._activate_cb.isChecked(), 0, x, y, w, h)) def notifyRoiParams(self): # fill the QLineEdit(s) and Redis diff --git a/extra_foam/gui/image_tool/tests/test_image_tool.py b/extra_foam/gui/image_tool/tests/test_image_tool.py index 6ae121b25..0801094b9 100644 --- a/extra_foam/gui/image_tool/tests/test_image_tool.py +++ b/extra_foam/gui/image_tool/tests/test_image_tool.py @@ -113,6 +113,8 @@ def testRoiCtrlWidget(self): proc = self.pulse_worker._image_roi self.assertEqual(4, len(roi_ctrls)) + # test default + proc.update() for i, ctrl in enumerate(roi_ctrls, 1): @@ -121,7 +123,6 @@ def testRoiCtrlWidget(self): list(ctrl._roi.pos())) self.assertListEqual([int(ctrl._width_le.text()), int(ctrl._height_le.text())], list(ctrl._roi.size())) - # test default values self.assertListEqual(RectRoiGeom.INVALID, getattr(proc, f"_geom{i}")) for ctrl in roi_ctrls: @@ -132,61 +133,63 @@ def testRoiCtrlWidget(self): self.assertFalse(ctrl._px_le.isEnabled()) self.assertFalse(ctrl._py_le.isEnabled()) - roi1_ctrl = roi_ctrls[0] - roi1 = self.view._rois[0] - self.assertIs(roi1_ctrl._roi, roi1) - - # activate ROI1 ctrl - QTest.mouseClick(roi1_ctrl._activate_cb, Qt.LeftButton, - pos=QPoint(2, roi1_ctrl._activate_cb.height()/2)) - self.assertTrue(roi1_ctrl._activate_cb.isChecked()) - proc.update() - - self.assertTupleEqual((int(roi1_ctrl._width_le.text()), int(roi1_ctrl._height_le.text())), - tuple(roi1.size())) - self.assertTupleEqual((int(roi1_ctrl._px_le.text()), int(roi1_ctrl._py_le.text())), - tuple(roi1.pos())) - - # use keyClicks to test that the QLineEdit is enabled - roi1_ctrl._width_le.clear() - QTest.keyClicks(roi1_ctrl._width_le, "10") - QTest.keyPress(roi1_ctrl._width_le, Qt.Key_Enter) - roi1_ctrl._height_le.clear() - QTest.keyClicks(roi1_ctrl._height_le, "30") - QTest.keyPress(roi1_ctrl._height_le, Qt.Key_Enter) - self.assertTupleEqual((10, 30), tuple(roi1.size())) - - # ROI can be outside of the image - roi1_ctrl._px_le.clear() - QTest.keyClicks(roi1_ctrl._px_le, "-1") - QTest.keyPress(roi1_ctrl._px_le, Qt.Key_Enter) - roi1_ctrl._py_le.clear() - QTest.keyClicks(roi1_ctrl._py_le, "-3") - QTest.keyPress(roi1_ctrl._py_le, Qt.Key_Enter) - self.assertTupleEqual((-1, -3), tuple(roi1.pos())) + # test activating ROI + + for i, item in enumerate(zip(roi_ctrls, self.view._rois), 1): + ctrl, roi = item + self.assertIs(ctrl._roi, roi) + + QTest.mouseClick(ctrl._activate_cb, Qt.LeftButton, + pos=QPoint(2, ctrl._activate_cb.height()/2)) + self.assertTrue(ctrl._activate_cb.isChecked()) + proc.update() + w_gt, h_gt = int(ctrl._width_le.text()), int(ctrl._height_le.text()) + self.assertTupleEqual((w_gt, h_gt), tuple(roi.size())) + x_gt, y_gt = int(ctrl._px_le.text()), int(ctrl._py_le.text()) + self.assertTupleEqual((x_gt, y_gt), tuple(roi.pos())) + self.assertListEqual([x_gt, y_gt, w_gt, h_gt], getattr(proc, f"_geom{i}")) + + # use keyClicks to test that the QLineEdit is enabled + ctrl._width_le.clear() + QTest.keyClicks(ctrl._width_le, "10") + QTest.keyPress(ctrl._width_le, Qt.Key_Enter) + ctrl._height_le.clear() + QTest.keyClicks(ctrl._height_le, "30") + QTest.keyPress(ctrl._height_le, Qt.Key_Enter) + self.assertTupleEqual((10, 30), tuple(roi.size())) + + # ROI can be outside of the image + ctrl._px_le.clear() + QTest.keyClicks(ctrl._px_le, "-1") + QTest.keyPress(ctrl._px_le, Qt.Key_Enter) + ctrl._py_le.clear() + QTest.keyClicks(ctrl._py_le, "-3") + QTest.keyPress(ctrl._py_le, Qt.Key_Enter) + self.assertTupleEqual((-1, -3), tuple(roi.pos())) + proc.update() + self.assertListEqual([-1, -3, 10, 30], getattr(proc, f"_geom{i}")) + + # lock ROI ctrl + QTest.mouseClick(ctrl._lock_cb, Qt.LeftButton, + pos=QPoint(2, ctrl._lock_cb.height()/2)) + self.assertTrue(ctrl._activate_cb.isChecked()) + self.assertTrue(ctrl._lock_cb.isChecked()) + self.assertFalse(ctrl._width_le.isEnabled()) + self.assertFalse(ctrl._height_le.isEnabled()) + self.assertFalse(ctrl._px_le.isEnabled()) + self.assertFalse(ctrl._py_le.isEnabled()) - proc.update() - self.assertListEqual([-1, -3, 10, 30], proc._geom1) - - # lock ROI ctrl - QTest.mouseClick(roi1_ctrl._lock_cb, Qt.LeftButton, - pos=QPoint(2, roi1_ctrl._lock_cb.height()/2)) - self.assertTrue(roi1_ctrl._activate_cb.isChecked()) - self.assertTrue(roi1_ctrl._lock_cb.isChecked()) - self.assertFalse(roi1_ctrl._width_le.isEnabled()) - self.assertFalse(roi1_ctrl._height_le.isEnabled()) - self.assertFalse(roi1_ctrl._px_le.isEnabled()) - self.assertFalse(roi1_ctrl._py_le.isEnabled()) - - # deactivate ROI ctrl - QTest.mouseClick(roi1_ctrl._activate_cb, Qt.LeftButton, - pos=QPoint(2, roi1_ctrl._activate_cb.height()/2)) - self.assertFalse(roi1_ctrl._activate_cb.isChecked()) - self.assertTrue(roi1_ctrl._lock_cb.isChecked()) - self.assertFalse(roi1_ctrl._width_le.isEnabled()) - self.assertFalse(roi1_ctrl._height_le.isEnabled()) - self.assertFalse(roi1_ctrl._px_le.isEnabled()) - self.assertFalse(roi1_ctrl._py_le.isEnabled()) + # deactivate ROI ctrl + QTest.mouseClick(ctrl._activate_cb, Qt.LeftButton, + pos=QPoint(2, ctrl._activate_cb.height()/2)) + self.assertFalse(ctrl._activate_cb.isChecked()) + self.assertTrue(ctrl._lock_cb.isChecked()) + self.assertFalse(ctrl._width_le.isEnabled()) + self.assertFalse(ctrl._height_le.isEnabled()) + self.assertFalse(ctrl._px_le.isEnabled()) + self.assertFalse(ctrl._py_le.isEnabled()) + proc.update() + self.assertListEqual(RectRoiGeom.INVALID, getattr(proc, f"_geom{i}")) def testMovingAverageQLineEdit(self): # TODO: remove it in the future diff --git a/extra_foam/gui/mediator.py b/extra_foam/gui/mediator.py index 009c28711..a00f0bf9b 100644 --- a/extra_foam/gui/mediator.py +++ b/extra_foam/gui/mediator.py @@ -174,8 +174,9 @@ def onPpReset(self): self.onResetMa() def onRoiGeometryChange(self, value: tuple): - idx, x, y, w, h = value - self._meta.hset(mt.ROI_PROC, f'geom{idx}', str((x, y, w, h))) + idx, activated, locked, x, y, w, h = value + self._meta.hset(mt.ROI_PROC, f'geom{idx}', + str((int(activated), int(locked), x, y, w, h))) def onRoiFomTypeChange(self, value: IntEnum): self._meta.hset(mt.ROI_PROC, 'fom:type', int(value)) diff --git a/extra_foam/pipeline/processors/image_roi.py b/extra_foam/pipeline/processors/image_roi.py index 6c96e6353..a8dc2c450 100644 --- a/extra_foam/pipeline/processors/image_roi.py +++ b/extra_foam/pipeline/processors/image_roi.py @@ -127,10 +127,14 @@ def update(self): """Override.""" cfg = super().update() - self._geom1 = self.str2list(cfg[f'geom1'], handler=int) - self._geom2 = self.str2list(cfg[f'geom2'], handler=int) - self._geom3 = self.str2list(cfg[f'geom3'], handler=int) - self._geom4 = self.str2list(cfg[f'geom4'], handler=int) + geom1 = self.str2list(cfg[f'geom1'], handler=int) + self._geom1 = RectRoiGeom.INVALID if geom1[0] == 0 else geom1[2:] + geom2 = self.str2list(cfg[f'geom2'], handler=int) + self._geom2 = RectRoiGeom.INVALID if geom2[0] == 0 else geom2[2:] + geom3 = self.str2list(cfg[f'geom3'], handler=int) + self._geom3 = RectRoiGeom.INVALID if geom3[0] == 0 else geom3[2:] + geom4 = self.str2list(cfg[f'geom4'], handler=int) + self._geom4 = RectRoiGeom.INVALID if geom4[0] == 0 else geom4[2:] @profiler("ROI Processor (pulse)") def process(self, data): From 1173207329be2c7949dfaee47b5d4bfd83609b13 Mon Sep 17 00:00:00 2001 From: zhujun Date: Tue, 3 Mar 2020 18:26:24 +0100 Subject: [PATCH 14/23] Implement save and load metadata --- extra_foam/config.py | 5 + extra_foam/database/base_proxy.py | 4 +- extra_foam/database/metadata.py | 165 +++++++++- extra_foam/database/tests/test_metadata.py | 114 ++++++- .../gui/ctrl_widgets/analysis_ctrl_widget.py | 9 + .../azimuthal_integ_ctrl_widget.py | 40 ++- .../gui/ctrl_widgets/base_ctrl_widgets.py | 19 +- .../ctrl_widgets/calibration_ctrl_widget.py | 17 +- .../ctrl_widgets/correlation_ctrl_widget.py | 1 + .../gui/ctrl_widgets/filter_ctrl_widget.py | 12 + .../gui/ctrl_widgets/geometry_ctrl_widget.py | 28 +- .../gui/ctrl_widgets/histogram_ctrl_widget.py | 13 + .../gui/ctrl_widgets/image_ctrl_widget.py | 6 + .../ctrl_widgets/pump_probe_ctrl_widget.py | 30 +- .../gui/ctrl_widgets/roi_ctrl_widget.py | 20 +- .../gui/ctrl_widgets/roi_fom_ctrl_widget.py | 28 +- .../gui/ctrl_widgets/roi_hist_ctrl_widget.py | 11 + .../gui/ctrl_widgets/roi_norm_ctrl_widget.py | 12 + .../gui/ctrl_widgets/roi_proj_ctrl_widget.py | 30 +- extra_foam/gui/ctrl_widgets/smart_widgets.py | 2 +- extra_foam/gui/gui_helpers.py | 8 + extra_foam/gui/image_tool/image_tool.py | 5 + .../gui/image_tool/tests/test_image_tool.py | 147 ++++++++- extra_foam/gui/main_gui.py | 26 +- extra_foam/gui/mediator.py | 18 +- extra_foam/gui/misc_widgets/__init__.py | 1 + extra_foam/gui/misc_widgets/configurator.py | 292 ++++++++++++++++++ .../misc_widgets/tests/test_configurator.py | 133 ++++++++ extra_foam/gui/tests/test_main_gui.py | 83 ++++- .../pipeline/processors/image_processor.py | 16 +- .../processors/tests/test_histogram.py | 1 + 31 files changed, 1211 insertions(+), 85 deletions(-) create mode 100644 extra_foam/gui/misc_widgets/configurator.py create mode 100644 extra_foam/gui/misc_widgets/tests/test_configurator.py diff --git a/extra_foam/config.py b/extra_foam/config.py index 64d9dd301..28bfbd659 100644 --- a/extra_foam/config.py +++ b/extra_foam/config.py @@ -524,6 +524,11 @@ def config_file(self): return _Config.config_file(topic) raise ValueError("TOPIC is not specified!") + @property + def setup_file(self): + detector = self._data['DETECTOR'] + return osp.join(ROOT_PATH, f".{detector.lower()}.setup.yaml") + @property def control_sources(self): return self._data.control_sources diff --git a/extra_foam/database/base_proxy.py b/extra_foam/database/base_proxy.py index b0e820f4a..472c19d82 100644 --- a/extra_foam/database/base_proxy.py +++ b/extra_foam/database/base_proxy.py @@ -8,10 +8,10 @@ All rights reserved. """ from .db_utils import redis_except_handler -from ..ipc import RedisConnection +from ..ipc import ProcessLogger, RedisConnection -class _AbstractProxy: +class _AbstractProxy(ProcessLogger): """_AbstractProxy. Base class for communicate with Redis server. diff --git a/extra_foam/database/metadata.py b/extra_foam/database/metadata.py index be36d343a..c165dc570 100644 --- a/extra_foam/database/metadata.py +++ b/extra_foam/database/metadata.py @@ -7,21 +7,33 @@ Copyright (C) European X-Ray Free-Electron Laser Facility GmbH. All rights reserved. """ +from datetime import datetime +import os.path as osp +from collections import OrderedDict + +import redis +import yaml +from yaml.scanner import ScannerError +from yaml.parser import ParserError + from .base_proxy import _AbstractProxy from .db_utils import redis_except_handler -from ..config import AnalysisType +from ..config import AnalysisType, config class MetaMetadata(type): def __new__(mcs, name, bases, class_dict): proc_list = [] + proc_keys = [] for k, v in class_dict.items(): if isinstance(v, str): s = v.split(":") if len(s) == 3 and s[1] == 'proc': proc_list.append(s[2]) + proc_keys.append(v) class_dict['processors'] = proc_list + class_dict['processor_keys'] = proc_keys cls = type.__new__(mcs, name, bases, class_dict) return cls @@ -35,6 +47,7 @@ class Metadata(metaclass=MetaMetadata): ANALYSIS_TYPE = "meta:analysis_type" # The key of processors' metadata must end with '_PROC' + META_PROC = "meta:proc:meta" GLOBAL_PROC = "meta:proc:global" IMAGE_PROC = "meta:proc:image" GEOMETRY_PROC = "meta:proc:geometry" @@ -142,3 +155,153 @@ def remove_data_source(self, item): return self._db.pipeline().execute_command( 'DEL', key).execute_command( 'PUBLISH', Metadata.DATA_SOURCE, key).execute() + + @redis_except_handler + def take_snapshot(self, name): + """Take a snapshot of the current metadata. + + :param str name: name of the snapshot. + """ + # return (unix time in seconds, microseconds) + timestamp = self._db.execute_command("TIME") + datetime_str = datetime.fromtimestamp(timestamp[0]).strftime( + "%m/%d/%Y, %H:%M:%S") + + cfg = self._read_configuration() + cfg[Metadata.META_PROC]["timestamp"] = datetime_str + cfg[Metadata.META_PROC]["description"] = "" + self._write_configuration(cfg, None, name) + return name, datetime_str, "" + + @redis_except_handler + def load_snapshot(self, name): + """Load metadata snapshot by name. + + :param str name: name of the snapshot. + """ + self._write_configuration(self._read_configuration(name), name, None) + + @redis_except_handler + def copy_snapshot(self, old, new): + """Copy metadata snapshot. + + :param str old: name of the old snapshot. + :param str new: name of the new snapshot. + """ + self._write_configuration(self._read_configuration(old), old, new) + + @redis_except_handler + def remove_snapshot(self, name): + """Remove metadata snapshot by name. + + :param str name: name of the snapshot + """ + pipe = self.pipeline() + for k in Metadata.processor_keys: + pipe.execute_command("DEL", f"{k}:{name}") + pipe.execute() + + @redis_except_handler + def rename_snapshot(self, old, new): + """Rename a metadata snapshot. + + :param str old: old configuration name. + :param str new: new configuration name. + """ + for k in Metadata.processor_keys: + try: + self._db.execute_command("RENAME", f"{k}:{old}", f"{k}:{new}") + except redis.ResponseError: + pass + + def _read_configuration(self, name=None): + """Read a configuration from Redis. + + :param str name: configuration name. + """ + cfg = dict() + for k in Metadata.processor_keys: + if name is not None: + k = f"{k}:{name}" + cfg[k] = self.hget_all(k) + return cfg + + def _write_configuration(self, cfg, old, new): + """Write a configuration into Redis. + + :param dict cfg: configuration. + :param str old: old configuration name. + :param str new: new configuration name. + """ + invalid_keys = [] + for k, v in cfg.items(): + if old is not None: + k_root = k.rsplit(':', maxsplit=1)[0] + else: + k_root = k + + if new is not None: + k_new = f"{k_root}:{new}" + else: + k_new = k_root + + if k_root in Metadata.processor_keys: + if v: + self._db.hmset(k_new, v) + else: + self._db.execute_command("DEL", k_new) + else: + invalid_keys.append(k) + + if invalid_keys: + self.warning( + f"Invalid keys when writing configuration: {invalid_keys}") + + def dump_configurations(self, lst): + """Dump all GUI configurations into file. + + :param list lst: a list of (name, description) of configurations in the + Configurator widget. + """ + filepath = config.setup_file + with open(filepath, 'w') as fp: + configurations = OrderedDict() + for name, description in lst: + configurations[name] = self._read_configuration(name) + meta_key = f"{Metadata.META_PROC}:{name}" + configurations[name][meta_key]["description"] = description + + if configurations: + try: + yaml.dump(configurations, fp, Dumper=yaml.Dumper) + except (ScannerError, ParserError) as e: + self.error(f"Invalid setup file: {filepath}\n{repr(e)}") + + def load_configurations(self): + """Load all GUI configurations from file.""" + filepath = config.setup_file + lst = [] + if osp.isfile(filepath): + with open(filepath, 'r') as fp: + try: + configurations = yaml.load(fp, Loader=yaml.Loader) + for name, cfg in configurations.items(): + meta_key = f"{Metadata.META_PROC}:{name}" + try: + timestamp = cfg[meta_key]["timestamp"] + except KeyError: + self.error(f"Invalid configuration: {name}! " + f"timestamp is missing") + continue + + try: + description = cfg[meta_key]["description"] + except KeyError: + description = "" + + lst.append((name, timestamp, description)) + self._write_configuration(cfg, name, name) + + except (ScannerError, ParserError) as e: + self.error(f"Invalid setup file: {filepath}\n{repr(e)}") + return lst diff --git a/extra_foam/database/tests/test_metadata.py b/extra_foam/database/tests/test_metadata.py index c70b58100..ecdc4f6b3 100644 --- a/extra_foam/database/tests/test_metadata.py +++ b/extra_foam/database/tests/test_metadata.py @@ -1,12 +1,23 @@ import unittest +from unittest.mock import MagicMock, patch +import os +import tempfile -from extra_foam.config import AnalysisType +from extra_foam.config import AnalysisType, config from extra_foam.database.metadata import Metadata, MetaMetadata from extra_foam.database import MetaProxy, MonProxy from extra_foam.processes import wait_until_redis_shutdown from extra_foam.services import start_redis_server +from extra_foam.gui.misc_widgets.configurator import Configurator +LAST_SAVED = Configurator.LAST_SAVED +DEFAULT = Configurator.DEFAULT +_tmp_cfg_dir = tempfile.mkdtemp() + + +@patch("extra_foam.config.ROOT_PATH", _tmp_cfg_dir) +@patch.dict(config._data, {"DETECTOR": "DSSC"}) class TestDataProxy(unittest.TestCase): @classmethod def setUpClass(cls): @@ -15,6 +26,8 @@ def setUpClass(cls): cls._meta = MetaProxy() cls._mon = MonProxy() + cls._meta.error = MagicMock() + @classmethod def tearDownClass(cls): wait_until_redis_shutdown() @@ -23,6 +36,8 @@ def tearDownClass(cls): cls._meta.reset() cls._mon.reset() + os.rmdir(_tmp_cfg_dir) + def testAnalysisType(self): type1 = AnalysisType.AZIMUTHAL_INTEG type2 = AnalysisType.PUMP_PROBE @@ -61,3 +76,100 @@ class Dummy(metaclass=MetaMetadata): GEOMETRY_PROC = "meta:proc:geometry" self.assertListEqual(['global', 'image', 'geometry'], Dummy.processors) + self.assertListEqual([Dummy.GLOBAL_PROC, + Dummy.IMAGE_PROC, + Dummy.GEOMETRY_PROC], Dummy.processor_keys) + + def testSnapshotOperation(self): + data = { + Metadata.IMAGE_PROC: {"aaa": '1', "bbb": "(-1, 1)", "ccc": "sea"}, + Metadata.GLOBAL_PROC: {"a1": '10', "b1": 'True'} + } + + def _write_to_redis(name=None): + for k, v in data.items(): + kk = k if name is None else f"{k}:{name}" + self._meta.hmset(kk, v) + + def _assert_data(meta, name=None): + for k, v in data.items(): + kk = k if name is None else f"{k}:{name}" + self.assertDictEqual(v, meta.hget_all(kk)) + + def _assert_empty_data(meta, name=None): + for k in data: + kk = k if name is None else f"{k}:{name}" + self.assertDictEqual({}, meta.hget_all(kk)) + + # test take snapshot + self._meta.take_snapshot(DEFAULT) + _assert_empty_data(self._meta, DEFAULT) + _write_to_redis() + + _assert_empty_data(self._meta, DEFAULT) + name, timestamp, description = self._meta.take_snapshot(LAST_SAVED) + self.assertEqual(LAST_SAVED, name) + self.assertEqual("", description) + _assert_data(self._meta, LAST_SAVED) + + # test copy snapshot + self._meta.copy_snapshot(LAST_SAVED, "abc") + _assert_data(self._meta, LAST_SAVED) + _assert_data(self._meta, "abc") + + # test load snapshot + self._meta.load_snapshot(DEFAULT) + _assert_empty_data(self._meta) + self._meta.load_snapshot("abc") + _assert_data(self._meta, "abc") + + # test rename snapshot + self._meta.rename_snapshot("abc", "efg") + _assert_empty_data(self._meta, "abc") + _assert_data(self._meta, "efg") + + # test remove snapshot + self._meta.remove_snapshot("efg") + _assert_empty_data(self._meta, "efg") + + def testDumpLoadConfigurations(self): + filepath = config.setup_file + + image_proc_data = {"aaa": '1', "bbb": "(-1, 1)", "ccc": "sea"} + self._meta.hmset(f"{Metadata.IMAGE_PROC}:Last saved", image_proc_data) + self._meta.hmset(f"{Metadata.META_PROC}:Last saved", { + "timestamp": "2020-03-03 03:03:03", + "description": "" + }) + + self._meta.hmset(f"{Metadata.IMAGE_PROC}:abc", image_proc_data) + # no 'description' + self._meta.hmset(f"{Metadata.META_PROC}:abc", { + "timestamp": "2020-03-04 03:03:03", + }) + + # no 'timestamp' + self._meta.hmset(f"{Metadata.IMAGE_PROC}:efg", image_proc_data) + + try: + self._meta.dump_configurations([("Last saved", ''), ("abc", "abc setup"), ("efg", "efg setup")]) + + self._meta.remove_snapshot("Last saved") + self._meta.remove_snapshot("abc") + self._meta.remove_snapshot("efg") + self.assertDictEqual({}, self._meta.hget_all(f"{Metadata.IMAGE_PROC}:Last saved")) + + lst = self._meta.load_configurations() + + self._meta.error.assert_called_once() + self.assertListEqual([('Last saved', '2020-03-03 03:03:03', ''), + ('abc', '2020-03-04 03:03:03', 'abc setup')], lst) + + self.assertDictEqual(image_proc_data, self._meta.hget_all(f"{Metadata.IMAGE_PROC}:Last saved")) + self.assertDictEqual(image_proc_data, self._meta.hget_all(f"{Metadata.IMAGE_PROC}:abc")) + + finally: + os.remove(filepath) + self._meta.remove_snapshot("Last saved") + self._meta.remove_snapshot("abc") + self._meta.remove_snapshot("efg") diff --git a/extra_foam/gui/ctrl_widgets/analysis_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/analysis_ctrl_widget.py index e1cdbd231..eaa9f2f7e 100644 --- a/extra_foam/gui/ctrl_widgets/analysis_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/analysis_ctrl_widget.py @@ -14,6 +14,7 @@ from .base_ctrl_widgets import _AbstractGroupBoxCtrlWidget from .smart_widgets import SmartLineEdit from ...config import config +from ...database import Metadata as mt class AnalysisCtrlWidget(_AbstractGroupBoxCtrlWidget): @@ -80,3 +81,11 @@ def updateMetaData(self): w.returnPressed.emit() self._ma_window_le.returnPressed.emit() return True + + def loadMetaData(self): + """Override.""" + cfg = self._meta.hget_all(mt.GLOBAL_PROC) + self._ma_window_le.setText(cfg["ma_window"]) + if self._pulse_resolved: + self._poi_index_les[0].setText(cfg["poi1_index"]) + self._poi_index_les[1].setText(cfg["poi2_index"]) diff --git a/extra_foam/gui/ctrl_widgets/azimuthal_integ_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/azimuthal_integ_ctrl_widget.py index 8baeea555..64171b6d0 100644 --- a/extra_foam/gui/ctrl_widgets/azimuthal_integ_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/azimuthal_integ_ctrl_widget.py @@ -7,15 +7,18 @@ Copyright (C) European X-Ray Free-Electron Laser Facility GmbH. All rights reserved. """ +from collections import OrderedDict + from PyQt5.QtCore import Qt from PyQt5.QtGui import QDoubleValidator, QIntValidator from PyQt5.QtWidgets import QComboBox, QGridLayout, QLabel from .base_ctrl_widgets import _AbstractCtrlWidget from .smart_widgets import SmartBoundaryLineEdit, SmartLineEdit +from ..gui_helpers import invert_dict from ...algorithms import compute_q -from ...config import config, list_azimuthal_integ_methods - +from ...config import config, list_azimuthal_integ_methods, Normalizer +from ...database import Metadata as mt _DEFAULT_AZIMUTHAL_INTEG_POINTS = 512 @@ -31,6 +34,15 @@ def _estimate_q_range(): class AzimuthalIntegCtrlWidget(_AbstractCtrlWidget): """Widget for setting up azimuthal integration parameters.""" + _available_norms = OrderedDict({ + "": Normalizer.UNDEFINED, + "AUC": Normalizer.AUC, + "XGM": Normalizer.XGM, + "DIGITIZER": Normalizer.DIGITIZER, + "ROI": Normalizer.ROI, + }) + _available_norms_inv = invert_dict(_available_norms) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -152,8 +164,7 @@ def initConnections(self): mediator.onAiIntegMethodChange) self._norm_cb.currentTextChanged.connect( - lambda x: mediator.onCurveNormalizerChange( - self._available_norms[x])) + lambda x: mediator.onAiNormChange(self._available_norms[x])) self._integ_range_le.value_changed_sgn.connect( mediator.onAiIntegRangeChange) @@ -162,7 +173,7 @@ def initConnections(self): lambda x: mediator.onAiIntegPointsChange(int(x))) self._auc_range_le.value_changed_sgn.connect( - mediator.onAiAucChangeChange) + mediator.onAiAucRangeChange) self._fom_integ_range_le.value_changed_sgn.connect( mediator.onAiFomIntegRangeChange) @@ -193,3 +204,22 @@ def updateMetaData(self): self._fom_integ_range_le.returnPressed.emit() return True + + def loadMetaData(self): + """Override.""" + cfg = self._meta.hget_all(mt.GLOBAL_PROC) + self._photon_energy_le.setText(cfg["photon_energy"]) + self._sample_dist_le.setText(cfg["sample_distance"]) + + cfg = self._meta.hget_all(mt.AZIMUTHAL_INTEG_PROC) + self._px_le.setText(cfg['pixel_size_x']) + self._py_le.setText(cfg['pixel_size_y']) + self._cx_le.setText(cfg['integ_center_x']) + self._cy_le.setText(cfg['integ_center_y']) + self._integ_method_cb.setCurrentText(cfg['integ_method']) + self._integ_range_le.setText(cfg['integ_range'][1:-1]) + self._integ_pts_le.setText(cfg['integ_points']) + self._norm_cb.setCurrentText( + self._available_norms_inv[int(cfg['normalizer'])]) + self._auc_range_le.setText(cfg['auc_range'][1:-1]) + self._fom_integ_range_le.setText(cfg['fom_integ_range'][1:-1]) diff --git a/extra_foam/gui/ctrl_widgets/base_ctrl_widgets.py b/extra_foam/gui/ctrl_widgets/base_ctrl_widgets.py index 7780cec48..408ec99f1 100644 --- a/extra_foam/gui/ctrl_widgets/base_ctrl_widgets.py +++ b/extra_foam/gui/ctrl_widgets/base_ctrl_widgets.py @@ -8,12 +8,11 @@ All rights reserved. """ import abc -from collections import OrderedDict from PyQt5.QtWidgets import QFrame, QGroupBox from ..mediator import Mediator -from ...config import Normalizer +from ...database import MetaProxy class _AbstractCtrlWidgetMixin: @@ -36,6 +35,12 @@ def updateMetaData(self): """ raise NotImplementedError + @abc.abstractmethod + def loadMetaData(self): + """Load metadata from Redis and set this control widget.""" + # raise NotImplementedError + pass + @abc.abstractmethod def onStart(self): raise NotImplementedError @@ -47,14 +52,6 @@ def onStop(self): class _AbstractCtrlWidget(QFrame, _AbstractCtrlWidgetMixin): - _available_norms = OrderedDict({ - "": Normalizer.UNDEFINED, - "AUC": Normalizer.AUC, - "XGM": Normalizer.XGM, - "DIGITIZER": Normalizer.DIGITIZER, - "ROI": Normalizer.ROI, - }) - def __init__(self, *, pulse_resolved=True, parent=None): """Initialization. @@ -64,6 +61,7 @@ def __init__(self, *, pulse_resolved=True, parent=None): super().__init__(parent=parent) self._mediator = Mediator() + self._meta = MetaProxy() # widgets whose values are not allowed to change after the "run" # button is clicked @@ -103,6 +101,7 @@ def __init__(self, title, *, pulse_resolved=True, parent=None): self.setStyleSheet(self.GROUP_BOX_STYLE_SHEET) self._mediator = Mediator() + self._meta = MetaProxy() # widgets whose values are not allowed to change after the "run" # button is clicked diff --git a/extra_foam/gui/ctrl_widgets/calibration_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/calibration_ctrl_widget.py index 150e99798..6b69d31b9 100644 --- a/extra_foam/gui/ctrl_widgets/calibration_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/calibration_ctrl_widget.py @@ -16,7 +16,8 @@ from .base_ctrl_widgets import _AbstractCtrlWidget from .smart_widgets import SmartSliceLineEdit -from ..gui_helpers import create_icon_button +from ..gui_helpers import create_icon_button, parse_slice_inv +from ...database import Metadata as mt from ...ipc import CalConstantsPub @@ -128,6 +129,20 @@ def updateMetaData(self): self._offset_slicer_le.returnPressed.emit() return True + def loadMetaData(self): + """Override.""" + cfg = self._meta.hget_all(mt.IMAGE_PROC) + + self._correct_gain_cb.setChecked(cfg["correct_gain"] == 'True') + self._correct_offset_cb.setChecked(cfg["correct_offset"] == 'True') + self._dark_as_offset_cb.setChecked(cfg["dark_as_offset"] == 'True') + + if self._pulse_resolved: + self._gain_slicer_le.setText( + parse_slice_inv(cfg["gain_slicer"])) + self._offset_slicer_le.setText( + parse_slice_inv(cfg["offset_slicer"])) + @pyqtSlot() def _loadGainConst(self): filepath = QFileDialog.getOpenFileName( diff --git a/extra_foam/gui/ctrl_widgets/correlation_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/correlation_ctrl_widget.py index 1bda5da2f..9503786ec 100644 --- a/extra_foam/gui/ctrl_widgets/correlation_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/correlation_ctrl_widget.py @@ -14,6 +14,7 @@ from PyQt5.QtGui import QDoubleValidator from PyQt5.QtWidgets import ( QComboBox, QGridLayout, QHeaderView, QLabel, QPushButton, QTableWidget, + QTableWidgetItem ) from .base_ctrl_widgets import _AbstractGroupBoxCtrlWidget diff --git a/extra_foam/gui/ctrl_widgets/filter_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/filter_ctrl_widget.py index b8e1cf190..0a5441d2b 100644 --- a/extra_foam/gui/ctrl_widgets/filter_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/filter_ctrl_widget.py @@ -14,7 +14,9 @@ from .base_ctrl_widgets import _AbstractGroupBoxCtrlWidget from .smart_widgets import SmartBoundaryLineEdit +from ..gui_helpers import invert_dict from ...config import AnalysisType +from ...database import Metadata as mt class FomFilterCtrlWidget(_AbstractGroupBoxCtrlWidget): @@ -24,6 +26,7 @@ class FomFilterCtrlWidget(_AbstractGroupBoxCtrlWidget): "": AnalysisType.UNDEFINED, "ROI FOM": AnalysisType.ROI_FOM, }) + _analysis_types_inv = invert_dict(_analysis_types) def __init__(self, *args, **kwargs): super().__init__("FOM filter setup", *args, **kwargs) @@ -77,3 +80,12 @@ def updateMetaData(self): self._pulse_resolved_cb.toggled.emit( self._pulse_resolved_cb.isChecked()) return True + + def loadMetaData(self): + """Override.""" + cfg = self._meta.hget_all(mt.FOM_FILTER_PROC) + self._analysis_type_cb.setCurrentText( + self._analysis_types_inv[int(cfg["analysis_type"])]) + self._fom_range_le.setText(cfg["fom_range"][1:-1]) + if self._pulse_resolved: + self._pulse_resolved_cb.setChecked(cfg["pulse_resolved"] == 'True') diff --git a/extra_foam/gui/ctrl_widgets/geometry_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/geometry_ctrl_widget.py index 26b2dc7fd..584f20245 100644 --- a/extra_foam/gui/ctrl_widgets/geometry_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/geometry_ctrl_widget.py @@ -10,6 +10,8 @@ import os.path as osp from collections import OrderedDict +import json + from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QCheckBox, QComboBox, QFileDialog, QGridLayout, QHeaderView, QHBoxLayout, @@ -18,8 +20,9 @@ ) from .base_ctrl_widgets import _AbstractCtrlWidget -from ..gui_helpers import parse_table_widget +from ..gui_helpers import invert_dict, parse_table_widget from ...config import config, GeomAssembler +from ...database import Metadata as mt from ...logger import logger @@ -30,6 +33,7 @@ class GeometryCtrlWidget(_AbstractCtrlWidget): "EXtra-foam": GeomAssembler.OWN, "EXtra-geom": GeomAssembler.EXTRA_GEOM, }) + _assemblers_inv = invert_dict(_assemblers) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -142,6 +146,8 @@ def updateMetaData(self): self._assembler_cb.currentTextChanged.emit( self._assembler_cb.currentText()) + # FIXME + geom_file = self._geom_file_le.text() if not osp.isfile(geom_file): logger.error(f": {geom_file} is not a valid file") @@ -157,3 +163,23 @@ def updateMetaData(self): self._mediator.onGeomQuadPositionsChange(quad_positions) return True + + def loadMetaData(self): + """Override.""" + if not config['REQUIRE_GEOMETRY']: + return + + cfg = self._meta.hget_all(mt.GEOMETRY_PROC) + + self._assembler_cb.setCurrentText( + self._assemblers_inv[int(cfg["assembler"])]) + self._stack_only_cb.setChecked(cfg["stack_only"] == 'True') + self._geom_file_le.setText(cfg["geometry_file"]) + quad_positions = json.loads(cfg["quad_positions"], encoding='utf8') + + table = self._quad_positions_tb + n_rows = table.rowCount() + n_cols = table.columnCount() + for j in range(n_cols): + for i in range(n_rows): + table.item(i, j).setText(str(quad_positions[j][i])) diff --git a/extra_foam/gui/ctrl_widgets/histogram_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/histogram_ctrl_widget.py index 6119bb783..244c0ce25 100644 --- a/extra_foam/gui/ctrl_widgets/histogram_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/histogram_ctrl_widget.py @@ -17,7 +17,9 @@ from .base_ctrl_widgets import _AbstractGroupBoxCtrlWidget from .smart_widgets import SmartBoundaryLineEdit, SmartLineEdit +from ..gui_helpers import invert_dict from ...config import AnalysisType +from ...database import Metadata as mt class HistogramCtrlWidget(_AbstractGroupBoxCtrlWidget): @@ -27,6 +29,7 @@ class HistogramCtrlWidget(_AbstractGroupBoxCtrlWidget): "": AnalysisType.UNDEFINED, "ROI FOM": AnalysisType.ROI_FOM, }) + _analysis_types_inv = invert_dict(_analysis_types) def __init__(self, *args, **kwargs): super().__init__("Histogram setup", *args, **kwargs) @@ -95,3 +98,13 @@ def updateMetaData(self): self._pulse_resolved_cb.toggled.emit( self._pulse_resolved_cb.isChecked()) return True + + def loadMetaData(self): + """Override.""" + cfg = self._meta.hget_all(mt.HISTOGRAM_PROC) + self._analysis_type_cb.setCurrentText( + self._analysis_types_inv[int(cfg["analysis_type"])]) + self._bin_range_le.setText(cfg["bin_range"][1:-1]) + self._n_bins_le.setText(cfg['n_bins']) + if self._pulse_resolved: + self._pulse_resolved_cb.setChecked(cfg["pulse_resolved"] == 'True') diff --git a/extra_foam/gui/ctrl_widgets/image_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/image_ctrl_widget.py index b0ea28ad6..f40470f1a 100644 --- a/extra_foam/gui/ctrl_widgets/image_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/image_ctrl_widget.py @@ -17,6 +17,7 @@ from ..ctrl_widgets.smart_widgets import ( SmartLineEdit, SmartBoundaryLineEdit ) +from ...database import Metadata as mt class ImageCtrlWidget(_AbstractCtrlWidget): @@ -82,3 +83,8 @@ def updateMetaData(self): """Override.""" self.threshold_mask_le.returnPressed.emit() return True + + def loadMetaData(self): + """Override.""" + cfg = self._meta.hget_all(mt.IMAGE_PROC) + self.threshold_mask_le.setText(cfg["threshold_mask"][1:-1]) diff --git a/extra_foam/gui/ctrl_widgets/pump_probe_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/pump_probe_ctrl_widget.py index 2e0f74e76..7e5657e5d 100644 --- a/extra_foam/gui/ctrl_widgets/pump_probe_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/pump_probe_ctrl_widget.py @@ -16,7 +16,9 @@ from .base_ctrl_widgets import _AbstractGroupBoxCtrlWidget from .smart_widgets import SmartIdLineEdit +from ..gui_helpers import invert_dict from ...config import PumpProbeMode, AnalysisType +from ...database import Metadata as mt class PumpProbeCtrlWidget(_AbstractGroupBoxCtrlWidget): @@ -29,6 +31,7 @@ class PumpProbeCtrlWidget(_AbstractGroupBoxCtrlWidget): "even/odd train": PumpProbeMode.EVEN_TRAIN_ON, "odd/even train": PumpProbeMode.ODD_TRAIN_ON }) + _available_modes_inv = invert_dict(_available_modes) _analysis_types = OrderedDict({ "": AnalysisType.UNDEFINED, @@ -36,6 +39,7 @@ class PumpProbeCtrlWidget(_AbstractGroupBoxCtrlWidget): "ROI proj": AnalysisType.ROI_PROJ, "azimuthal integ": AnalysisType.AZIMUTHAL_INTEG, }) + _analysis_types_inv = invert_dict(_analysis_types) def __init__(self, *args, **kwargs): super().__init__("Pump-probe setup", *args, **kwargs) @@ -101,7 +105,6 @@ def initConnections(self): self._mode_cb.currentTextChanged.connect( lambda x: mediator.onPpModeChange(self._available_modes[x])) - self._mode_cb.currentTextChanged.connect( lambda x: self.onPpModeChange(self._available_modes[x])) @@ -113,20 +116,37 @@ def initConnections(self): def updateMetaData(self): """Override""" - self._abs_difference_cb.toggled.emit( - self._abs_difference_cb.isChecked()) - self._analysis_type_cb.currentTextChanged.emit( self._analysis_type_cb.currentText()) self._mode_cb.currentTextChanged.emit(self._mode_cb.currentText()) self._on_pulse_le.returnPressed.emit() - self._off_pulse_le.returnPressed.emit() + self._abs_difference_cb.toggled.emit( + self._abs_difference_cb.isChecked()) + return True + def loadMetaData(self): + """Override.""" + cfg = self._meta.hget_all(mt.PUMP_PROBE_PROC) + self._analysis_type_cb.setCurrentText( + self._analysis_types_inv[int(cfg["analysis_type"])]) + + i_mode = int(cfg["mode"]) + if i_mode == PumpProbeMode.SAME_TRAIN: + self._mode_cb.setCurrentText("") + else: + self._mode_cb.setCurrentText( + self._available_modes_inv[int(cfg["mode"])]) + + self._abs_difference_cb.setChecked(cfg["abs_difference"] == 'True') + # FIXME: + # self._on_pulse_le.setText(cfg["on_pulse_indices"]) + # self._off_pulse_le.setText(cfg["on_pulse_indices"]) + def onPpModeChange(self, pp_mode): if not self._pulse_resolved: return diff --git a/extra_foam/gui/ctrl_widgets/roi_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/roi_ctrl_widget.py index 2cebfc884..88fb2f9a1 100644 --- a/extra_foam/gui/ctrl_widgets/roi_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/roi_ctrl_widget.py @@ -17,6 +17,7 @@ from ..plot_widgets.plot_items import RectROI from ..ctrl_widgets import _AbstractCtrlWidget, SmartLineEdit from ..misc_widgets import FColor +from ...database import Metadata as mt from ...config import config @@ -166,6 +167,17 @@ def notifyRoiParams(self): # fill the QLineEdit(s) and Redis self._roi.sigRegionChangeFinished.emit(self._roi) + def reloadRoiParams(self, cfg): + state, _, x, y, w, h = [v.strip() for v in cfg.split(',')] + + self.roi_geometry_change_sgn.disconnect() + self._px_le.setText(x) + self._py_le.setText(y) + self._width_le.setText(w) + self._height_le.setText(h) + self.roi_geometry_change_sgn.connect(Mediator().onRoiGeometryChange) + self._activate_cb.setChecked(bool(int(state))) + def updateParameters(self, x, y, w, h): self.roi_geometry_change_sgn.disconnect() self._px_le.setText(str(x)) @@ -223,6 +235,12 @@ def initConnections(self): def updateMetaData(self): """Override.""" - for i, widget in enumerate(self._roi_ctrls, 1): + for _, widget in enumerate(self._roi_ctrls, 1): widget.notifyRoiParams() return True + + def loadMetaData(self): + """Override.""" + cfg = self._meta.hget_all(mt.ROI_PROC) + for i, widget in enumerate(self._roi_ctrls, 1): + widget.reloadRoiParams(cfg[f"geom{i}"][1:-1]) diff --git a/extra_foam/gui/ctrl_widgets/roi_fom_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/roi_fom_ctrl_widget.py index 42f4a90f5..f7ece7178 100644 --- a/extra_foam/gui/ctrl_widgets/roi_fom_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/roi_fom_ctrl_widget.py @@ -12,15 +12,22 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QCheckBox, QComboBox, QGridLayout, QLabel -from .base_ctrl_widgets import _AbstractCtrlWidget, _AbstractGroupBoxCtrlWidget -from ...config import RoiCombo, RoiFom +from .base_ctrl_widgets import _AbstractGroupBoxCtrlWidget +from ..gui_helpers import invert_dict +from ...config import Normalizer, RoiCombo, RoiFom +from ...database import Metadata as mt class RoiFomCtrlWidget(_AbstractGroupBoxCtrlWidget): """Widget for setting up ROI FOM analysis parameters.""" - _available_norms = _AbstractCtrlWidget._available_norms.copy() - del _available_norms["AUC"] + _available_norms = OrderedDict({ + "": Normalizer.UNDEFINED, + "XGM": Normalizer.XGM, + "DIGITIZER": Normalizer.DIGITIZER, + "ROI": Normalizer.ROI, + }) + _available_norms_inv = invert_dict(_available_norms) _available_combos = OrderedDict({ "ROI1": RoiCombo.ROI1, @@ -29,6 +36,7 @@ class RoiFomCtrlWidget(_AbstractGroupBoxCtrlWidget): "ROI1 + ROI2": RoiCombo.ROI1_ADD_ROI2, "ROI1 / ROI2": RoiCombo.ROI1_DIV_ROI2, }) + _available_combos_inv = invert_dict(_available_combos) _available_types = OrderedDict({ "SUM": RoiFom.SUM, @@ -37,6 +45,7 @@ class RoiFomCtrlWidget(_AbstractGroupBoxCtrlWidget): "MAX": RoiFom.MAX, "MIN": RoiFom.MIN, }) + _available_types_inv = invert_dict(_available_types) def __init__(self, *args, **kwargs): super().__init__("ROI FOM setup", *args, **kwargs) @@ -114,3 +123,14 @@ def onMasterSlaveModeToggled(self, state): self._combo_cb.setEnabled(False) else: self._combo_cb.setEnabled(True) + + def loadMetaData(self): + """Override.""" + cfg = self._meta.hget_all(mt.ROI_PROC) + self._combo_cb.setCurrentText( + self._available_combos_inv[int(cfg["fom:combo"])]) + self._type_cb.setCurrentText( + self._available_types_inv[int(cfg["fom:type"])]) + self._norm_cb.setCurrentText( + self._available_norms_inv[int(cfg["fom:norm"])]) + self._master_slave_cb.setChecked(cfg["fom:master_slave"] == 'True') diff --git a/extra_foam/gui/ctrl_widgets/roi_hist_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/roi_hist_ctrl_widget.py index 276fadcd3..0f92ac65c 100644 --- a/extra_foam/gui/ctrl_widgets/roi_hist_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/roi_hist_ctrl_widget.py @@ -15,7 +15,9 @@ from .base_ctrl_widgets import _AbstractGroupBoxCtrlWidget from .smart_widgets import SmartBoundaryLineEdit, SmartLineEdit +from ..gui_helpers import invert_dict from ...config import RoiCombo +from ...database import Metadata as mt class RoiHistCtrlWidget(_AbstractGroupBoxCtrlWidget): @@ -28,6 +30,7 @@ class RoiHistCtrlWidget(_AbstractGroupBoxCtrlWidget): "ROI1 - ROI2": RoiCombo.ROI1_SUB_ROI2, "ROI1 + ROI2": RoiCombo.ROI1_ADD_ROI2, }) + _available_combos_inv = invert_dict(_available_combos) def __init__(self, *args, **kwargs): super().__init__("ROI histogram setup", *args, **kwargs) @@ -84,3 +87,11 @@ def updateMetaData(self): self._n_bins_le.returnPressed.emit() self._bin_range_le.returnPressed.emit() return True + + def loadMetaData(self): + """Override.""" + cfg = self._meta.hget_all(mt.ROI_PROC) + self._combo_cb.setCurrentText( + self._available_combos_inv[int(cfg["hist:combo"])]) + self._n_bins_le.setText(cfg["hist:n_bins"]) + self._bin_range_le.setText(cfg["hist:bin_range"][1:-1]) diff --git a/extra_foam/gui/ctrl_widgets/roi_norm_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/roi_norm_ctrl_widget.py index 07dc1d398..3cdcaed40 100644 --- a/extra_foam/gui/ctrl_widgets/roi_norm_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/roi_norm_ctrl_widget.py @@ -13,7 +13,9 @@ from PyQt5.QtWidgets import QComboBox, QGridLayout, QLabel from .base_ctrl_widgets import _AbstractGroupBoxCtrlWidget +from ..gui_helpers import invert_dict from ...config import RoiCombo, RoiFom +from ...database import Metadata as mt class RoiNormCtrlWidget(_AbstractGroupBoxCtrlWidget): @@ -25,6 +27,7 @@ class RoiNormCtrlWidget(_AbstractGroupBoxCtrlWidget): "ROI3 - ROI4": RoiCombo.ROI3_SUB_ROI4, "ROI3 + ROI4": RoiCombo.ROI3_ADD_ROI4, }) + _available_combos_inv = invert_dict(_available_combos) _available_types = OrderedDict({ "SUM": RoiFom.SUM, @@ -33,6 +36,7 @@ class RoiNormCtrlWidget(_AbstractGroupBoxCtrlWidget): "MAX": RoiFom.MAX, "MIN": RoiFom.MIN, }) + _available_types_inv = invert_dict(_available_types) def __init__(self, *args, **kwargs): super().__init__("ROI normalizer setup", *args, **kwargs) @@ -80,3 +84,11 @@ def updateMetaData(self): self._combo_cb.currentTextChanged.emit(self._combo_cb.currentText()) self._type_cb.currentTextChanged.emit(self._type_cb.currentText()) return True + + def loadMetaData(self): + """Override.""" + cfg = self._meta.hget_all(mt.ROI_PROC) + self._combo_cb.setCurrentText( + self._available_combos_inv[int(cfg["norm:combo"])]) + self._type_cb.setCurrentText( + self._available_types_inv[int(cfg["norm:type"])]) diff --git a/extra_foam/gui/ctrl_widgets/roi_proj_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/roi_proj_ctrl_widget.py index 51bcadd73..2db8dcf07 100644 --- a/extra_foam/gui/ctrl_widgets/roi_proj_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/roi_proj_ctrl_widget.py @@ -12,15 +12,24 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QComboBox, QGridLayout, QLabel -from .base_ctrl_widgets import _AbstractCtrlWidget, _AbstractGroupBoxCtrlWidget +from .base_ctrl_widgets import _AbstractGroupBoxCtrlWidget from .smart_widgets import SmartBoundaryLineEdit -from ...config import RoiCombo, RoiProjType +from ..gui_helpers import invert_dict +from ...database import Metadata as mt +from ...config import Normalizer, RoiCombo, RoiProjType class RoiProjCtrlWidget(_AbstractGroupBoxCtrlWidget): """Widget for setting up ROI 1D projection analysis parameters.""" - _available_norms = _AbstractCtrlWidget._available_norms + _available_norms = OrderedDict({ + "": Normalizer.UNDEFINED, + "AUC": Normalizer.AUC, + "XGM": Normalizer.XGM, + "DIGITIZER": Normalizer.DIGITIZER, + "ROI": Normalizer.ROI, + }) + _available_norms_inv = invert_dict(_available_norms) _available_combos = OrderedDict({ "ROI1": RoiCombo.ROI1, @@ -28,11 +37,13 @@ class RoiProjCtrlWidget(_AbstractGroupBoxCtrlWidget): "ROI1 - ROI2": RoiCombo.ROI1_SUB_ROI2, "ROI1 + ROI2": RoiCombo.ROI1_ADD_ROI2, }) + _available_combos_inv = invert_dict(_available_combos) _available_types = OrderedDict({ "SUM": RoiProjType.SUM, "MEAN": RoiProjType.MEAN, }) + _available_types_inv = invert_dict(_available_types) def __init__(self, *args, **kwargs): super().__init__("ROI projection setup", *args, **kwargs) @@ -123,3 +134,16 @@ def updateMetaData(self): self._auc_range_le.returnPressed.emit() self._fom_integ_range_le.returnPressed.emit() return True + + def loadMetaData(self): + """Override.""" + cfg = self._meta.hget_all(mt.ROI_PROC) + self._combo_cb.setCurrentText( + self._available_combos_inv[int(cfg["proj:combo"])]) + self._type_cb.setCurrentText( + self._available_types_inv[int(cfg["proj:type"])]) + self._direct_cb.setCurrentText(cfg["proj:direct"]) + self._norm_cb.setCurrentText( + self._available_norms_inv[int(cfg["proj:norm"])]) + self._auc_range_le.setText(cfg["proj:auc_range"][1:-1]) + self._fom_integ_range_le.setText(cfg["proj:fom_integ_range"][1:-1]) diff --git a/extra_foam/gui/ctrl_widgets/smart_widgets.py b/extra_foam/gui/ctrl_widgets/smart_widgets.py index 50f19ce29..223593a13 100644 --- a/extra_foam/gui/ctrl_widgets/smart_widgets.py +++ b/extra_foam/gui/ctrl_widgets/smart_widgets.py @@ -43,7 +43,7 @@ def onTextChanged(self): self._text_modified = True def setText(self, text): - """'Press enter' after setText. + """'Press enter after setText. This will remove the background color used when modified. """ diff --git a/extra_foam/gui/gui_helpers.py b/extra_foam/gui/gui_helpers.py index b5e9f6830..9a24ad3fd 100644 --- a/extra_foam/gui/gui_helpers.py +++ b/extra_foam/gui/gui_helpers.py @@ -208,3 +208,11 @@ def create_icon_button(filename, size): btn.setIconSize(QSize(size, size)) btn.setFixedSize(btn.minimumSizeHint()) return btn + + +def invert_dict(mapping): + """Return a dictionary with key and value swapped.""" + ret = dict() + for k, v in mapping.items(): + ret[v] = k + return ret diff --git a/extra_foam/gui/image_tool/image_tool.py b/extra_foam/gui/image_tool/image_tool.py index f7a92f93d..f9ea5238e 100644 --- a/extra_foam/gui/image_tool/image_tool.py +++ b/extra_foam/gui/image_tool/image_tool.py @@ -232,6 +232,11 @@ def updateMetaData(self): return False return True + def loadMetaData(self): + """Load metadata from Redis and set child control widgets.""" + for widget in self._ctrl_widgets: + widget.loadMetaData() + def reset(self): """Override.""" pass diff --git a/extra_foam/gui/image_tool/tests/test_image_tool.py b/extra_foam/gui/image_tool/tests/test_image_tool.py index 0801094b9..9f81468f5 100644 --- a/extra_foam/gui/image_tool/tests/test_image_tool.py +++ b/extra_foam/gui/image_tool/tests/test_image_tool.py @@ -199,15 +199,21 @@ def testMovingAverageQLineEdit(self): @patch("extra_foam.gui.plot_widgets.image_views.ImageAnalysis." "onThresholdMaskChange") - @patch("extra_foam.gui.mediator.Mediator.onImageThresholdMaskChange") - def testThresholdMask(self, on_mask_mediator, on_mask): + def testThresholdMask(self, on_mask): widget = self.image_tool._image_ctrl_widget - widget.threshold_mask_le.clear() - QTest.keyClicks(widget.threshold_mask_le, "1, 10") - QTest.keyPress(widget.threshold_mask_le, Qt.Key_Enter) - on_mask.assert_called_once_with((1, 10)) - on_mask_mediator.assert_called_once_with((1, 10)) + with patch.object(widget._mediator, "onImageThresholdMaskChange") as patched: + widget.threshold_mask_le.clear() + QTest.keyClicks(widget.threshold_mask_le, "1, 10") + QTest.keyPress(widget.threshold_mask_le, Qt.Key_Enter) + on_mask.assert_called_once_with((1, 10)) + patched.assert_called_once_with((1, 10)) + + # test loading meta data + mediator = widget._mediator + mediator.onImageThresholdMaskChange((-100, 10000)) + widget.loadMetaData() + self.assertEqual("-100, 10000", widget.threshold_mask_le.text()) def testAutoLevel(self): widget = self.image_tool._image_ctrl_widget @@ -470,6 +476,20 @@ def _read_constants_side_effect(fn): self.assertTrue(new_offset) self.assertIsNone(offset) + # test loading meta data + mediator = widget._mediator + mediator.onCalDarkAsOffset(True) + mediator.onCalGainCorrection(False) + mediator.onCalOffsetCorrection(False) + mediator.onCalGainSlicerChange([0, None, 2]) + mediator.onCalOffsetSlicerChange([0, None, 4]) + widget.loadMetaData() + self.assertEqual(True, widget._dark_as_offset_cb.isChecked()) + self.assertEqual(False, widget._correct_gain_cb.isChecked()) + self.assertEqual(False, widget._correct_offset_cb.isChecked()) + self.assertEqual("0::2", widget._gain_slicer_le.text()) + self.assertEqual("0::4", widget._offset_slicer_le.text()) + def testAzimuthalInteg1dCtrlWidget(self): from extra_foam.pipeline.processors.azimuthal_integration import energy2wavelength from extra_foam.gui.ctrl_widgets.azimuthal_integ_ctrl_widget import \ @@ -482,6 +502,7 @@ def testAzimuthalInteg1dCtrlWidget(self): proc.update() + # test default self.assertAlmostEqual(config['SAMPLE_DISTANCE'], proc._sample_dist) self.assertAlmostEqual(0.001 * energy2wavelength(config['PHOTON_ENERGY']), proc._wavelength) self.assertEqual(AnalysisType.UNDEFINED, proc.analysis_type) @@ -498,6 +519,7 @@ def testAzimuthalInteg1dCtrlWidget(self): self.assertEqual(0, proc._poni1) self.assertEqual(0, proc._poni2) + # test setting new values widget._photon_energy_le.setText("12.4") widget._sample_dist_le.setText("0.3") widget._integ_method_cb.setCurrentText('nosplit_csr') @@ -524,6 +546,34 @@ def testAzimuthalInteg1dCtrlWidget(self): self.assertEqual(-1000 * 0.000001, proc._poni2) self.assertEqual(1000 * 0.000002, proc._poni1) + # test loading meta data + mediator = widget._mediator + mediator.onPhotonEnergyChange("2.0") + mediator.onSampleDistanceChange("0.2") + mediator.onAiIntegMethodChange("BBox") + mediator.onAiNormChange(Normalizer.XGM) + mediator.onAiIntegPointsChange(512) + mediator.onAiIntegRangeChange((1, 2)) + mediator.onAiAucRangeChange((2, 3)) + mediator.onAiFomIntegRangeChange((3, 4)) + mediator.onAiPixelSizeXChange(0.001) + mediator.onAiPixelSizeYChange(0.002) + mediator.onAiIntegCenterXChange(1) + mediator.onAiIntegCenterYChange(2) + widget.loadMetaData() + self.assertEqual("2.0", widget._photon_energy_le.text()) + self.assertEqual("0.2", widget._sample_dist_le.text()) + self.assertEqual("BBox", widget._integ_method_cb.currentText()) + self.assertEqual("XGM", widget._norm_cb.currentText()) + self.assertEqual("512", widget._integ_pts_le.text()) + self.assertEqual("1, 2", widget._integ_range_le.text()) + self.assertEqual("2, 3", widget._auc_range_le.text()) + self.assertEqual("3, 4", widget._fom_integ_range_le.text()) + self.assertEqual("0.001", widget._px_le.text()) + self.assertEqual("0.002", widget._py_le.text()) + self.assertEqual("1", widget._cx_le.text()) + self.assertEqual("2", widget._cy_le.text()) + def testRoiFomCtrlWidget(self): widget = self.image_tool._corrected_view._roi_fom_ctrl_widget avail_norms = {value: key for key, value in widget._available_norms.items()} @@ -557,6 +607,18 @@ def testRoiFomCtrlWidget(self): widget._master_slave_cb.setChecked(False) self.assertTrue(widget._combo_cb.isEnabled()) + # test loading meta data + mediator = widget._mediator + mediator.onRoiFomComboChange(RoiCombo.ROI2) + mediator.onRoiFomTypeChange(RoiFom.MEAN) + mediator.onRoiFomNormChange(Normalizer.XGM) + mediator.onRoiFomMasterSlaveModeChange(False) + widget.loadMetaData() + self.assertEqual("ROI2", widget._combo_cb.currentText()) + self.assertEqual("MEAN", widget._type_cb.currentText()) + self.assertEqual("XGM", widget._norm_cb.currentText()) + self.assertFalse(widget._master_slave_cb.isChecked()) + def testRoiHistCtrl(self): widget = self.image_tool._corrected_view._roi_hist_ctrl_widget avail_combos = {value: key for key, value in widget._available_combos.items()} @@ -574,11 +636,19 @@ def testRoiHistCtrl(self): widget._n_bins_le.setText("100") widget._bin_range_le.setText("-1.0, 10.0") proc.update() - self.assertEqual(RoiCombo.ROI1_SUB_ROI2, proc._hist_combo) self.assertEqual(100, proc._hist_n_bins) self.assertEqual((-1.0, 10.0), proc._hist_bin_range) + mediator = widget._mediator + mediator.onRoiHistComboChange(RoiCombo.ROI2) + mediator.onRoiHistNumBinsChange(10) + mediator.onRoiHistBinRangeChange((-3, 3)) + widget.loadMetaData() + self.assertEqual("ROI2", widget._combo_cb.currentText()) + self.assertEqual("10", widget._n_bins_le.text()) + self.assertEqual("-3, 3", widget._bin_range_le.text()) + def testRoiNormCtrlWidget(self): widget = self.image_tool._corrected_view._roi_norm_ctrl_widget avail_combos = {value: key for key, value in widget._available_combos.items()} @@ -594,17 +664,23 @@ def testRoiNormCtrlWidget(self): # test setting new values widget._combo_cb.setCurrentText(avail_combos[RoiCombo.ROI3_ADD_ROI4]) widget._type_cb.setCurrentText(avail_types[RoiFom.MEDIAN]) - proc.update() - self.assertEqual(RoiCombo.ROI3_ADD_ROI4, proc._norm_combo) self.assertEqual(RoiFom.MEDIAN, proc._norm_type) + # test loading meta data + mediator = widget._mediator + mediator.onRoiNormComboChange(RoiCombo.ROI3_SUB_ROI4) + mediator.onRoiNormTypeChange(RoiProjType.SUM) + widget.loadMetaData() + self.assertEqual("ROI3 - ROI4", widget._combo_cb.currentText()) + self.assertEqual("SUM", widget._type_cb.currentText()) + def testRoiProjCtrlWidget(self): widget = self.image_tool._corrected_view._roi_proj_ctrl_widget - avail_norms = {value: key for key, value in widget._available_norms.items()} - avail_combos = {value: key for key, value in widget._available_combos.items()} - avail_types = {value: key for key, value in widget._available_types.items()} + avail_norms_inv = widget._available_norms_inv + avail_combos_inv = widget._available_combos_inv + avail_types_inv = widget._available_types_inv proc = self.train_worker._image_roi proc.update() @@ -618,10 +694,10 @@ def testRoiProjCtrlWidget(self): self.assertEqual((0, math.inf), proc._proj_auc_range) # test setting new values - widget._combo_cb.setCurrentText(avail_combos[RoiCombo.ROI1_SUB_ROI2]) - widget._type_cb.setCurrentText(avail_types[RoiProjType.MEAN]) + widget._combo_cb.setCurrentText(avail_combos_inv[RoiCombo.ROI1_SUB_ROI2]) + widget._type_cb.setCurrentText(avail_types_inv[RoiProjType.MEAN]) widget._direct_cb.setCurrentText('y') - widget._norm_cb.setCurrentText(avail_norms[Normalizer.ROI]) + widget._norm_cb.setCurrentText(avail_norms_inv[Normalizer.ROI]) widget._fom_integ_range_le.setText("10, 20") widget._auc_range_le.setText("30, 40") proc.update() @@ -632,10 +708,27 @@ def testRoiProjCtrlWidget(self): self.assertEqual((10, 20), proc._proj_fom_integ_range) self.assertEqual((30, 40), proc._proj_auc_range) + # test loading meta data + mediator = widget._mediator + mediator.onRoiProjComboChange(RoiCombo.ROI1_ADD_ROI2) + mediator.onRoiProjTypeChange(RoiProjType.SUM) + mediator.onRoiProjDirectChange('x') + mediator.onRoiProjNormChange(Normalizer.XGM) + mediator.onRoiProjAucRangeChange((1, 2)) + mediator.onRoiProjFomIntegRangeChange((-5, 5)) + widget.loadMetaData() + self.assertEqual("ROI1 + ROI2", widget._combo_cb.currentText()) + self.assertEqual("SUM", widget._type_cb.currentText()) + self.assertEqual("x", widget._direct_cb.currentText()) + self.assertEqual("XGM", widget._norm_cb.currentText()) + self.assertEqual("1, 2", widget._auc_range_le.text()) + self.assertEqual("-5, 5", widget._fom_integ_range_le.text()) + def testGeometryCtrlWidget(self): from extra_geom import LPD_1MGeometry as LPD_1MGeometry from extra_foam.geometries import LPD_1MGeometryFast from extra_foam.config import GeomAssembler + from extra_foam.gui.gui_helpers import parse_table_widget cw = self.image_tool._views_tab view = self.image_tool._geometry_view @@ -668,6 +761,19 @@ def testGeometryCtrlWidget(self): widget._geom_file_le.setText("/geometry/file/") self.assertFalse(widget.updateMetaData()) + # test loading meta data + mediator = widget._mediator + mediator.onGeomAssemblerChange(GeomAssembler.EXTRA_GEOM) + mediator.onGeomFilenameChange('geometry/new_file') + mediator.onGeomStackOnlyChange(False) + quad_positions = [[1., 2.], [3., 4.], [5., 6.], [7., 8.]] + mediator.onGeomQuadPositionsChange(quad_positions) + widget.loadMetaData() + self.assertEqual("EXtra-geom", widget._assembler_cb.currentText()) + self.assertEqual('geometry/new_file', widget._geom_file_le.text()) + self.assertFalse(widget._stack_only_cb.isChecked()) + self.assertListEqual(quad_positions, parse_table_widget((widget._quad_positions_tb))) + def testViewTabSwitching(self): tab = self.image_tool._views_tab self.assertEqual(0, tab.currentIndex()) @@ -746,6 +852,15 @@ def testCalibrationCtrlWidget(self): self.assertFalse(widget._gain_slicer_le.isEnabled()) self.assertFalse(widget._offset_slicer_le.isEnabled()) + # test loading meta data + # test if the meta data is invalid + mediator = widget._mediator + mediator.onCalGainSlicerChange([0, None, 2]) + mediator.onCalOffsetSlicerChange([0, None, 2]) + widget.loadMetaData() + self.assertEqual(":", widget._gain_slicer_le.text()) + self.assertEqual(":", widget._offset_slicer_le.text()) + if __name__ == '__main__': unittest.main() diff --git a/extra_foam/gui/main_gui.py b/extra_foam/gui/main_gui.py index 5925290d0..52a929b71 100644 --- a/extra_foam/gui/main_gui.py +++ b/extra_foam/gui/main_gui.py @@ -35,7 +35,7 @@ HistogramCtrlWidget, PumpProbeCtrlWidget, TrXasCtrlWidget, ) from .gui_helpers import create_icon_button -from .misc_widgets import GuiLogger +from .misc_widgets import Configurator, GuiLogger from .image_tool import ImageToolWindow from .windows import ( BinningWindow, CorrelationWindow, HistogramWindow, PulseOfInterestWindow, @@ -144,16 +144,6 @@ def __init__(self, pause_ev, close_ev): self._util_panel_container = QWidget() self._util_panel_cw = QTabWidget() - # ************************************************************* - # Menu bar - # ************************************************************* - # self._menu_bar = self.menuBar() - # file_menu = self._menu_bar.addMenu('&Config') - # save_cfg = QAction('Save config', self) - # file_menu.addAction(save_cfg) - # load_cfg = QAction('Load config', self) - # file_menu.addAction(load_cfg) - # ************************************************************* # Tool bar # Note: the order of 'addAction` affect the unittest!!! @@ -227,6 +217,8 @@ def __init__(self, pause_ev, close_ev): self._logger = GuiLogger(parent=self) logging.getLogger().addHandler(self._logger) + self._configurator = Configurator() + self._thread_logger = ThreadLoggerBridge() self.quit_sgn.connect(self._thread_logger.stop) self._thread_logger_t = QThread() @@ -281,6 +273,7 @@ def __init__(self, pause_ev, close_ev): self.initUI() self.initConnections() self.updateMetaData() + self._configurator.onInit() self.setMinimumSize(640, 480) self.resize(self._WIDTH, self._HEIGHT) @@ -359,6 +352,7 @@ def initSpecialAnalysisUI(self): def initUtilUI(self): self._util_panel_cw.addTab(self._logger.widget, "Logger") + self._util_panel_cw.addTab(self._configurator, "Configurator") self._util_panel_cw.setTabPosition(QTabWidget.TabPosition.South) layout = QVBoxLayout() @@ -366,7 +360,7 @@ def initUtilUI(self): self._util_panel_container.setLayout(layout) def initConnections(self): - pass + self._configurator.load_metadata_sgn.connect(self.loadMetaData) def connect_input_to_output(self, output): self._input.connect(output) @@ -516,6 +510,7 @@ def onStart(self): for widget in self._ctrl_widgets: widget.onStart() self._image_tool.onStart() + self._configurator.onStart() self._running = True # starting to update plots self._input_update_ev.set() # notify update @@ -534,6 +529,7 @@ def onStop(self): for widget in self._ctrl_widgets: widget.onStop() self._image_tool.onStop() + self._configurator.onStop() def updateMetaData(self): """Update metadata from all the ctrl widgets. @@ -547,6 +543,12 @@ def updateMetaData(self): return False return self._image_tool.updateMetaData() + def loadMetaData(self): + """Load metadata from Redis and set child control widgets.""" + for widget in self._ctrl_widgets: + widget.loadMetaData() + return self._image_tool.loadMetaData() + @pyqtSlot(str, str) def onLogMsgReceived(self, ch, msg): if ch == 'log:debug': diff --git a/extra_foam/gui/mediator.py b/extra_foam/gui/mediator.py index a00f0bf9b..1a017ba3a 100644 --- a/extra_foam/gui/mediator.py +++ b/extra_foam/gui/mediator.py @@ -68,25 +68,25 @@ def onSourceItemToggled(self, checked: bool, item: object): self._meta.remove_data_source(item) def onCalGainCorrection(self, value: bool): - self._meta.hset(mt.IMAGE_PROC, "correct gain", str(value)) + self._meta.hset(mt.IMAGE_PROC, "correct_gain", str(value)) def onCalOffsetCorrection(self, value: bool): - self._meta.hset(mt.IMAGE_PROC, "correct offset", str(value)) + self._meta.hset(mt.IMAGE_PROC, "correct_offset", str(value)) def onCalGainSlicerChange(self, value: list): - self._meta.hset(mt.IMAGE_PROC, "gain slicer", str(value)) + self._meta.hset(mt.IMAGE_PROC, "gain_slicer", str(value)) def onCalOffsetSlicerChange(self, value: list): - self._meta.hset(mt.IMAGE_PROC, "offset slicer", str(value)) + self._meta.hset(mt.IMAGE_PROC, "offset_slicer", str(value)) def onCalDarkAsOffset(self, value:bool): - self._meta.hset(mt.IMAGE_PROC, "dark as offset", str(value)) + self._meta.hset(mt.IMAGE_PROC, "dark_as_offset", str(value)) def onCalDarkRecording(self, value: bool): - self._meta.hset(mt.IMAGE_PROC, "recording dark", str(value)) + self._meta.hset(mt.IMAGE_PROC, "recording_dark", str(value)) def onCalDarkRemove(self): - self._meta.hset(mt.IMAGE_PROC, "remove dark", 1) + self._meta.hset(mt.IMAGE_PROC, "remove_dark", 1) def onImageThresholdMaskChange(self, value: tuple): self._meta.hset(mt.IMAGE_PROC, "threshold_mask", str(value)) @@ -144,10 +144,10 @@ def onAiIntegPointsChange(self, value: int): def onAiIntegRangeChange(self, value: tuple): self._meta.hset(mt.AZIMUTHAL_INTEG_PROC, 'integ_range', str(value)) - def onCurveNormalizerChange(self, value: IntEnum): + def onAiNormChange(self, value: IntEnum): self._meta.hset(mt.AZIMUTHAL_INTEG_PROC, 'normalizer', int(value)) - def onAiAucChangeChange(self, value: tuple): + def onAiAucRangeChange(self, value: tuple): self._meta.hset(mt.AZIMUTHAL_INTEG_PROC, 'auc_range', str(value)) def onAiFomIntegRangeChange(self, value: tuple): diff --git a/extra_foam/gui/misc_widgets/__init__.py b/extra_foam/gui/misc_widgets/__init__.py index 02ec64d25..b1add9350 100644 --- a/extra_foam/gui/misc_widgets/__init__.py +++ b/extra_foam/gui/misc_widgets/__init__.py @@ -2,3 +2,4 @@ FColor, SequentialColor, colorMapFactory ) from .gui_logger import GuiLogger, InputDialogWithCheckBox +from .configurator import Configurator diff --git a/extra_foam/gui/misc_widgets/configurator.py b/extra_foam/gui/misc_widgets/configurator.py new file mode 100644 index 000000000..1e569cc4a --- /dev/null +++ b/extra_foam/gui/misc_widgets/configurator.py @@ -0,0 +1,292 @@ +""" +Distributed under the terms of the BSD 3-Clause License. + +The full license is in the file LICENSE, distributed with this software. + +Author: Jun Zhu +Copyright (C) European X-Ray Free-Electron Laser Facility GmbH. +All rights reserved. +""" +from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal +from PyQt5.QtWidgets import ( + QGridLayout, QHeaderView, QInputDialog, QLineEdit, QMenu, QMessageBox, + QPushButton, QTableWidget, QTableWidgetItem, QWidget, +) + +from ...database import MetaProxy +from ...logger import logger + + +class Configurator(QWidget): + + load_metadata_sgn = pyqtSignal() + + DEFAULT = "default" + LAST_SAVED = "Last saved" + + def __init__(self): + super().__init__() + + self._table = QTableWidget() + self._table.setContextMenuPolicy(Qt.CustomContextMenu) + + self._snapshot_btn = QPushButton("Take snapshot") + self._reset_btn = QPushButton("Reset to default") + self._save_cfg_btn = QPushButton("Save setups in file") + self._load_cfg_btn = QPushButton("Load setups from file") + + self._meta = MetaProxy() + self._config = dict() # key: name, value: # of row + + self.initUI() + self.initConnections() + + def initUI(self): + table = self._table + table.setColumnCount(3) + table.setHorizontalHeaderLabels(['Name', 'Timestamp', 'Description']) + + header = table.horizontalHeader() + header.setSectionResizeMode(QHeaderView.Interactive) + header.setSectionResizeMode(1, QHeaderView.ResizeToContents) + header.setSectionResizeMode(2, QHeaderView.Stretch) + + layout = QGridLayout() + layout.addWidget(table, 0, 0, 1, 4) + layout.addWidget(self._snapshot_btn, 1, 0) + layout.addWidget(self._reset_btn, 1, 1) + layout.addWidget(self._save_cfg_btn, 1, 2) + layout.addWidget(self._load_cfg_btn, 1, 3) + self.setLayout(layout) + + def initConnections(self): + self._snapshot_btn.clicked.connect(lambda x: self._takeSnapshot()) + self._reset_btn.clicked.connect(self._resetToDefault) + self._table.itemDoubleClicked.connect(self.onItemDoubleClicked) + self._table.customContextMenuRequested.connect(self.showContextMenu) + self._save_cfg_btn.clicked.connect(self._askSaveConfiguration) + self._load_cfg_btn.clicked.connect(self._askLoadConfiguration) + + def onInit(self): + """Called by the MainGUI on initialization.""" + # save a default snapshot + self._meta.copy_snapshot(None, self.DEFAULT) + self._loadConfigurations() + + def onStart(self): + self._table.setDisabled(True) + self._snapshot_btn.setDisabled(True) + self._reset_btn.setDisabled(True) + self._save_cfg_btn.setDisabled(True) + self._load_cfg_btn.setDisabled(True) + + def onStop(self): + self._table.setEnabled(True) + self._snapshot_btn.setEnabled(True) + self._reset_btn.setEnabled(True) + self._save_cfg_btn.setEnabled(True) + self._load_cfg_btn.setEnabled(True) + + def _insertConfigurationToList(self, cfg, row=None): + """Insert a row for the new configuration. + + Note: this method does not involve any database operation. + + :param list/tuple cfg: (name, timestamp, description) of the + configuration. + """ + table = self._table + + if cfg[0] == self.LAST_SAVED: + row = 0 + elif row is None: + row = table.rowCount() + + table.insertRow(row) + for col, text in zip(range(table.columnCount()), cfg): + item = QTableWidgetItem() + table.setItem(row, col, item) + flag = Qt.ItemIsEnabled + if cfg[0] != self.LAST_SAVED and col == 2: + flag |= Qt.ItemIsEditable + item.setFlags(flag) + try: + item.setText(text) + except TypeError: + # I hope it will not happen in real life. + item.setText(" ") + logger.error(f"TypeError: Invalid value {text} for column " + f"{col+1} in Configurator!") + + if row != len(self._config): + # adjust the row indices for the other configurations + for k, v in self._config.items(): + if v >= row: + self._config[k] += 1 + + self._config[cfg[0]] = row + + def _removeConfigurationFromList(self, name): + """Remove a row from the table and configuration list. + + :param str name: name of the configuration. + """ + row = self._config[name] + del self._config[name] + self._table.removeRow(row) + + if row != len(self._config): + # adjust the row indices for the other configurations + for k, v in self._config.items(): + if v > row: + self._config[k] -= 1 + + def _copyConfiguration(self, row, new_name): + """Copy a row and insert the new one at the end of the table.""" + cfg = [self._table.item(row, i).text() + for i in range(self._table.columnCount())] + + self._meta.copy_snapshot(cfg[0], new_name) + cfg[0] = new_name + self._insertConfigurationToList(cfg) + + def _removeConfiguration(self, row): + """Remove a row of configuration.""" + table = self._table + name = table.item(row, 0).text() + + self._meta.remove_snapshot(name) + self._removeConfigurationFromList(name) + + def _renameConfiguration(self, row, new_name): + """Rename a row of configuration.""" + table = self._table + name = table.item(row, 0).text() + + self._meta.rename_snapshot(name, new_name) + + del self._config[name] + self._config[new_name] = row + table.item(row, 0).setText(new_name) + + def _askSaveConfiguration(self): + reply = QMessageBox.question( + self, "Save configurations", + "Setups in the file will be lost. Continue?", + QMessageBox.Yes | QMessageBox.No) + + if reply == QMessageBox.Yes: + self._saveConfigurations() + + def _saveConfigurations(self): + """Save all configurations to file.""" + table = self._table + sorted_config = sorted(self._config.items(), key=lambda x: x[1]) + lst = [] + for name, row in sorted_config: + # "description" was not saved in Redis when edited in the table + lst.append((name, table.item(row, 2).text())) + self._meta.dump_configurations(lst) + + def _askLoadConfiguration(self): + reply = QMessageBox.question( + self, "Load configurations", + "Current snapshots will be overwritten in case of name conflict. " + "Continue?", + QMessageBox.Yes | QMessageBox.No) + + if reply == QMessageBox.Yes: + self._loadConfigurations() + + def _loadConfigurations(self): + """Load configurations from file.""" + cfg_list = self._meta.load_configurations() + + for cfg in cfg_list: + # 'self._meta.load_configurations' has already + # write all the configurations into Redis. + if cfg[0] in self._config: + self._removeConfigurationFromList(cfg[0]) + + for cfg in cfg_list: + self._insertConfigurationToList(cfg) + + if self.LAST_SAVED not in self._config: + self._takeSnapshot() + + def _takeSnapshot(self): + """Take a snapshot of the current configuration.""" + table = self._table + cfg = self._meta.take_snapshot(self.LAST_SAVED) + + if table.rowCount() == 0 or table.item(0, 0).text() != self.LAST_SAVED: + self._insertConfigurationToList(cfg, 0) + else: + for i, text in zip(range(table.columnCount()), cfg): + table.item(0, i).setText(text) + + def _resetToDefault(self): + self._meta.load_snapshot(self.DEFAULT) + self.load_metadata_sgn.emit() + + def onItemDoubleClicked(self, item): + """Double-click the name to set the configuration.""" + if self._table.column(item) < 2: + self._meta.load_snapshot(item.text()) + self.load_metadata_sgn.emit() + + def showContextMenu(self, pos): + table = self._table + item = table.itemAt(pos) + if table.column(item) != 0: + # show context menu only when right-clicking on the name + return + + row = self._table.row(item) + menu = QMenu() + copy_action = menu.addAction("Copy snapshot") + if row != 0: + # The first one is always "Last saved" and is not allowed + # to be deleted + delete_action = menu.addAction("Delete snapshot") + rename_action = menu.addAction("Rename snapshot") + + action = menu.exec_(self.mapToGlobal(pos)) + if action == copy_action: + new_name, ok = QInputDialog.getText( + self, "", "New name: ", QLineEdit.Normal, "") + + if not self._checkConfigName(new_name): + return + + if ok: + self._copyConfiguration(row, new_name) + + elif row != 0: + if action == delete_action: + self._removeConfiguration(row) + + elif action == rename_action: + new_name, ok = QInputDialog.getText( + self, "", "New name: ", QLineEdit.Normal, "") + + if not self._checkConfigName(new_name): + return + + if ok: + self._renameConfiguration(row, new_name) + + def _checkConfigName(self, name): + if name in self._config: + logger.error(f"Configuration '{name}' already exists!") + return False + + if name in [self.DEFAULT, self.LAST_SAVED]: + logger.error( + f"'{name}' is not allowed for user-defined configuration!") + return False + + if name == '': + return False + + return True diff --git a/extra_foam/gui/misc_widgets/tests/test_configurator.py b/extra_foam/gui/misc_widgets/tests/test_configurator.py new file mode 100644 index 000000000..5c7b2d3b3 --- /dev/null +++ b/extra_foam/gui/misc_widgets/tests/test_configurator.py @@ -0,0 +1,133 @@ +import unittest +from unittest.mock import MagicMock, patch + +from PyQt5.QtCore import Qt +from PyQt5.QtTest import QSignalSpy, QTest +from PyQt5.QtWidgets import QMessageBox + +from extra_foam.gui import mkQApp +from extra_foam.gui.misc_widgets.configurator import Configurator + +app = mkQApp() + + +class TestConfigurator(unittest.TestCase): + def setUp(self): + self._widget = Configurator() + self._widget._meta = MagicMock() + self._widget._meta.take_snapshot.return_value = ( + self._widget.LAST_SAVED, "2020-01-01 01:01:01", "") + self._widget.onInit() + + def testAddCopyRemoveConfiguration(self): + widget = self._widget + table = widget._table + + self.assertEqual(1, table.rowCount()) + self.assertEqual(widget.LAST_SAVED, table.item(0, 0).text()) + self.assertEqual("2020-01-01 01:01:01", table.item(0, 1).text()) + self.assertEqual("", table.item(0, 2).text()) + + # test add + + cfg = ["abc", "2020-02-02 02:02:02", "abc setup"] + widget._insertConfigurationToList(cfg) + self.assertEqual("abc", table.item(1, 0).text()) + self.assertEqual("2020-02-02 02:02:02", table.item(1, 1).text()) + self.assertEqual("abc setup", table.item(1, 2).text()) + self.assertDictEqual({widget.LAST_SAVED: 0, 'abc': 1}, widget._config) + + # test copy + + widget._copyConfiguration(1, "efg") + self.assertDictEqual({widget.LAST_SAVED: 0, 'abc': 1, 'efg': 2}, widget._config) + + # test remove + + widget._removeConfiguration(1) + self.assertEqual("efg", table.item(1, 0).text()) + self.assertEqual("2020-02-02 02:02:02", table.item(1, 1).text()) + self.assertEqual("abc setup", table.item(1, 2).text()) + self.assertDictEqual({widget.LAST_SAVED: 0, 'efg': 1}, widget._config) + self._widget._meta.remove_snapshot.assert_called_with('abc') + + widget._removeConfiguration(1) + self.assertDictEqual({widget.LAST_SAVED: 0}, widget._config) + self._widget._meta.remove_snapshot.assert_called_with('efg') + + def testRenameConfiguration(self): + widget = self._widget + table = widget._table + + cfg = ["efg", "2020-03-03 03:03:03", "efg setup"] + widget._insertConfigurationToList(cfg) + widget._renameConfiguration(1, "abc") + widget._meta.rename_snapshot.assert_called_with('efg', 'abc') + self.assertEqual("abc", table.item(1, 0).text()) + self.assertDictEqual({widget.LAST_SAVED: 0, 'abc': 1}, widget._config) + + def testSetConfiguration(self): + widget = self._widget + table = widget._table + spy = QSignalSpy(widget.load_metadata_sgn) + spy_count = 0 + + cfg = ["efg", "2020-03-03 03:03:03", "efg setup"] + widget._insertConfigurationToList(cfg) + + for row in range(2): + for i in range(2): + table.itemDoubleClicked.emit(table.item(row, i)) + widget._meta.load_snapshot.assert_called_with(table.item(row, i).text()) + widget._meta.load_snapshot.reset_mock() + spy_count += 1 + self.assertEqual(spy_count, len(spy)) + table.itemDoubleClicked.emit(table.item(row, 2)) + widget._meta.load_snapshot.assert_not_called() + self.assertEqual(spy_count, len(spy)) + + # test reset + spy = QSignalSpy(widget.load_metadata_sgn) + widget._reset_btn.clicked.emit() + widget._meta.load_snapshot.assert_called_with(widget.DEFAULT) + self.assertEqual(1, len(spy)) + + def testSaveLoad(self): + widget = self._widget + table = widget._table + + widget._insertConfigurationToList(["abc", "2020-02-02 02:02:02", "abc setup"]) + widget._insertConfigurationToList(["efg", "2020-03-03 03:03:03", "efg setup"]) + + with patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QMessageBox.No): + widget._save_cfg_btn.clicked.emit() + widget._meta.dump_configurations.assert_not_called() + + with patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QMessageBox.Yes): + widget._save_cfg_btn.clicked.emit() + widget._meta.dump_configurations.assert_called_with( + [(widget.LAST_SAVED, ""), ("abc", "abc setup"), ("efg", "efg setup")]) + + with patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QMessageBox.No): + widget._meta.load_configurations.reset_mock() + widget._load_cfg_btn.clicked.emit() + widget._meta.load_configurations.assert_not_called() + + with patch("PyQt5.QtWidgets.QMessageBox.question", return_value=QMessageBox.Yes): + widget._meta.load_configurations.return_value = [ + (widget.LAST_SAVED, "2020-02-13 04:04:04", ""), + ("abc", "2020-02-12 02:02:02", "abc setup 1"), + ("efg", "2020-03-13 03:03:03", "efg setup 1") + ] + widget._load_cfg_btn.clicked.emit() + widget._meta.load_configurations.assert_called_once() + + self.assertEqual(3, table.rowCount()) + self.assertEqual(widget.LAST_SAVED, table.item(0, 0).text()) + self.assertEqual("2020-02-13 04:04:04", table.item(0, 1).text()) + self.assertEqual("abc", table.item(1, 0).text()) + self.assertEqual("2020-02-12 02:02:02", table.item(1, 1).text()) + self.assertEqual("abc setup 1", table.item(1, 2).text()) + self.assertEqual("efg", table.item(2, 0).text()) + self.assertEqual("2020-03-13 03:03:03", table.item(2, 1).text()) + self.assertEqual("efg setup 1", table.item(2, 2).text()) diff --git a/extra_foam/gui/tests/test_main_gui.py b/extra_foam/gui/tests/test_main_gui.py index 4a0bd21f2..9b4cc4ff1 100644 --- a/extra_foam/gui/tests/test_main_gui.py +++ b/extra_foam/gui/tests/test_main_gui.py @@ -122,6 +122,16 @@ def testAnalysisCtrlWidget(self): self.assertEqual(index, win._poi_roi_hists[i]._index) win.close() + # test loading meta data + mediator = widget._mediator + mediator.onMaWindowChange(111) + mediator.onPoiIndexChange(0, 22) + mediator.onPoiIndexChange(1, 33) + widget.loadMetaData() + self.assertEqual("111", widget._ma_window_le.text()) + self.assertEqual("22", widget._poi_index_les[0].text()) + self.assertEqual("33", widget._poi_index_les[1].text()) + def testPumpProbeCtrlWidget(self): widget = self.gui.pump_probe_ctrl_widget pp_proc = self.pulse_worker._pp_proc @@ -184,6 +194,16 @@ def testPumpProbeCtrlWidget(self): pp_proc.update() self.assertTrue(pp_proc._reset) + # test loading meta data + mediator = widget._mediator + mediator.onPpAnalysisTypeChange(AnalysisType.AZIMUTHAL_INTEG) + mediator.onPpModeChange(PumpProbeMode.ODD_TRAIN_ON) + mediator.onPpAbsDifferenceChange(True) + widget.loadMetaData() + self.assertEqual("azimuthal integ", widget._analysis_type_cb.currentText()) + self.assertEqual("odd/even train", widget._mode_cb.currentText()) + self.assertEqual(True, widget._abs_difference_cb.isChecked()) + def testDataSourceWidget(self): from extra_foam.gui.ctrl_widgets.data_source_widget import DataSourceWidget @@ -228,6 +248,16 @@ def testFomFilterCtrlWidget(self): self.assertEqual(AnalysisType.ROI_FOM, filter_train.analysis_type) self.assertTupleEqual((-2, 2), filter_train._fom_range) + # test loading meta data + mediator = widget._mediator + mediator.onFomFilterAnalysisTypeChange(AnalysisType.UNDEFINED) + mediator.onFomFilterRangeChange((-10, 10)) + mediator.onFomFilterPulseResolvedChange(True) + widget.loadMetaData() + self.assertEqual("", widget._analysis_type_cb.currentText()) + self.assertEqual("-10, 10", widget._fom_range_le.text()) + self.assertEqual(True, widget._pulse_resolved_cb.isChecked()) + def testCorrelationCtrlWidget(self): from extra_foam.gui.ctrl_widgets.correlation_ctrl_widget import ( _N_PARAMS, _DEFAULT_RESOLUTION) @@ -457,6 +487,18 @@ def testHistogramCtrlWidget(self): proc.update() self.assertTrue(proc._reset) + # test loading meta data + mediator = widget._mediator + mediator.onHistAnalysisTypeChange(AnalysisType.UNDEFINED) + mediator.onHistBinRangeChange((-10, 10)) + mediator.onHistNumBinsChange(55) + mediator.onHistPulseResolvedChange(True) + widget.loadMetaData() + self.assertEqual("", widget._analysis_type_cb.currentText()) + self.assertEqual("-10, 10", widget._bin_range_le.text()) + self.assertEqual("55", widget._n_bins_le.text()) + self.assertEqual(True, widget._pulse_resolved_cb.isChecked()) + @patch('extra_foam.gui.ctrl_widgets.PumpProbeCtrlWidget.' 'updateMetaData', MagicMock(return_value=True)) @patch('extra_foam.gui.ctrl_widgets.HistogramCtrlWidget.' @@ -708,6 +750,15 @@ def testAnalysisCtrlWidget(self): self.assertFalse(widget._poi_index_les[i].isEnabled()) self.assertEqual(0, idx) # test default values + # test loading meta data + # Test if the meta data is invalid. + mediator = widget._mediator + mediator.onPoiIndexChange(0, 22) + mediator.onPoiIndexChange(1, 33) + widget.loadMetaData() + self.assertEqual("0", widget._poi_index_les[0].text()) + self.assertEqual("0", widget._poi_index_les[1].text()) + def testPumpProbeCtrlWidget(self): widget = self.gui.pump_probe_ctrl_widget pp_proc = self.pulse_worker._pp_proc @@ -745,16 +796,20 @@ def testPumpProbeCtrlWidget(self): widget._mode_cb.setCurrentText(all_modes[PumpProbeMode.SAME_TRAIN]) self.assertEqual(2, len(spy)) + mediator = widget._mediator + mediator.onPpModeChange(PumpProbeMode.SAME_TRAIN) + widget.loadMetaData() + self.assertEqual("", widget._mode_cb.currentText()) + def testFomFilterCtrlWidget(self): widget = self.gui.fom_filter_ctrl_widget filter_pulse = self.pulse_worker._filter filter_train = self.train_worker._filter - analysis_types = {value: key for key, value in widget._analysis_types.items()} - # test default self.assertFalse(widget._pulse_resolved_cb.isChecked()) + self.assertFalse(widget._pulse_resolved_cb.isEnabled()) filter_pulse.update() self.assertEqual(AnalysisType.UNDEFINED, filter_pulse.analysis_type) @@ -765,7 +820,7 @@ def testFomFilterCtrlWidget(self): # test set new - widget._analysis_type_cb.setCurrentText(analysis_types[AnalysisType.ROI_FOM]) + widget._analysis_type_cb.setCurrentText(widget._analysis_types_inv[AnalysisType.ROI_FOM]) widget._fom_range_le.setText("-2, 2") filter_pulse.update() self.assertEqual(AnalysisType.UNDEFINED, filter_pulse.analysis_type) @@ -774,6 +829,24 @@ def testFomFilterCtrlWidget(self): self.assertEqual(AnalysisType.ROI_FOM, filter_train.analysis_type) self.assertTupleEqual((-2, 2), filter_train._fom_range) + # test loading meta data + # test if the meta data is invalid + mediator = widget._mediator + mediator.onFomFilterPulseResolvedChange(True) + widget.loadMetaData() + self.assertEqual(False, widget._pulse_resolved_cb.isChecked()) + def testHistogramCtrlWidget(self): - # TODO - pass + widget = self.gui.histogram_ctrl_widget + + # test default + + self.assertFalse(widget._pulse_resolved_cb.isChecked()) + self.assertFalse(widget._pulse_resolved_cb.isEnabled()) + + # test loading meta data + # test if the meta data is invalid + mediator = widget._mediator + mediator.onHistPulseResolvedChange(True) + widget.loadMetaData() + self.assertEqual(False, widget._pulse_resolved_cb.isChecked()) diff --git a/extra_foam/pipeline/processors/image_processor.py b/extra_foam/pipeline/processors/image_processor.py index 1eb024c54..901cb00dc 100644 --- a/extra_foam/pipeline/processors/image_processor.py +++ b/extra_foam/pipeline/processors/image_processor.py @@ -93,26 +93,26 @@ def update(self): # image cfg = self._meta.hget_all(mt.IMAGE_PROC) - self._correct_gain = cfg['correct gain'] == 'True' - self._correct_offset = cfg['correct offset'] == 'True' + self._correct_gain = cfg['correct_gain'] == 'True' + self._correct_offset = cfg['correct_offset'] == 'True' - gain_slicer = self.str2slice(cfg['gain slicer']) + gain_slicer = self.str2slice(cfg['gain_slicer']) if gain_slicer != self._gain_slicer: self._compute_gain_mean = True self._gain_slicer = gain_slicer - offset_slicer = self.str2slice(cfg['offset slicer']) + offset_slicer = self.str2slice(cfg['offset_slicer']) if offset_slicer != self._offset_slicer: self._compute_offset_mean = True self._offset_slicer = offset_slicer - dark_as_offset = cfg['dark as offset'] == 'True' + dark_as_offset = cfg['dark_as_offset'] == 'True' if dark_as_offset != self._dark_as_offset: self._compute_offset_mean = True self._dark_as_offset = dark_as_offset - self._recording_dark = cfg['recording dark'] == 'True' - if 'remove dark' in cfg: - self._meta.hdel(mt.IMAGE_PROC, 'remove dark') + self._recording_dark = cfg['recording_dark'] == 'True' + if 'remove_dark' in cfg: + self._meta.hdel(mt.IMAGE_PROC, 'remove_dark') del self._dark self._dark_mean = None diff --git a/extra_foam/pipeline/processors/tests/test_histogram.py b/extra_foam/pipeline/processors/tests/test_histogram.py index fa0270429..0b59f7be6 100644 --- a/extra_foam/pipeline/processors/tests/test_histogram.py +++ b/extra_foam/pipeline/processors/tests/test_histogram.py @@ -24,6 +24,7 @@ 'ROI': AnalysisType.ROI_FOM } + class TestHistogramProcessor(_TestDataMixin): @pytest.fixture(autouse=True) def setUp(self): From 6f1eb95b907320c33583b4568383120834e90245 Mon Sep 17 00:00:00 2001 From: zhujun98 Date: Sat, 7 Mar 2020 22:47:22 +0100 Subject: [PATCH 15/23] Change PumpProbe indices to slice --- .../ctrl_widgets/pump_probe_ctrl_widget.py | 22 +++---- extra_foam/gui/gui_helpers.py | 40 +++++++++++++ extra_foam/gui/mediator.py | 8 +-- extra_foam/gui/tests/test_helper_functions.py | 47 ++++++++++----- extra_foam/gui/tests/test_main_gui.py | 28 +++++---- extra_foam/pipeline/processors/pump_probe.py | 55 ++++++------------ .../processors/tests/test_pump_probe.py | 58 ++++++------------- 7 files changed, 141 insertions(+), 117 deletions(-) diff --git a/extra_foam/gui/ctrl_widgets/pump_probe_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/pump_probe_ctrl_widget.py index 7e5657e5d..42c9d41fb 100644 --- a/extra_foam/gui/ctrl_widgets/pump_probe_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/pump_probe_ctrl_widget.py @@ -15,8 +15,8 @@ ) from .base_ctrl_widgets import _AbstractGroupBoxCtrlWidget -from .smart_widgets import SmartIdLineEdit -from ..gui_helpers import invert_dict +from .smart_widgets import SmartSliceLineEdit +from ..gui_helpers import invert_dict, parse_slice_inv from ...config import PumpProbeMode, AnalysisType from ...database import Metadata as mt @@ -46,8 +46,8 @@ def __init__(self, *args, **kwargs): self._mode_cb = QComboBox() - self._on_pulse_le = SmartIdLineEdit(":") - self._off_pulse_le = SmartIdLineEdit(":") + self._on_pulse_le = SmartSliceLineEdit(":") + self._off_pulse_le = SmartSliceLineEdit(":") all_keys = list(self._available_modes.keys()) if self._pulse_resolved: @@ -109,10 +109,9 @@ def initConnections(self): lambda x: self.onPpModeChange(self._available_modes[x])) self._on_pulse_le.value_changed_sgn.connect( - mediator.onPpOnPulseIdsChange) - + mediator.onPpOnPulseSlicerChange) self._off_pulse_le.value_changed_sgn.connect( - mediator.onPpOffPulseIdsChange) + mediator.onPpOffPulseSlicerChange) def updateMetaData(self): """Override""" @@ -143,9 +142,12 @@ def loadMetaData(self): self._available_modes_inv[int(cfg["mode"])]) self._abs_difference_cb.setChecked(cfg["abs_difference"] == 'True') - # FIXME: - # self._on_pulse_le.setText(cfg["on_pulse_indices"]) - # self._off_pulse_le.setText(cfg["on_pulse_indices"]) + + if self._pulse_resolved: + self._on_pulse_le.setText( + parse_slice_inv(cfg["on_pulse_slicer"])) + self._off_pulse_le.setText( + parse_slice_inv(cfg["off_pulse_slicer"])) def onPpModeChange(self, pp_mode): if not self._pulse_resolved: diff --git a/extra_foam/gui/gui_helpers.py b/extra_foam/gui/gui_helpers.py index 9a24ad3fd..49970d9f4 100644 --- a/extra_foam/gui/gui_helpers.py +++ b/extra_foam/gui/gui_helpers.py @@ -195,6 +195,46 @@ def parse_slice(text): raise ValueError(err_msg) +def parse_slice_inv(text): + """Parse a string into a slice notation. + + This function inverts the result from 'parse_slice'. + + :param str text: the input string. + + :return str: the slice notation. + + :raise ValueError + + Examples: + + parse_slice_inv('[None, None]') == ":" + parse_slice_inv('[1, 2]') == "1:2" + parse_slice_inv('[0, 10, 2]') == "0:10:2" + """ + err_msg = f"Failed to convert '{text}' to a slice notation." + + if len(text) > 1: + try: + parts = [None if v.strip() == 'None' else int(v) + for v in text[1:-1].split(',')] + except ValueError: + raise ValueError(err_msg) + + if len(parts) == 2: + s0 = '' if parts[0] is None else str(parts[0]) + s1 = '' if parts[1] is None else str(parts[1]) + return f"{s0}:{s1}" + + if len(parts) == 3: + s0 = '' if parts[0] is None else str(parts[0]) + s1 = '' if parts[1] is None else str(parts[1]) + s2 = '' if parts[2] is None else str(parts[2]) + return f"{s0}:{s1}:{s2}" + + raise ValueError(err_msg) + + def create_icon_button(filename, size): """Create a QPushButton with icon. diff --git a/extra_foam/gui/mediator.py b/extra_foam/gui/mediator.py index 1a017ba3a..f9f6b70d4 100644 --- a/extra_foam/gui/mediator.py +++ b/extra_foam/gui/mediator.py @@ -156,11 +156,11 @@ def onAiFomIntegRangeChange(self, value: tuple): def onPpModeChange(self, value: IntEnum): self._meta.hset(mt.PUMP_PROBE_PROC, 'mode', int(value)) - def onPpOnPulseIdsChange(self, value: list): - self._meta.hset(mt.PUMP_PROBE_PROC, 'on_pulse_indices', str(value)) + def onPpOnPulseSlicerChange(self, value: list): + self._meta.hset(mt.PUMP_PROBE_PROC, 'on_pulse_slicer', str(value)) - def onPpOffPulseIdsChange(self, value: list): - self._meta.hset(mt.PUMP_PROBE_PROC, 'off_pulse_indices', str(value)) + def onPpOffPulseSlicerChange(self, value: list): + self._meta.hset(mt.PUMP_PROBE_PROC, 'off_pulse_slicer', str(value)) def onPpAnalysisTypeChange(self, value: IntEnum): self._meta.hset(mt.PUMP_PROBE_PROC, 'analysis_type', int(value)) diff --git a/extra_foam/gui/tests/test_helper_functions.py b/extra_foam/gui/tests/test_helper_functions.py index 96f5d5e34..7cddb14c5 100644 --- a/extra_foam/gui/tests/test_helper_functions.py +++ b/extra_foam/gui/tests/test_helper_functions.py @@ -3,7 +3,9 @@ import numpy as np -from extra_foam.gui.gui_helpers import parse_boundary, parse_id, parse_slice +from extra_foam.gui.gui_helpers import ( + parse_boundary, parse_id, parse_slice, parse_slice_inv +) class TestGUI(unittest.TestCase): @@ -62,24 +64,39 @@ def test_parseslice(self): with self.assertRaises(ValueError): parse_slice("1:5:2:1") - self.assertEqual(slice(2), slice(*parse_slice('2'))) - self.assertEqual(slice(2, 3), slice(*parse_slice('2:3'))) - self.assertEqual(slice(-3, -1), slice(*parse_slice('-3:-1'))) - self.assertEqual(slice(None), slice(*parse_slice(":"))) - self.assertEqual(slice(2, None), slice(*parse_slice("2:"))) - self.assertEqual(slice(None, 3), slice(*parse_slice(':3'))) - self.assertEqual(slice(1, 4, 2), slice(*parse_slice('1:4:2'))) + self.assertListEqual([None, 2], parse_slice('2')) + self.assertListEqual([None, 2], parse_slice(':2')) + self.assertListEqual([2, 3], parse_slice('2:3')) + self.assertListEqual([-3, -1], parse_slice('-3:-1')) + self.assertListEqual([None, None], parse_slice(":")) + self.assertListEqual([2, None], parse_slice("2:")) + self.assertListEqual([None, 3], parse_slice(':3')) + self.assertListEqual([1, 4, 2], parse_slice('1:4:2')) # input with space in between - self.assertEqual(slice(1, 4, 2), slice(*parse_slice(' 1 : 4 : 2 '))) - self.assertEqual(slice(1, -4, 2), slice(*parse_slice('1:-4:2'))) - self.assertEqual(slice(2, None, 4), slice(*parse_slice('2::4'))) - self.assertEqual(slice(1, 3, None), slice(*parse_slice('1:3:'))) - self.assertEqual(slice(None, None, 4), slice(*parse_slice('::4'))) - self.assertEqual(slice(2, None, None), slice(*parse_slice('2::'))) - self.assertEqual(slice(None, None), slice(*parse_slice('::'))) + self.assertListEqual([1, 4, 2], parse_slice(' 1 : 4 : 2 ')) + self.assertListEqual([1, -4, 2], parse_slice('1:-4:2')) + self.assertListEqual([2, None, 4], parse_slice('2::4')) + self.assertListEqual([1, 3, None], parse_slice('1:3:')) + self.assertListEqual([None, None, 4], parse_slice('::4')) + self.assertListEqual([2, None, None], parse_slice('2::')) + self.assertListEqual([None, None, None], parse_slice('::')) with self.assertRaises(ValueError): parse_slice('2.0') with self.assertRaises(ValueError): parse_slice('2:3.0:2.0') + + def testParseSliceInv(self): + with self.assertRaises(ValueError): + parse_slice_inv("") + + with self.assertRaises(ValueError): + parse_slice_inv(":::") + + with self.assertRaises(ValueError): + parse_slice_inv("1:a") + + for s in [':2', '2:3', '-3:-1', ":", "2:", ':3', '1:4:2', + '1:-4:2', '2::4', '1:3:', '::4', '2::', '::']: + self.assertEqual(s, parse_slice_inv(str(parse_slice(s)))) diff --git a/extra_foam/gui/tests/test_main_gui.py b/extra_foam/gui/tests/test_main_gui.py index 9b4cc4ff1..f374e41a9 100644 --- a/extra_foam/gui/tests/test_main_gui.py +++ b/extra_foam/gui/tests/test_main_gui.py @@ -146,10 +146,8 @@ def testPumpProbeCtrlWidget(self): pp_proc.update() self.assertEqual(PumpProbeMode.UNDEFINED, pp_proc._mode) - self.assertListEqual([-1], pp_proc._indices_on) - self.assertIsInstance(pp_proc._indices_on[0], int) - self.assertListEqual([-1], pp_proc._indices_off) - self.assertIsInstance(pp_proc._indices_off[0], int) + self.assertEqual(slice(None, None), pp_proc._indices_on) + self.assertEqual(slice(None, None), pp_proc._indices_off) # change analysis type pp_proc._reset = False @@ -185,8 +183,8 @@ def testPumpProbeCtrlWidget(self): widget._off_pulse_le.setText('1:10:2') pp_proc.update() self.assertEqual(PumpProbeMode.EVEN_TRAIN_ON, pp_proc._mode) - self.assertListEqual([0, 2, 4, 6, 8], pp_proc._indices_on) - self.assertListEqual([1, 3, 5, 7, 9], pp_proc._indices_off) + self.assertEqual(slice(0, 10, 2), pp_proc._indices_on) + self.assertEqual(slice(1, 10, 2), pp_proc._indices_off) # test reset button pp_proc._reset = False @@ -198,11 +196,15 @@ def testPumpProbeCtrlWidget(self): mediator = widget._mediator mediator.onPpAnalysisTypeChange(AnalysisType.AZIMUTHAL_INTEG) mediator.onPpModeChange(PumpProbeMode.ODD_TRAIN_ON) + mediator.onPpOnPulseSlicerChange([0, None, 2]) + mediator.onPpOffPulseSlicerChange([1, None, 2]) mediator.onPpAbsDifferenceChange(True) widget.loadMetaData() self.assertEqual("azimuthal integ", widget._analysis_type_cb.currentText()) self.assertEqual("odd/even train", widget._mode_cb.currentText()) self.assertEqual(True, widget._abs_difference_cb.isChecked()) + self.assertEqual("0::2", widget._on_pulse_le.text()) + self.assertEqual("1::2", widget._off_pulse_le.text()) def testDataSourceWidget(self): from extra_foam.gui.ctrl_widgets.data_source_widget import DataSourceWidget @@ -773,8 +775,8 @@ def testPumpProbeCtrlWidget(self): pp_proc.update() self.assertEqual(PumpProbeMode.UNDEFINED, pp_proc._mode) - self.assertListEqual([-1], pp_proc._indices_on) - self.assertListEqual([-1], pp_proc._indices_off) + self.assertEqual(slice(None, None), pp_proc._indices_on) + self.assertEqual(slice(None, None), pp_proc._indices_off) spy = QSignalSpy(widget._mode_cb.currentTextChanged) @@ -783,8 +785,8 @@ def testPumpProbeCtrlWidget(self): pp_proc.update() self.assertEqual(PumpProbeMode(PumpProbeMode.EVEN_TRAIN_ON), pp_proc._mode) - self.assertListEqual([-1], pp_proc._indices_on) - self.assertListEqual([-1], pp_proc._indices_off) + self.assertEqual(slice(None, None), pp_proc._indices_on) + self.assertEqual(slice(None, None), pp_proc._indices_off) widget._mode_cb.setCurrentText(all_modes[PumpProbeMode.REFERENCE_AS_OFF]) self.assertEqual(2, len(spy)) @@ -796,10 +798,16 @@ def testPumpProbeCtrlWidget(self): widget._mode_cb.setCurrentText(all_modes[PumpProbeMode.SAME_TRAIN]) self.assertEqual(2, len(spy)) + # test loading meta data + # test if the meta data is invalid mediator = widget._mediator mediator.onPpModeChange(PumpProbeMode.SAME_TRAIN) + mediator.onPpOnPulseSlicerChange([0, None, 2]) + mediator.onPpOffPulseSlicerChange([0, None, 2]) widget.loadMetaData() self.assertEqual("", widget._mode_cb.currentText()) + self.assertEqual(":", widget._on_pulse_le.text()) + self.assertEqual(":", widget._off_pulse_le.text()) def testFomFilterCtrlWidget(self): widget = self.gui.fom_filter_ctrl_widget diff --git a/extra_foam/pipeline/processors/pump_probe.py b/extra_foam/pipeline/processors/pump_probe.py index d5a701838..94dfa423e 100644 --- a/extra_foam/pipeline/processors/pump_probe.py +++ b/extra_foam/pipeline/processors/pump_probe.py @@ -24,8 +24,8 @@ class PumpProbeProcessor(_BaseProcessor): Attributes: analysis_type (AnalysisType): pump-probe analysis type. _mode (PumpProbeMode): pump-probe analysis mode. - _indices_on (list): a list of laser-on pulse indices. - _indices_off (list): a list of laser-off pulse indices. + _indices_on (slice): a slicer for laser-on pulse indices. + _indices_off (slice): a slicer of laser-off pulse indices. _prev_unmasked_on (numpy.ndarray): the most recent on-pulse image. _prev_xi_on (double): the most recent xgm on-intensity. _prev_dpi_on (double): the most recent digitizer on-pulse-integral. @@ -39,8 +39,8 @@ def __init__(self): self.analysis_type = AnalysisType.UNDEFINED self._mode = PumpProbeMode.UNDEFINED - self._indices_on = [] - self._indices_off = [] + self._indices_on = slice(None, None) + self._indices_off = slice(None, None) self._reset = False self._abs_difference = False @@ -72,10 +72,8 @@ def update(self): # reset when commanded by the GUI self._reset = True - self._indices_on = self.str2list( - cfg['on_pulse_indices'], handler=int) - self._indices_off = self.str2list( - cfg['off_pulse_indices'], handler=int) + self._indices_on = self.str2slice(cfg['on_pulse_slicer']) + self._indices_off = self.str2slice(cfg['off_pulse_slicer']) @profiler("Pump-probe processor") def process(self, data): @@ -173,13 +171,13 @@ def _compute_on_off_data(self, tid, assembled, xi, dpi, dropped_indices, *, mode = self._mode if mode != PumpProbeMode.UNDEFINED: - self._parse_on_off_indices(assembled.shape) + indices_on, indices_off = self._parse_on_off_indices(assembled.shape) if assembled.ndim == 3: - self._validate_on_off_indices(assembled.shape[0]) + self._validate_on_off_indices(indices_on, indices_off) - indices_on = list(set(self._indices_on) - set(dropped_indices)) - indices_off = list(set(self._indices_off) - set(dropped_indices)) + indices_on = list(set(indices_on) - set(dropped_indices)) + indices_off = list(set(indices_off) - set(dropped_indices)) # on and off are not from different trains if mode in (PumpProbeMode.REFERENCE_AS_OFF, @@ -286,37 +284,18 @@ def _compute_on_off_data(self, tid, assembled, xi, dpi, dropped_indices, *, def _parse_on_off_indices(self, shape): if len(shape) == 3: - # pulse-resolved - all_indices = list(range(shape[0])) + n_pulses = shape[0] else: - # train-resolved (indeed not used) - all_indices = [0] - - # convert [-1] to a list of indices - if self._indices_on[0] == -1: - self._indices_on = all_indices - if self._indices_off[0] == -1: - self._indices_off = all_indices - - def _validate_on_off_indices(self, n_pulses): - """Check pulse index when on/off pulses in the same train. - - Note: We can not check it in the GUI side since we do not know - how many pulses are there in the train. - """ - # check index range - if self._mode == PumpProbeMode.REFERENCE_AS_OFF: - max_index = max(self._indices_on) - else: - max_index = max(max(self._indices_on), max(self._indices_off)) + n_pulses = 1 - if max_index >= n_pulses: - raise PumpProbeIndexError(f"Index {max_index} is out of range for" - f" a train with {n_pulses} pulses!") + return (list(range(*self._indices_on.indices(n_pulses))), + list(range(*self._indices_off.indices(n_pulses)))) + def _validate_on_off_indices(self, indices_on, indices_off): + """Check pulse index when on/off pulses in the same train.""" if self._mode == PumpProbeMode.SAME_TRAIN: # check pulse index overlap in on- and off- indices - common = set(self._indices_on).intersection(self._indices_off) + common = set(indices_on).intersection(indices_off) if common: raise PumpProbeIndexError( "Pulse indices {} are found in both on- and off- pulses.". diff --git a/extra_foam/pipeline/processors/tests/test_pump_probe.py b/extra_foam/pipeline/processors/tests/test_pump_probe.py index c0329904b..36363e616 100644 --- a/extra_foam/pipeline/processors/tests/test_pump_probe.py +++ b/extra_foam/pipeline/processors/tests/test_pump_probe.py @@ -20,8 +20,8 @@ class _PumpProbeTestMixin: def _check_pp_params_in_data_model(self, data): self.assertEqual(self._proc._mode, data.pp.mode) - self.assertListEqual(self._proc._indices_on, data.pp.indices_on) - self.assertListEqual(self._proc._indices_off, data.pp.indices_off) + self.assertEqual(self._proc._indices_on, data.pp.indices_on) + self.assertEqual(self._proc._indices_off, data.pp.indices_off) def check_other_none(self, processed): pp = processed.pp @@ -46,8 +46,8 @@ class TestPumpProbeProcessorTr(_PumpProbeTestMixin, _TestDataMixin, unittest.Tes """ def setUp(self): self._proc = PumpProbeProcessor() - self._proc._indices_on = [0] - self._proc._indices_off = [0] + self._proc._indices_on = slice(None, None) + self._proc._indices_off = slice(None, None) def _gen_data(self, tid, with_xgm=True, with_digitizer=True): shape = (3, 2) @@ -194,8 +194,8 @@ class TestPumpProbeProcessorPr(_PumpProbeTestMixin, _TestDataMixin, unittest.Tes """ def setUp(self): self._proc = PumpProbeProcessor() - self._proc._indices_on = [0] - self._proc._indices_off = [0] + self._proc._indices_on = slice(0, 1, 1) + self._proc._indices_off = slice(0, 1, 1) def _gen_data(self, tid, with_xgm=True, with_digitizer=True): shape = (3, 2) @@ -223,42 +223,20 @@ def testFomPulseFilter(self): def testInvalidPulseIndices(self): proc = self._proc - proc._indices_on = [0, 1, 5] - proc._indices_off = [1] - - proc._mode = PumpProbeMode.REFERENCE_AS_OFF - with self.assertRaises(PumpProbeIndexError): - # the maximum index is 4 - data, _ = self._gen_data(1001) - proc.process(data) - - proc._indices_on = [0, 1, 5] - proc._indices_off = [1, 3] - proc._mode = PumpProbeMode.EVEN_TRAIN_ON - with self.assertRaises(PumpProbeIndexError): - data, _ = self._gen_data(1001) - proc.process(data) # raises when the same pulse index was found in both # on- and off- indices - proc._indices_on = [0, 1] - proc._indices_off = [1, 3] + proc._indices_on = slice(None, None) + proc._indices_off = slice(None, None) proc._mode = PumpProbeMode.SAME_TRAIN with self.assertRaises(PumpProbeIndexError): data, _ = self._gen_data(1001) proc.process(data) - # off-indices check is not trigger in REFERENCE_AS_OFF mode - proc._indices_on = [0, 1] - proc._indices_off = [5] - proc._mode = PumpProbeMode.REFERENCE_AS_OFF - data, _ = self._gen_data(1001) - proc.process(data) - def testUndefined(self): proc = self._proc - proc._indices_on = [0, 2] - proc._indices_off = [1, 3] + proc._indices_on = slice(0, None, 2) + proc._indices_off = slice(1, None, 2) proc._threshold_mask = (-np.inf, np.inf) proc._mode = PumpProbeMode.UNDEFINED @@ -273,8 +251,8 @@ def testUndefined(self): def testPredefinedOff(self): proc = self._proc proc._mode = PumpProbeMode.REFERENCE_AS_OFF - proc._indices_on = [0, 2] - proc._indices_off = [1, 3] + proc._indices_on = slice(0, None, 2) + proc._indices_off = slice(1, None, 2) data, processed = self._gen_data(1001) proc.process(data) @@ -322,8 +300,8 @@ def testPredefinedOff(self): def testSameTrain(self): proc = self._proc proc._mode = PumpProbeMode.SAME_TRAIN - proc._indices_on = [0, 2] - proc._indices_off = [1, 3] + proc._indices_on = slice(0, None, 2) + proc._indices_off = slice(1, None, 2) data, processed = self._gen_data(1001) proc.process(data) @@ -377,8 +355,8 @@ def testSameTrain(self): def testEvenOn(self): proc = self._proc proc._mode = PumpProbeMode.EVEN_TRAIN_ON - proc._indices_on = [0, 2] - proc._indices_off = [1, 3] + proc._indices_on = slice(0, None, 2) + proc._indices_off = slice(1, None, 2) # test off will not be acknowledged without on data, processed = self._gen_data(1001) # off @@ -466,8 +444,8 @@ def testEvenOn(self): def testOddOn(self): proc = self._proc proc._mode = PumpProbeMode.ODD_TRAIN_ON - proc._indices_on = [0, 2] - proc._indices_off = [1, 3] + proc._indices_on = slice(0, None, 2) + proc._indices_off = slice(1, None, 2) # test off will not be acknowledged without on data, processed = self._gen_data(1002) # off From 93db01827f709e81339d704a2ac029507317c639 Mon Sep 17 00:00:00 2001 From: zhujun Date: Thu, 12 Mar 2020 13:44:20 +0100 Subject: [PATCH 16/23] Improve GeometryCtrlWidget - Use SmartLineEdit with validator in QTableWidget; - Any change will immediately write into Redis; - Not check existence of geometry file and quad positions when updating metadata. --- .../gui/ctrl_widgets/geometry_ctrl_widget.py | 98 ++++++++++--------- extra_foam/gui/gui_helpers.py | 29 ------ .../gui/image_tool/tests/test_image_tool.py | 26 ++--- extra_foam/gui/mediator.py | 2 +- 4 files changed, 67 insertions(+), 88 deletions(-) diff --git a/extra_foam/gui/ctrl_widgets/geometry_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/geometry_ctrl_widget.py index 584f20245..8af316ba0 100644 --- a/extra_foam/gui/ctrl_widgets/geometry_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/geometry_ctrl_widget.py @@ -7,23 +7,30 @@ Copyright (C) European X-Ray Free-Electron Laser Facility GmbH. All rights reserved. """ -import os.path as osp from collections import OrderedDict import json from PyQt5.QtCore import Qt +from PyQt5.QtGui import QDoubleValidator from PyQt5.QtWidgets import ( QCheckBox, QComboBox, QFileDialog, QGridLayout, QHeaderView, QHBoxLayout, - QLabel, QLineEdit, QPushButton, QTableWidget, QTableWidgetItem, - QVBoxLayout + QLabel, QPushButton, QTableWidget, QVBoxLayout ) from .base_ctrl_widgets import _AbstractCtrlWidget -from ..gui_helpers import invert_dict, parse_table_widget +from .smart_widgets import SmartLineEdit, SmartStringLineEdit +from ..gui_helpers import invert_dict from ...config import config, GeomAssembler from ...database import Metadata as mt -from ...logger import logger + + +def _parse_table_widget(widget): + ret = [] + for i in range(widget.columnCount()): + ret.append([float(widget.cellWidget(j, i).text()) + for j in range(widget.rowCount())]) + return ret class GeometryCtrlWidget(_AbstractCtrlWidget): @@ -50,9 +57,9 @@ def __init__(self, *args, **kwargs): self._quad_positions_tb.setEnabled(False) self._assembler_cb.setCurrentText("EXtra-geom") - self._geom_file_le = QLineEdit(config["GEOMETRY_FILE"]) + self._geom_file_le = SmartStringLineEdit(config["GEOMETRY_FILE"]) self._geom_file_open_btn = QPushButton("Load geometry file") - self._geom_file_open_btn.clicked.connect(self.loadGeometryFile) + self._geom_file_open_btn.clicked.connect(self._loadGeometryFile) self._non_reconfigurable_widgets = [ self @@ -98,44 +105,53 @@ def initConnections(self): lambda x: mediator.onGeomAssemblerChange( self._assemblers[x])) + self._geom_file_le.value_changed_sgn.connect( + mediator.onGeomFileChange) + def initQuadTable(self): n_row = 2 n_col = 4 - widget = self._quad_positions_tb - widget.setRowCount(n_row) - widget.setColumnCount(n_col) - try: - for i in range(n_row): - for j in range(n_col): - if config["DETECTOR"] in ["LPD", "DSSC"]: - widget.setItem(i, j, QTableWidgetItem( - str(config["QUAD_POSITIONS"][j][i]))) - else: - widget.setItem(i, j, QTableWidgetItem('0')) - except IndexError: - pass - - widget.move(0, 0) - widget.setVerticalHeaderLabels(['x', 'y']) - widget.setHorizontalHeaderLabels(['1', '2', '3', '4']) - - header = widget.horizontalHeader() + + table = self._quad_positions_tb + table.setRowCount(n_row) + table.setColumnCount(n_col) + + for i in range(n_row): + for j in range(n_col): + if config["DETECTOR"] in ["LPD", "DSSC"]: + widget = SmartLineEdit(str(config["QUAD_POSITIONS"][j][i])) + else: + widget = SmartLineEdit('0') + widget.setValidator(QDoubleValidator(-999, 999, 6)) + widget.value_changed_sgn.connect(self._updateQuadPositions) + table.setCellWidget(i, j, widget) + + table.move(0, 0) + table.setVerticalHeaderLabels(['x', 'y']) + table.setHorizontalHeaderLabels(['1', '2', '3', '4']) + + header = table.horizontalHeader() + for i in range(n_col): header.setSectionResizeMode(i, QHeaderView.Stretch) - header = widget.verticalHeader() + header = table.verticalHeader() for i in range(n_row): header.setSectionResizeMode(i, QHeaderView.Stretch) - header_height = widget.horizontalHeader().height() - widget.setMinimumHeight(header_height * (n_row + 1.5)) - widget.setMaximumHeight(header_height * (n_row + 2.0)) + header_height = table.horizontalHeader().height() + table.setMinimumHeight(header_height * (n_row + 1.5)) + table.setMaximumHeight(header_height * (n_row + 2.0)) - def loadGeometryFile(self): + def _loadGeometryFile(self): filename = QFileDialog.getOpenFileName()[0] if filename: self._geom_file_le.setText(filename) + def _updateQuadPositions(self): + self._mediator.onGeomQuadPositionsChange( + _parse_table_widget(self._quad_positions_tb)) + def updateMetaData(self): """Override""" if not config['REQUIRE_GEOMETRY']: @@ -146,21 +162,9 @@ def updateMetaData(self): self._assembler_cb.currentTextChanged.emit( self._assembler_cb.currentText()) - # FIXME - - geom_file = self._geom_file_le.text() - if not osp.isfile(geom_file): - logger.error(f": {geom_file} is not a valid file") - return False - self._mediator.onGeomFilenameChange(geom_file) + self._geom_file_le.returnPressed.emit() - try: - quad_positions = parse_table_widget(self._quad_positions_tb) - except ValueError as e: - logger.error(": " + repr(e)) - return False - - self._mediator.onGeomQuadPositionsChange(quad_positions) + self._updateQuadPositions() return True @@ -175,11 +179,11 @@ def loadMetaData(self): self._assemblers_inv[int(cfg["assembler"])]) self._stack_only_cb.setChecked(cfg["stack_only"] == 'True') self._geom_file_le.setText(cfg["geometry_file"]) - quad_positions = json.loads(cfg["quad_positions"], encoding='utf8') + quad_positions = json.loads(cfg["quad_positions"], encoding='utf8') table = self._quad_positions_tb n_rows = table.rowCount() n_cols = table.columnCount() for j in range(n_cols): for i in range(n_rows): - table.item(i, j).setText(str(quad_positions[j][i])) + table.cellWidget(i, j).setText(str(quad_positions[j][i])) diff --git a/extra_foam/gui/gui_helpers.py b/extra_foam/gui/gui_helpers.py index 49970d9f4..33f1f56a2 100644 --- a/extra_foam/gui/gui_helpers.py +++ b/extra_foam/gui/gui_helpers.py @@ -129,35 +129,6 @@ def parse_item(v): return sorted(ret) -def parse_table_widget(widget): - """Parse a table widget to a list of list. - - The inner list represents a row of the table. - - :param QTableWidget widget: a table widget. - - :return list: a list of table elements. - - Examples: - - For the following table, - - col1 col2 - row1 1 2 - row2 3 4 - row3 5 6 - - The return value is [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]. - - TODO: add test - """ - n_row, n_col = widget.rowCount(), widget.columnCount() - ret = [] - for i in range(n_col): - ret.append([float(widget.item(j, i).text()) for j in range(n_row)]) - return ret - - def parse_slice(text): """Parse a string into list which can be converted into a slice object. diff --git a/extra_foam/gui/image_tool/tests/test_image_tool.py b/extra_foam/gui/image_tool/tests/test_image_tool.py index 9f81468f5..3540ff7bf 100644 --- a/extra_foam/gui/image_tool/tests/test_image_tool.py +++ b/extra_foam/gui/image_tool/tests/test_image_tool.py @@ -728,7 +728,7 @@ def testGeometryCtrlWidget(self): from extra_geom import LPD_1MGeometry as LPD_1MGeometry from extra_foam.geometries import LPD_1MGeometryFast from extra_foam.config import GeomAssembler - from extra_foam.gui.gui_helpers import parse_table_widget + from extra_foam.gui.ctrl_widgets.geometry_ctrl_widget import _parse_table_widget cw = self.image_tool._views_tab view = self.image_tool._geometry_view @@ -743,28 +743,32 @@ def testGeometryCtrlWidget(self): self.assertIsInstance(proc._geom, LPD_1MGeometryFast) self.assertFalse(proc._stack_only) self.assertEqual(GeomAssembler.OWN, proc._assembler_type) + self.assertListEqual([list(v) for v in config["QUAD_POSITIONS"]], proc._quad_position) - # test assembler + # test setting new values avail_assemblers = {value: key for key, value in widget._assemblers.items()} widget._assembler_cb.setCurrentText(avail_assemblers[GeomAssembler.EXTRA_GEOM]) - proc.update() - self.assertEqual(GeomAssembler.EXTRA_GEOM, proc._assembler_type) - - # test stack only widget._stack_only_cb.setChecked(True) + widget._geom_file_le.setText("/geometry/file/") + for i in range(4): + for j in range(2): + widget._quad_positions_tb.cellWidget(j, i).setText("0.0") proc.update() self.assertTrue(proc._stack_only) # when "stack only" is checked, "assembler type" setup will be ignored self.assertEqual(GeomAssembler.OWN, proc._assembler_type) + self.assertEqual("/geometry/file/", proc._geom_file) + self.assertListEqual([[0., 0.] for i in range(4)], proc._quad_position) - # test geometry file - widget._geom_file_le.setText("/geometry/file/") - self.assertFalse(widget.updateMetaData()) + widget._stack_only_cb.setChecked(False) + with patch.object(proc, "_load_geometry"): + proc.update() + self.assertEqual(GeomAssembler.EXTRA_GEOM, proc._assembler_type) # test loading meta data mediator = widget._mediator mediator.onGeomAssemblerChange(GeomAssembler.EXTRA_GEOM) - mediator.onGeomFilenameChange('geometry/new_file') + mediator.onGeomFileChange('geometry/new_file') mediator.onGeomStackOnlyChange(False) quad_positions = [[1., 2.], [3., 4.], [5., 6.], [7., 8.]] mediator.onGeomQuadPositionsChange(quad_positions) @@ -772,7 +776,7 @@ def testGeometryCtrlWidget(self): self.assertEqual("EXtra-geom", widget._assembler_cb.currentText()) self.assertEqual('geometry/new_file', widget._geom_file_le.text()) self.assertFalse(widget._stack_only_cb.isChecked()) - self.assertListEqual(quad_positions, parse_table_widget((widget._quad_positions_tb))) + self.assertListEqual(quad_positions, _parse_table_widget((widget._quad_positions_tb))) def testViewTabSwitching(self): tab = self.image_tool._views_tab diff --git a/extra_foam/gui/mediator.py b/extra_foam/gui/mediator.py index f9f6b70d4..4c73e651b 100644 --- a/extra_foam/gui/mediator.py +++ b/extra_foam/gui/mediator.py @@ -97,7 +97,7 @@ def onGeomStackOnlyChange(self, value: bool): def onGeomAssemblerChange(self, value: IntEnum): self._meta.hset(mt.GEOMETRY_PROC, "assembler", int(value)) - def onGeomFilenameChange(self, value: str): + def onGeomFileChange(self, value: str): self._meta.hset(mt.GEOMETRY_PROC, "geometry_file", value) def onGeomQuadPositionsChange(self, value: str): From 602adb8fff0fc1e560ec8ee5afb918937620813a Mon Sep 17 00:00:00 2001 From: zhujun Date: Thu, 12 Mar 2020 16:02:38 +0100 Subject: [PATCH 17/23] Catch Exception and raise AssemblingError for 1MGeometryFast --- .../pipeline/processors/image_assembler.py | 35 +++++++++++------ .../processors/tests/test_image_assembler.py | 39 +++++++++++++++++-- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/extra_foam/pipeline/processors/image_assembler.py b/extra_foam/pipeline/processors/image_assembler.py index a5dca07c2..0435fa17d 100644 --- a/extra_foam/pipeline/processors/image_assembler.py +++ b/extra_foam/pipeline/processors/image_assembler.py @@ -263,11 +263,14 @@ def update(self): self._stack_only = stack_only self._assembler_type = assembler_type - self._geom_file = geom_file self._quad_position = quad_positions self._geom = None # reset first self._load_geometry(geom_file, quad_positions) + # caveat: if _load_geometry raises, _geom_file will not + # be set. Therefore, _load_geometry will raise + # AssemblingError in the next train. + self._geom_file = geom_file if not stack_only: logger.info(f"Loaded geometry from {geom_file} with " @@ -464,16 +467,20 @@ def _load_geometry(self, filename, quad_positions): self._geom = AGIPD_1MGeometryFast() else: try: + # catch any exceptions here since it loads the CFEL + # geometry file with a CFEL function self._geom = AGIPD_1MGeometryFast.from_crystfel_geom( filename) - except (ImportError, ModuleNotFoundError, OSError) as e: + except Exception as e: raise AssemblingError(e) else: from extra_geom import AGIPD_1MGeometry try: + # catch any exceptions here since it loads the CFEL + # geometry file with a CFEL function self._geom = AGIPD_1MGeometry.from_crystfel_geom(filename) - except (ImportError, ModuleNotFoundError, OSError) as e: + except Exception as e: raise AssemblingError(e) class LpdImageAssembler(BaseAssembler): @@ -520,16 +527,19 @@ def _load_geometry(self, filename, quad_positions): if self._stack_only: self._geom = LPD_1MGeometryFast() else: - self._geom = LPD_1MGeometryFast.from_h5_file_and_quad_positions( - filename, quad_positions) + try: + self._geom = LPD_1MGeometryFast.from_h5_file_and_quad_positions( + filename, quad_positions) + except (OSError, KeyError) as e: + raise AssemblingError(f"[Geometry] {e}") else: from extra_geom import LPD_1MGeometry try: self._geom = LPD_1MGeometry.from_h5_file_and_quad_positions( filename, quad_positions) - except OSError as e: - raise AssemblingError(e) + except (OSError, KeyError) as e: + raise AssemblingError(f"[Geometry] {e}") class DsscImageAssembler(BaseAssembler): @@ -574,16 +584,19 @@ def _load_geometry(self, filename, quad_positions): if self._stack_only: self._geom = DSSC_1MGeometryFast() else: - self._geom = DSSC_1MGeometryFast.from_h5_file_and_quad_positions( - filename, quad_positions) + try: + self._geom = DSSC_1MGeometryFast.from_h5_file_and_quad_positions( + filename, quad_positions) + except (OSError, KeyError) as e: + raise AssemblingError(f"[Geometry] {e}") else: from extra_geom import DSSC_1MGeometry try: self._geom = DSSC_1MGeometry.from_h5_file_and_quad_positions( filename, quad_positions) - except OSError as e: - raise AssemblingError(e) + except (OSError, KeyError) as e: + raise AssemblingError(f"[Geometry] {e}") class JungFrauImageAssembler(BaseAssembler): def _get_modules_bridge(self, data, src): diff --git a/extra_foam/pipeline/processors/tests/test_image_assembler.py b/extra_foam/pipeline/processors/tests/test_image_assembler.py index 3afbb1623..37d6d6638 100644 --- a/extra_foam/pipeline/processors/tests/test_image_assembler.py +++ b/extra_foam/pipeline/processors/tests/test_image_assembler.py @@ -21,7 +21,6 @@ from extra_foam.pipeline.processors.image_assembler import ( _IMAGE_DTYPE, _RAW_IMAGE_DTYPE, ImageAssemblerFactory ) -from extra_foam.pipeline.tests import _TestDataMixin from extra_foam.pipeline.exceptions import AssemblingError from extra_foam.config import GeomAssembler, config, DataSource from extra_foam.database import SourceCatalog, SourceItem @@ -87,6 +86,18 @@ def _create_catalog(self, src_name, key_name): catalog.add_item(SourceItem('AGIPD', src_name, [], key_name, slice(None, None), None)) return src, catalog + @pytest.mark.parametrize("assembler_type", [GeomAssembler.EXTRA_GEOM, GeomAssembler.OWN]) + def testInvalidGeometryFile(self, assembler_type): + self._assembler._assembler_type = assembler_type + # test file does not exist + with pytest.raises(AssemblingError): + self._assembler._load_geometry("abc", self._quad_positions) + + # test invalid file + with tempfile.NamedTemporaryFile() as fp: + with pytest.raises(AssemblingError): + self._assembler._load_geometry(fp.name, self._quad_positions) + def testGeneral(self): # Note: this test does not need to repeat for each detector key_name = 'image.data' @@ -201,7 +212,29 @@ def testAssembleBridge(self): _check_single_module_result(data, src, config["MODULE_SHAPE"]) -class TestLpdAssembler: +class _AssemblerGeometryTest: + @pytest.mark.parametrize("assembler_type", [GeomAssembler.EXTRA_GEOM, GeomAssembler.OWN]) + def testInvalidGeometryFile(self, assembler_type): + import h5py + + self._assembler._assembler_type = assembler_type + # test file does not exist + with pytest.raises(AssemblingError): + self._assembler._load_geometry("abc", self._quad_positions) + + # test invalid file (file signature not found) + with tempfile.TemporaryFile() as fp: + with pytest.raises(AssemblingError): + self._assembler._load_geometry(fp, self._quad_positions) + + # test invalid h5 file (component not found) + with tempfile.NamedTemporaryFile() as fp: + fph5 = h5py.File(fp.name, 'r+') + with pytest.raises(AssemblingError): + self._assembler._load_geometry(fp.name, self._quad_positions) + + +class TestLpdAssembler(_AssemblerGeometryTest): @classmethod def setup_class(cls): config.load('LPD', 'FXE') @@ -431,7 +464,7 @@ def testAssembleDtype(self, assembler_type): self._assembler.process(data) -class TestDSSCAssembler: +class TestDSSCAssembler(_AssemblerGeometryTest): @classmethod def setup_class(cls): config.load('DSSC', 'SCS') From 6047209af558e1bcf191991a1efd28a70f243015 Mon Sep 17 00:00:00 2001 From: zhujun98 Date: Fri, 13 Mar 2020 11:39:23 +0100 Subject: [PATCH 18/23] Add save/load metadata for correlation and binning analysis --- .../gui/ctrl_widgets/bin_ctrl_widget.py | 52 ++++++++++++++- .../ctrl_widgets/correlation_ctrl_widget.py | 47 +++++++++++++- extra_foam/gui/tests/test_main_gui.py | 64 +++++++++++++++++-- 3 files changed, 157 insertions(+), 6 deletions(-) diff --git a/extra_foam/gui/ctrl_widgets/bin_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/bin_ctrl_widget.py index 2c7b73d25..b3b88df65 100644 --- a/extra_foam/gui/ctrl_widgets/bin_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/bin_ctrl_widget.py @@ -18,7 +18,9 @@ from .base_ctrl_widgets import _AbstractGroupBoxCtrlWidget from .smart_widgets import SmartBoundaryLineEdit, SmartLineEdit +from ..gui_helpers import invert_dict from ...config import AnalysisType, BinMode, config +from ...database import Metadata as mt _N_PARAMS = 2 @@ -37,14 +39,18 @@ class BinCtrlWidget(_AbstractGroupBoxCtrlWidget): "ROI proj": AnalysisType.ROI_PROJ, "azimuthal integ": AnalysisType.AZIMUTHAL_INTEG, }) + _analysis_types_inv = invert_dict(_analysis_types) _bin_modes = OrderedDict({ "average": BinMode. AVERAGE, "accumulcate": BinMode.ACCUMULATE, }) + _bin_modes_inv = invert_dict(_bin_modes) _user_defined_key = config["SOURCE_USER_DEFINED_CATEGORY"] + _UNDEFINED_CATEGORY = '' + def __init__(self, *args, **kwargs): super().__init__("Binning setup", *args, **kwargs) @@ -119,7 +125,7 @@ def initParamTable(self): # loop over bin parameters for i_row in range(n_row): category_cb = QComboBox() - category_cb.addItem('') # default is empty + category_cb.addItem(self._UNDEFINED_CATEGORY) for k, v in self._src_metadata.items(): if v: category_cb.addItem(k) @@ -253,3 +259,47 @@ def updateMetaData(self): self.onBinParamChangeCb(i_row) return True + + def loadMetaData(self): + """Override.""" + cfg = self._meta.hget_all(mt.BIN_PROC) + self._analysis_type_cb.setCurrentText( + self._analysis_types_inv[int(cfg["analysis_type"])]) + + self._mode_cb.setCurrentText(self._bin_modes_inv[int(cfg["mode"])]) + + for i in range(_N_PARAMS): + src = cfg[f'source{i+1}'] + if not src: + self._table.cellWidget(i, 0).setCurrentText( + self._UNDEFINED_CATEGORY) + self.onCategoryChange(i, self._UNDEFINED_CATEGORY) + else: + device_id, ppt = src.split(' ') + bin_range = cfg[f'bin_range{i+1}'][1:-1] + n_bins = cfg[f'n_bins{i+1}'] + ctg = self._find_category(device_id, ppt) + + self._table.cellWidget(i, 0).setCurrentText(ctg) + self.onCategoryChange(i, ctg) + if ctg == self._user_defined_key: + self._table.cellWidget(i, 1).setText(device_id) + self._table.cellWidget(i, 2).setText(ppt) + else: + self._table.cellWidget(i, 1).setCurrentText(device_id) + self._table.cellWidget(i, 2).setCurrentText(ppt) + self._table.cellWidget(i, 3).setText(bin_range) + self._table.cellWidget(i, 4).setText(n_bins) + + def _find_category(self, device_id, ppt): + for ctg in self._src_instrument: + ctg_srcs = self._src_instrument[ctg] + if device_id in ctg_srcs and ppt in ctg_srcs[device_id]: + return ctg + + for ctg in self._src_metadata: + ctg_srcs = self._src_metadata[ctg] + if device_id in ctg_srcs and ppt in ctg_srcs[device_id]: + return ctg + + return self._user_defined_key diff --git a/extra_foam/gui/ctrl_widgets/correlation_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/correlation_ctrl_widget.py index 9503786ec..c39543d4b 100644 --- a/extra_foam/gui/ctrl_widgets/correlation_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/correlation_ctrl_widget.py @@ -19,7 +19,9 @@ from .base_ctrl_widgets import _AbstractGroupBoxCtrlWidget from .smart_widgets import SmartLineEdit +from ..gui_helpers import invert_dict from ...config import AnalysisType, config +from ...database import Metadata as mt _N_PARAMS = 2 # maximum number of correlated parameters _DEFAULT_RESOLUTION = 0.0 @@ -35,9 +37,12 @@ class CorrelationCtrlWidget(_AbstractGroupBoxCtrlWidget): "ROI proj": AnalysisType.ROI_PROJ, "azimuthal integ": AnalysisType.AZIMUTHAL_INTEG, }) + _analysis_types_inv = invert_dict(_analysis_types) _user_defined_key = config["SOURCE_USER_DEFINED_CATEGORY"] + _UNDEFINED_CATEGORY = '' + def __init__(self, *args, **kwargs): super().__init__("Correlation setup", *args, **kwargs) @@ -96,7 +101,7 @@ def initParamTable(self): for i_row in range(_N_PARAMS): category_cb = QComboBox() - category_cb.addItem('') # default is empty + category_cb.addItem(self._UNDEFINED_CATEGORY) for k, v in self._src_metadata.items(): if v: category_cb.addItem(k) @@ -213,3 +218,43 @@ def updateMetaData(self): else: self.onCorrelationParamChangeCb(i_row) return True + + def loadMetaData(self): + """Override.""" + cfg = self._meta.hget_all(mt.CORRELATION_PROC) + self._analysis_type_cb.setCurrentText( + self._analysis_types_inv[int(cfg["analysis_type"])]) + + for i in range(_N_PARAMS): + src = cfg[f'source{i+1}'] + if not src: + self._table.cellWidget(i, 0).setCurrentText( + self._UNDEFINED_CATEGORY) + self.onCategoryChange(i, self._UNDEFINED_CATEGORY) + else: + device_id, ppt = src.split(' ') + resolution = cfg[f'resolution{i+1}'] + ctg = self._find_category(device_id, ppt) + + self._table.cellWidget(i, 0).setCurrentText(ctg) + self.onCategoryChange(i, ctg) + if ctg == self._user_defined_key: + self._table.cellWidget(i, 1).setText(device_id) + self._table.cellWidget(i, 2).setText(ppt) + else: + self._table.cellWidget(i, 1).setCurrentText(device_id) + self._table.cellWidget(i, 2).setCurrentText(ppt) + self._table.cellWidget(i, 3).setText(resolution) + + def _find_category(self, device_id, ppt): + for ctg in self._src_instrument: + ctg_srcs = self._src_instrument[ctg] + if device_id in ctg_srcs and ppt in ctg_srcs[device_id]: + return ctg + + for ctg in self._src_metadata: + ctg_srcs = self._src_metadata[ctg] + if device_id in ctg_srcs and ppt in ctg_srcs[device_id]: + return ctg + + return self._user_defined_key diff --git a/extra_foam/gui/tests/test_main_gui.py b/extra_foam/gui/tests/test_main_gui.py index f374e41a9..83f7af21a 100644 --- a/extra_foam/gui/tests/test_main_gui.py +++ b/extra_foam/gui/tests/test_main_gui.py @@ -352,6 +352,32 @@ def testCorrelationCtrlWidget(self): proc.update() self.assertTrue(proc._reset) + # test loading meta data + mediator = widget._mediator + mediator.onCorrelationAnalysisTypeChange(AnalysisType.UNDEFINED) + if config["TOPIC"] == "FXE": + motor_id = 'FXE_SMS_USR/MOTOR/UM01' + else: + motor_id = 'SCS_ILH_LAS/MOTOR/LT3' + mediator.onCorrelationParamChange((1, f'{motor_id} actualPosition', 0.0)) + mediator.onCorrelationParamChange((2, 'ABC abc', 2.0)) + widget.loadMetaData() + self.assertEqual("", widget._analysis_type_cb.currentText()) + self.assertEqual('Motor', widget._table.cellWidget(0, 0).currentText()) + self.assertEqual(motor_id, widget._table.cellWidget(0, 1).currentText()) + self.assertEqual('actualPosition', widget._table.cellWidget(0, 2).currentText()) + self.assertEqual('0.0', widget._table.cellWidget(0, 3).text()) + self.assertEqual(widget._user_defined_key, widget._table.cellWidget(1, 0).currentText()) + self.assertEqual('ABC', widget._table.cellWidget(1, 1).text()) + self.assertEqual('abc', widget._table.cellWidget(1, 2).text()) + self.assertEqual('2.0', widget._table.cellWidget(1, 3).text()) + + mediator.onCorrelationParamChange((1, f'', 0.0)) + widget.loadMetaData() + self.assertEqual('', widget._table.cellWidget(0, 0).currentText()) + self.assertEqual('', widget._table.cellWidget(0, 1).text()) + self.assertEqual('', widget._table.cellWidget(0, 2).text()) + def testBinCtrlWidget(self): from extra_foam.gui.ctrl_widgets.bin_ctrl_widget import ( _DEFAULT_N_BINS, _DEFAULT_BIN_RANGE, _N_PARAMS @@ -361,8 +387,8 @@ def testBinCtrlWidget(self): widget = self.gui.bin_ctrl_widget - analysis_types = {value: key for key, value in widget._analysis_types.items()} - bin_modes = {value: key for key, value in widget._bin_modes.items()} + analysis_types_inv = widget._analysis_types_inv + bin_modes_inv = widget._bin_modes_inv for i in range(_N_PARAMS): combo_lst = [widget._table.cellWidget(i, 0).itemText(j) @@ -387,8 +413,8 @@ def testBinCtrlWidget(self): self.assertEqual(int(_DEFAULT_N_BINS), proc._n_bins2) # test analysis type and mode change - widget._analysis_type_cb.setCurrentText(analysis_types[AnalysisType.PUMP_PROBE]) - widget._mode_cb.setCurrentText(bin_modes[BinMode.ACCUMULATE]) + widget._analysis_type_cb.setCurrentText(analysis_types_inv[AnalysisType.PUMP_PROBE]) + widget._mode_cb.setCurrentText(bin_modes_inv[BinMode.ACCUMULATE]) proc.update() self.assertEqual(AnalysisType.PUMP_PROBE, proc.analysis_type) self.assertEqual(BinMode.ACCUMULATE, proc._mode) @@ -452,6 +478,36 @@ def testBinCtrlWidget(self): self.assertTrue(win._bin2d_count._auto_level) win.close() + # test loading meta data + mediator = widget._mediator + mediator.onBinAnalysisTypeChange(AnalysisType.UNDEFINED) + mediator.onBinModeChange(BinMode.AVERAGE) + if config["TOPIC"] == "FXE": + motor_id = 'FXE_SMS_USR/MOTOR/UM01' + else: + motor_id = 'SCS_ILH_LAS/MOTOR/LT3' + mediator.onBinParamChange((1, f'{motor_id} actualPosition', (-9, 9), 5)) + mediator.onBinParamChange((2, 'ABC abc', (-19, 19), 15)) + widget.loadMetaData() + self.assertEqual("", widget._analysis_type_cb.currentText()) + self.assertEqual("average", widget._mode_cb.currentText()) + self.assertEqual('Motor', widget._table.cellWidget(0, 0).currentText()) + self.assertEqual(motor_id, widget._table.cellWidget(0, 1).currentText()) + self.assertEqual('actualPosition', widget._table.cellWidget(0, 2).currentText()) + self.assertEqual('-9, 9', widget._table.cellWidget(0, 3).text()) + self.assertEqual('5', widget._table.cellWidget(0, 4).text()) + self.assertEqual(widget._user_defined_key, widget._table.cellWidget(1, 0).currentText()) + self.assertEqual('ABC', widget._table.cellWidget(1, 1).text()) + self.assertEqual('abc', widget._table.cellWidget(1, 2).text()) + self.assertEqual('-19, 19', widget._table.cellWidget(1, 3).text()) + self.assertEqual('15', widget._table.cellWidget(1, 4).text()) + + mediator.onBinParamChange((1, f'', (-9, 9), 5)) + widget.loadMetaData() + self.assertEqual('', widget._table.cellWidget(0, 0).currentText()) + self.assertEqual('', widget._table.cellWidget(0, 1).text()) + self.assertEqual('', widget._table.cellWidget(0, 2).text()) + def testHistogramCtrlWidget(self): widget = self.gui.histogram_ctrl_widget train_worker = self.train_worker From 88b28fc94418558a889a4005ccccc76072e08a47 Mon Sep 17 00:00:00 2001 From: zhujun98 Date: Mon, 16 Mar 2020 10:44:39 +0100 Subject: [PATCH 19/23] Fix PumpProbeMode.SAME_TRAIN in loadMetadata --- .../ctrl_widgets/pump_probe_ctrl_widget.py | 28 ++++++++----------- extra_foam/gui/tests/test_main_gui.py | 12 ++++---- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/extra_foam/gui/ctrl_widgets/pump_probe_ctrl_widget.py b/extra_foam/gui/ctrl_widgets/pump_probe_ctrl_widget.py index 42c9d41fb..705e221c7 100644 --- a/extra_foam/gui/ctrl_widgets/pump_probe_ctrl_widget.py +++ b/extra_foam/gui/ctrl_widgets/pump_probe_ctrl_widget.py @@ -8,6 +8,7 @@ All rights reserved. """ from collections import OrderedDict +import copy from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( @@ -24,14 +25,13 @@ class PumpProbeCtrlWidget(_AbstractGroupBoxCtrlWidget): """Widget for setting up pump-probe analysis parameters.""" - _available_modes = OrderedDict({ + __available_modes = OrderedDict({ "": PumpProbeMode.UNDEFINED, "reference as off": PumpProbeMode.REFERENCE_AS_OFF, - "same train": PumpProbeMode.SAME_TRAIN, "even/odd train": PumpProbeMode.EVEN_TRAIN_ON, - "odd/even train": PumpProbeMode.ODD_TRAIN_ON + "odd/even train": PumpProbeMode.ODD_TRAIN_ON, + "same train": PumpProbeMode.SAME_TRAIN, }) - _available_modes_inv = invert_dict(_available_modes) _analysis_types = OrderedDict({ "": AnalysisType.UNDEFINED, @@ -49,14 +49,14 @@ def __init__(self, *args, **kwargs): self._on_pulse_le = SmartSliceLineEdit(":") self._off_pulse_le = SmartSliceLineEdit(":") - all_keys = list(self._available_modes.keys()) - if self._pulse_resolved: - self._mode_cb.addItems(all_keys) - else: - all_keys.remove("same train") - self._mode_cb.addItems(all_keys) + self._available_modes = copy.copy(self.__available_modes) + self._available_modes_inv = invert_dict(self._available_modes) + if not self._pulse_resolved: + del self._available_modes["same train"] + del self._available_modes_inv[PumpProbeMode.SAME_TRAIN] self._on_pulse_le.setEnabled(False) self._off_pulse_le.setEnabled(False) + self._mode_cb.addItems(list(self._available_modes.keys())) self._analysis_type_cb = QComboBox() self._analysis_type_cb.addItems(list(self._analysis_types.keys())) @@ -134,12 +134,8 @@ def loadMetaData(self): self._analysis_type_cb.setCurrentText( self._analysis_types_inv[int(cfg["analysis_type"])]) - i_mode = int(cfg["mode"]) - if i_mode == PumpProbeMode.SAME_TRAIN: - self._mode_cb.setCurrentText("") - else: - self._mode_cb.setCurrentText( - self._available_modes_inv[int(cfg["mode"])]) + self._mode_cb.setCurrentText( + self._available_modes_inv[int(cfg["mode"])]) self._abs_difference_cb.setChecked(cfg["abs_difference"] == 'True') diff --git a/extra_foam/gui/tests/test_main_gui.py b/extra_foam/gui/tests/test_main_gui.py index 83f7af21a..5e515eba1 100644 --- a/extra_foam/gui/tests/test_main_gui.py +++ b/extra_foam/gui/tests/test_main_gui.py @@ -824,8 +824,7 @@ def testPumpProbeCtrlWidget(self): self.assertFalse(widget._on_pulse_le.isEnabled()) self.assertFalse(widget._off_pulse_le.isEnabled()) - all_modes = {value: key for key, value in - widget._available_modes.items()} + all_modes = widget._available_modes_inv # we only test train-resolved detector specific configuration @@ -851,20 +850,21 @@ def testPumpProbeCtrlWidget(self): self.assertFalse(widget._on_pulse_le.isEnabled()) # PumpProbeMode.SAME_TRAIN is not available - widget._mode_cb.setCurrentText(all_modes[PumpProbeMode.SAME_TRAIN]) - self.assertEqual(2, len(spy)) + self.assertNotIn(PumpProbeMode.SAME_TRAIN, all_modes) # test loading meta data # test if the meta data is invalid mediator = widget._mediator - mediator.onPpModeChange(PumpProbeMode.SAME_TRAIN) mediator.onPpOnPulseSlicerChange([0, None, 2]) mediator.onPpOffPulseSlicerChange([0, None, 2]) widget.loadMetaData() - self.assertEqual("", widget._mode_cb.currentText()) self.assertEqual(":", widget._on_pulse_le.text()) self.assertEqual(":", widget._off_pulse_le.text()) + mediator.onPpModeChange(PumpProbeMode.SAME_TRAIN) + with self.assertRaises(KeyError): + widget.loadMetaData() + def testFomFilterCtrlWidget(self): widget = self.gui.fom_filter_ctrl_widget filter_pulse = self.pulse_worker._filter From eb061091c2c0868ec4df2d70c7c1d1650e4312ba Mon Sep 17 00:00:00 2001 From: zhujun98 Date: Mon, 16 Mar 2020 10:54:06 +0100 Subject: [PATCH 20/23] Update documentation --- docs/images/configurator.png | Bin 0 -> 50628 bytes docs/introduction.rst | 2 +- docs/main_gui.rst | 48 +++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 docs/images/configurator.png diff --git a/docs/images/configurator.png b/docs/images/configurator.png new file mode 100644 index 0000000000000000000000000000000000000000..64367d041c31f0265292434fbdff8dd6e9d1dadc GIT binary patch literal 50628 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 literal 0 HcmV?d00001 diff --git a/docs/introduction.rst b/docs/introduction.rst index 9114a2e8f..bc0532937 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -23,7 +23,7 @@ real-time and off-line data analysis (**azimuthal integration**, **ROI**, **corr | FastCCD | True | True | True | True | +------------+------------+------------+------------+------------+ | ePix100 | True | True | True | True | ------------------------------------------------------------------- ++------------+------------+------------+------------+------------+ Why use **EXtra-foam** diff --git a/docs/main_gui.rst b/docs/main_gui.rst index 575e72837..ec07f5b7d 100644 --- a/docs/main_gui.rst +++ b/docs/main_gui.rst @@ -117,3 +117,51 @@ Statistics analysis ------------------- See :ref:`statistics analysis` + + +Configurator +------------ + +.. image:: images/configurator.png + :width: 640 + +*Configurator* is a new feature introduced in version 0.8.1, it allows users to save and load +different analysis setups (a snapshot in the Redis database) conveniently. To apply a setup, +simply **double-click** the name of the snapshot listed in the table. Please distinguish it +from :ref:`config file`, which is mainly used for data source management. Due to the historical +reason, some setups in the :ref:`config file` can also be saved and loaded via the configurator, +like ``photon energy``, ``sample distance``, etc. :ref:`config file` defines the default setups +which will be overwritten when a setup snapshot is applied. The default setups can be recovered by +clicking the ``Reset to default`` button. + ++----------------------------+--------------------------------------------------------------------+ +| Input | Description | ++============================+====================================================================+ +| ``Take snapshot`` | Take a snapshot of the current setup and store in ``Last saved``. | ++----------------------------+--------------------------------------------------------------------+ +| ``Reset to default`` | Reset the current setup to default. ``Last saved`` will not be | +| | affected. | ++----------------------------+--------------------------------------------------------------------+ +| ``Save setups in file`` | Save all the snapshots listed in the table to file. All the | +| | snapshots in the setup file will be lost. | ++----------------------------+--------------------------------------------------------------------+ +| ``Load setups from file`` | Load all the snapshots from file. In case of name conflict, the | +| | listed snapshot in the table will be overwritten. | ++----------------------------+--------------------------------------------------------------------+ + +When right-clicking the name of a snapshot, a menu will show up: + ++----------------------------+--------------------------------------------------------------------+ +| Input | Description | ++============================+====================================================================+ +| ``Copy snapshot`` | Make a copy of the snapshot. | ++----------------------------+--------------------------------------------------------------------+ +| ``Delete snapshot`` | Delete the snapshot. | ++----------------------------+--------------------------------------------------------------------+ +| ``Rename snapshot`` | Rename the snapshot. | ++----------------------------+--------------------------------------------------------------------+ + +.. warning:: + + *Configurator* is still in the testing phase and we are collecting feedbacks from users. + It should be noted that there is no backup recovery mechanism for now. From dd188d9c31507d3a94e3bc36b7a7021887638fa3 Mon Sep 17 00:00:00 2001 From: zhujun Date: Thu, 12 Mar 2020 12:40:53 +0100 Subject: [PATCH 21/23] Improve find_version --- setup.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index ad11fbb2c..903236602 100644 --- a/setup.py +++ b/setup.py @@ -31,9 +31,10 @@ def find_version(): with open(osp.join('extra_foam', '__init__.py')) as fp: for line in fp: - # FIXME: a better version parser - # m = re.search(r'^__version__ = "(\d+\.\d+\.\d[a-z]*\d*)"', line, re.M) - m = re.search(r'^__version__ = "*([\d.]+)"', line, re.M) + m = re.search(r'^__version__ = "(\d+\.\d+\.\d[a-z]*\d*)"', line, re.M) + if m is None: + # could be a hotfix + m = re.search(r'^__version__ = "(\d.){3}\d"', line, re.M) if m is not None: return m.group(1) raise RuntimeError("Unable to find version string.") From fb473135e0889223928ce923fbad69dbbc9f4414 Mon Sep 17 00:00:00 2001 From: zhujun Date: Thu, 12 Mar 2020 12:31:31 +0100 Subject: [PATCH 22/23] 0.8.1 Release --- docs/changelog.rst | 17 +++++++++++++++++ docs/start.rst | 4 ++-- extra_foam/__init__.py | 2 +- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index a02b132a2..f561a794f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,23 @@ CHANGELOG ========= +0.8.1 (16 March 2020) +------------------------ + +- **Improvement** + + - Automatically reset empty image mask with inconsistent shape. #104 + +- **New Feature** + + - Implement AGIPD 1M geometry in C++. #102 + - Add ROI1_DIV_ROI2 as an option for ROI FOM. #103 + - Implement normalization for ROI FOM. #96 + - Implement ROI FOM master-slave scan. #93 + - Add branch-based CI and Singularity image deployment. #92 + - Add support for ePix100 detector. #90 + - Implement save and load metadata. #87 + 0.8.0.1 (3 March 2020) ------------------------ diff --git a/docs/start.rst b/docs/start.rst index 903722990..7e460d43f 100644 --- a/docs/start.rst +++ b/docs/start.rst @@ -18,7 +18,7 @@ features than the **stable** version. .. code-block:: bash - module load exfel EXtra-foam + module load exfel EXtra-foam/beta extra-foam DETECTOR TOPIC More info on command line arguments can be obtained as @@ -78,7 +78,7 @@ To start the **stable** version on online or `Maxwell` clusters: .. code-block:: bash - module load exfel EXtra-foam/beta + module load exfel EXtra-foam extra-foam DETECTOR TOPIC diff --git a/extra_foam/__init__.py b/extra_foam/__init__.py index f0bb0ede0..9456dc322 100644 --- a/extra_foam/__init__.py +++ b/extra_foam/__init__.py @@ -37,7 +37,7 @@ import os -__version__ = "0.8.0.1" +__version__ = "0.8.1" # root path for storing config and log files ROOT_PATH = os.path.join(os.path.expanduser("~"), ".EXtra-foam") From f8794d8ccdc1b710038150a9d61b856f659d7406 Mon Sep 17 00:00:00 2001 From: Robert Rosca <32569096+RobertRosca@users.noreply.github.com> Date: Mon, 16 Mar 2020 15:30:34 +0100 Subject: [PATCH 23/23] Update .travis.yml Actually allows singularity build stage to fail --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index f554b6514..2bebca400 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,6 @@ stages: if: branch = master - name: singularity build push if: branch = master OR tag IS present - allow_failures: true env: global: @@ -17,6 +16,8 @@ services: - xvfb jobs: + allow_failures: + - stage: singularity build push include: - stage: basic env: PYTHON_VERSION="3.7.5" COMPILER="gcc" GCCv="7"