diff --git a/.github/workflows/check-news-item.yml b/.github/workflows/check-news-item.yml new file mode 100644 index 0000000..628fe4c --- /dev/null +++ b/.github/workflows/check-news-item.yml @@ -0,0 +1,12 @@ +name: Check for News + +on: + pull_request_target: + branches: + - main + +jobs: + build: + uses: Billingegroup/release-scripts/.github/workflows/_check-news-item.yml@v0 + with: + project: diffpy.fourigui diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 9531248..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Build Documentation - -on: - push: - branches: - - main - release: - -jobs: - test: - runs-on: ubuntu-latest - defaults: - run: - shell: bash -l {0} - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - uses: conda-incubator/setup-miniconda@v2 - with: - activate-environment: build - environment-file: ./environment.yml - python-version: 3 - auto-update-conda: true - - - name: install requirements - run: | - conda config --set always_yes yes --set changeps1 no - conda config --add channels conda-forge - conda create -n build python=3.12 - conda activate build - conda install --file requirements/build.txt - conda install --file requirements/run.txt - conda install --file requirements/test.txt - conda install --file requirements/docs.txt - pip install -r requirements/pip.txt - python -m pip install . --no-deps - - - name: build documents - run: make -C doc html - - - name: Run tests and upload coverage - shell: bash -l {0} - run: | - conda activate build - coverage run -m pytest -vv -s - coverage report -m - codecov - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - - - name: Deploy - uses: peaceiris/actions-gh-pages@v4 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./doc/build/html diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 9ab89ce..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: CI - -on: - push: - branches: - - main - - CI - pull_request: - workflow_dispatch: - -jobs: - miniconda: - name: Miniconda ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: ["ubuntu-latest"] - steps: - - name: check out diffpy.fourigui - uses: actions/checkout@v3 - with: - repository: diffpy/diffpy.fourigui - path: . - fetch-depth: 0 # avoid shallow clone with no tags - - - name: initialize miniconda - # this uses a marketplace action that sets up miniconda in a way that makes - # it easier to use. I tried setting it up without this and it was a pain - uses: conda-incubator/setup-miniconda@v2 - with: - activate-environment: test - # environment.yml file is needed by this action. Because I don't want - # maintain this but rather maintain the requirements files it just has - # basic things in it like conda and pip - environment-file: ./environment.yml - python-version: 3 - auto-activate-base: false - - - name: install diffpy.fourigui requirements - shell: bash -l {0} - run: | - conda config --set always_yes yes --set changeps1 no - conda config --add channels conda-forge - conda activate test - conda install --file requirements/run.txt - conda install --file requirements/test.txt - pip install . - - - name: Validate diffpy.fourigui - shell: bash -l {0} - run: | - conda activate test - coverage run -m pytest -vv -s - coverage report -m - codecov diff --git a/.github/workflows/matrix-and-codecov-on-merge-to-main.yml b/.github/workflows/matrix-and-codecov-on-merge-to-main.yml new file mode 100644 index 0000000..e739c34 --- /dev/null +++ b/.github/workflows/matrix-and-codecov-on-merge-to-main.yml @@ -0,0 +1,21 @@ +name: CI + +on: + push: + branches: + - main + release: + types: + - prereleased + - published + workflow_dispatch: + +jobs: + coverage: + uses: Billingegroup/release-scripts/.github/workflows/_matrix-and-codecov-on-merge-to-main.yml@v0 + with: + project: diffpy.fourigui + c_extension: false + headless: true + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml deleted file mode 100644 index f2ff7e4..0000000 --- a/.github/workflows/pre-commit.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: pre-commit - -on: - pull_request: - push: - workflow_dispatch: - -jobs: - pre-commit: - # pull requests are a duplicate of a branch push if within the same repo. - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.repository - - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - - uses: pre-commit/action@v3.0.0 - with: - extra_args: --all-files diff --git a/.github/workflows/publish-docs-on-release.yml b/.github/workflows/publish-docs-on-release.yml new file mode 100644 index 0000000..e341881 --- /dev/null +++ b/.github/workflows/publish-docs-on-release.yml @@ -0,0 +1,14 @@ +name: Build and Deploy Docs + +on: + release: + types: + - published + workflow_dispatch: + +jobs: + docs: + uses: Billingegroup/release-scripts/.github/workflows/_publish-docs-on-release.yml@v0 + with: + project: diffpy.fourigui + c_extension: false diff --git a/.github/workflows/tests-on-pr.yml b/.github/workflows/tests-on-pr.yml new file mode 100644 index 0000000..ea8bdb2 --- /dev/null +++ b/.github/workflows/tests-on-pr.yml @@ -0,0 +1,16 @@ +name: Tests on PR + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + validate: + uses: Billingegroup/release-scripts/.github/workflows/_tests-on-pr.yml@v0 + with: + project: diffpy.fourigui + c_extension: false + headless: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c458806..3070e19 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,10 @@ repos: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - exclude: '\.(rst|txt)$' + - id: check-case-conflict + - id: check-merge-conflict + - id: check-toml + - id: check-added-large-files - repo: https://github.com/psf/black rev: 24.4.2 hooks: diff --git a/AUTHORS.rst b/AUTHORS.rst index bd304e7..80a0c80 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -1,7 +1,7 @@ Authors ======= -Billinge Group and community contibutors. +Billinge Group and community contributors. Contributors ------------ diff --git a/MANIFEST.in b/MANIFEST.in index 4c3546c..f1a78ee 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,14 +1,12 @@ -include AUTHORS.rst -include LICENSE -include README.rst -include requirements.txt - -recursive-exclude * __pycache__ -recursive-exclude * *.py[co] - -recursive-include docs *.rst conf.py Makefile make.bat - -include diffpy.fourigui/version.py - -# If including data files in the package, add them like: -# include path/to/data_file +graft src +graft tests +graft requirements + +include AUTHORS.rst LICENSE*.rst README.rst + +# Exclude all bytecode files and __pycache__ directories +global-exclude *.py[cod] # Exclude all .pyc, .pyo, and .pyd files. +global-exclude .DS_Store # Exclude Mac filesystem artifacts. +global-exclude __pycache__ # Exclude Python cache directories. +global-exclude .git* # Exclude git files and directories. +global-exclude .idea # Exclude PyCharm project settings. diff --git a/README.rst b/README.rst index e22485a..289a00a 100644 --- a/README.rst +++ b/README.rst @@ -90,7 +90,7 @@ Support and Contribute `Diffpy user group `_ is the discussion forum for general questions and discussions about the use of diffpy.fourigui. Please join the diffpy.fourigui users community by joining the Google group. The diffpy.fourigui project welcomes your expertise and enthusiasm! -If you see a bug or want to request a feature, please `report it as an issue `_ and/or `submit a fix as a PR `_. You can also post it to the `Diffpy user group `_. +If you see a bug or want to request a feature, please `report it as an issue `_ and/or `submit a fix as a PR `_. You can also post it to the `Diffpy user group `_. Feel free to fork the project and contribute. To install diffpy.fourigui in a development mode, with its sources being directly used by Python diff --git a/doc/source/license.rst b/doc/source/license.rst index cfab61c..9ae52a9 100644 --- a/doc/source/license.rst +++ b/doc/source/license.rst @@ -9,9 +9,9 @@ OPEN SOURCE LICENSE AGREEMENT ============================= BSD 3-Clause License -Copyright (c) 2024, The Trustees of Columbia University in +Copyright (c) 2024, The Trustees of Columbia University in the City of New York. -All Rights Reserved. +All Rights Reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/news/recut.rst b/news/recut.rst new file mode 100644 index 0000000..6a5739f --- /dev/null +++ b/news/recut.rst @@ -0,0 +1,24 @@ +**Added:** + +* + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* cookiecut to group's Python package standard +* add pip packages under pip.txt + +**Security:** + +* diff --git a/pyproject.toml b/pyproject.toml index 498f3ee..b092493 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["setuptools>=62.0", "setuptools-git-versioning<2"] +requires = ["setuptools>=62.0", "setuptools-git-versioning>=2.0"] build-backend = "setuptools.build_meta" [project] name = "diffpy.fourigui" -dynamic=['version'] +dynamic=['version', 'dependencies'] authors = [ { name="Simon J.L. Billinge group", email="simon.billinge@gmail.com" }, ] @@ -45,9 +45,12 @@ dirty_template = "{tag}" [tool.setuptools.packages.find] where = ["src"] # list of folders that contain the packages (["."] by default) include = ["*"] # package names should match these glob patterns (["*"] by default) -exclude = ["diffpy.fourigui.tests*"] # exclude packages matching these glob patterns (empty by default) +exclude = [] # exclude packages matching these glob patterns (empty by default) namespaces = false # to disable scanning PEP 420 namespaces (true by default) +[tool.setuptools.dynamic] +dependencies = {file = ["requirements/pip.txt"]} + [tool.black] line-length = 115 include = '\.pyi?$' diff --git a/requirements/README.txt b/requirements/README.txt deleted file mode 100644 index dc34909..0000000 --- a/requirements/README.txt +++ /dev/null @@ -1,11 +0,0 @@ -# YOU MAY DELETE THIS FILE AFTER SETTING UP DEPENDENCIES! -# -# This directory is where you should place your project dependencies. -# "pip.txt" should contain all required packages not available on conda. -# All other files should contain only packages available to download from conda. -# build.txt should contain all packages required to build (not run) the project. -# run.txt should contain all packages (including optional packages) required for a user to run the program. -# test.txt should contain all packages required for the testing suite and to ensure all tests pass. -# docs.txt should contain all packages required for building the package documentation page. -# -# YOU MAY DELETE THIS FILE AFTER SETTING UP DEPENDENCIES! diff --git a/requirements/build.txt b/requirements/build.txt index f72d870..e69de29 100644 --- a/requirements/build.txt +++ b/requirements/build.txt @@ -1,2 +0,0 @@ -python -setuptools diff --git a/requirements/conda.txt b/requirements/conda.txt new file mode 100644 index 0000000..9de315b --- /dev/null +++ b/requirements/conda.txt @@ -0,0 +1,5 @@ +h5py +time +tk +matplotlib-base +numpy diff --git a/requirements/pip.txt b/requirements/pip.txt index e69de29..f8febd0 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -0,0 +1,5 @@ +h5py +time +tk +matplotlib +numpy diff --git a/requirements/run.txt b/requirements/run.txt deleted file mode 100644 index e69de29..0000000 diff --git a/requirements/test.txt b/requirements/test.txt index 6f9ccf8..a727786 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -2,4 +2,5 @@ flake8 pytest codecov coverage +pytest-cov pytest-env diff --git a/src/diffpy/__init__.py b/src/diffpy/__init__.py index 586be9d..4d4bf70 100644 --- a/src/diffpy/__init__.py +++ b/src/diffpy/__init__.py @@ -21,4 +21,3 @@ __path__ = extend_path(__path__, __name__) # End of file - diff --git a/src/diffpy/fourigui/fourigui.py b/src/diffpy/fourigui/fourigui.py new file mode 100755 index 0000000..88dd866 --- /dev/null +++ b/src/diffpy/fourigui/fourigui.py @@ -0,0 +1,571 @@ +import time +import tkinter as tk +from tkinter.ttk import Button + +import h5py +import matplotlib +import numpy as np +from matplotlib import pyplot as plt +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk + +matplotlib.use("tkagg") + +WIDTH = 920 +HEIGHT = 630 +XPOS = 300 +YPOS = 100 + + +class Gui(tk.Frame): + def __init__(self): + super().__init__() + self.initUI() + + def initUI(self): + + self.loaded = False # denotes whether a dataset is loaded + self.transformed = False # denotes whether dataset is Fourier transformed + self.cutted = False # denotes whether cutoff frequencies are applied to dataset + self.transcutted = False # denotes whether cutoff frequencies are applied and Fourier transformed + + self.master.title("FouriGUI") + self.pack(fill=tk.BOTH, expand=True) + + print("\nNew Session started ...") + print("Enjoy exploring the beautiful reconstructions in real and in reciprocal space!") + + # 4 frames: + # frame 00: all buttons + # frame 01: plot area + # frame 10: exit button + # frame 11: not used + + # 00 # + # frame 00, upper left + + frame00 = tk.Frame(self) + frame00.place(x=5, y=0) + + filelabel = tk.Label(frame00, text="filename: ") + filelabel.grid(row=0, column=0) + + # row 0: load file area + self.filename_entry = tk.Entry(frame00) + self.filename_entry.grid(row=0, column=1, columnspan=3) + self.filename_entry.insert(0, "/path/data.h5") + + loadbutton = Button(frame00, text="load", command=lambda: self.load_cube()) + loadbutton.grid(row=0, column=4) + + # row 1: change axis area + axislabel = tk.Label(frame00, text="axis: ") + axislabel.grid(row=1, column=0, pady=7, sticky=tk.W) + + self.axis = tk.IntVar() + + rb0 = tk.Radiobutton( + frame00, + text="0", + variable=self.axis, + value=0, + command=lambda: self.plot_plane(), + ) + rb0.grid(row=1, column=1) + rb1 = tk.Radiobutton( + frame00, + text="1", + variable=self.axis, + value=1, + command=lambda: self.plot_plane(), + ) + rb1.grid(row=1, column=2) + rb2 = tk.Radiobutton( + frame00, + text="2", + variable=self.axis, + value=2, + command=lambda: self.plot_plane(), + ) + rb2.grid(row=1, column=3) + + # row 2-4: intensity specs + intlabel = tk.Label(frame00, text="intensity:") + intlabel.grid(row=2, column=0, pady=1, sticky=tk.W) + maxintlabel = tk.Label(frame00, text="max:") + maxintlabel.grid(row=3, column=0, pady=1, sticky=tk.E) + minintlabel = tk.Label(frame00, text="min:") + minintlabel.grid(row=4, column=0, pady=1, sticky=tk.E) + sumintlabel = tk.Label(frame00, text="sum:") + sumintlabel.grid(row=5, column=0, pady=1, sticky=tk.E) + nanratiolabel = tk.Label(frame00, text="nan ratio:") + nanratiolabel.grid(row=6, column=0, pady=1, sticky=tk.E) + globallabel = tk.Label(frame00, text="global", width=7) + globallabel.grid(row=2, column=1) + self.globalmax = tk.Label(frame00, text="") + self.globalmax.grid(row=3, column=1) + self.globalmin = tk.Label(frame00, text="") + self.globalmin.grid(row=4, column=1) + self.globalsum = tk.Label(frame00, text="") + self.globalsum.grid(row=5, column=1) + self.globalnanratio = tk.Label(frame00, text="") + self.globalnanratio.grid(row=6, column=1) + inplanelabel = tk.Label(frame00, text="in plane", width=7) + inplanelabel.grid(row=2, column=2) + self.localmax = tk.Label(frame00, text="") + self.localmax.grid(row=3, column=2) + self.localmin = tk.Label(frame00, text="") + self.localmin.grid(row=4, column=2) + self.localsum = tk.Label(frame00, text="") + self.localsum.grid(row=5, column=2) + self.localnanratio = tk.Label(frame00, text="") + self.localnanratio.grid(row=6, column=2) + colorbarlabel = tk.Label(frame00, text="colorbar") + colorbarlabel.grid(row=2, column=3) + self.colorbarmax = tk.Entry(frame00, width=7) + self.colorbarmax.grid(row=3, column=3) + self.colorbarmin = tk.Entry(frame00, width=7) + self.colorbarmin.grid(row=4, column=3) + set_range = Button(frame00, text="set range", command=lambda: self.colorrange_upd()) + set_range.grid(row=2, column=4) + toglobalmax = Button( + frame00, + text="global max", + command=lambda: self.multiple_funcs( + self.colorbarmax.delete(0, len(self.colorbarmax.get())), + self.colorbarmax.insert(0, self.globalmax["text"]), + ), + ) + toglobalmax.grid(row=3, column=4) + toglobalmin = Button( + frame00, + text="global min", + command=lambda: self.multiple_funcs( + self.colorbarmin.delete(0, len(self.colorbarmin.get())), + self.colorbarmin.insert(0, self.globalmin["text"]), + ), + ) + toglobalmin.grid(row=4, column=4) + + # row 7-8: animation - automatic slicing through the planes + anilabel = tk.Label(frame00, text="animation speed [ms]") + anilabel.grid(row=7, column=3, columnspan=2, sticky=tk.W) + self.anientry = tk.Entry(frame00, width=7) + self.anientry.grid(row=8, column=3) + anibutton = Button(frame00, text="animation", command=lambda: self.animation()) + anibutton.grid(row=8, column=4) + + # row 10-12 Fourier transformation + seperator = tk.Label( + frame00, text=" " + ) # __________________________________________________________________") + seperator.grid(row=9, column=0, columnspan=5) + cutofflabel = tk.Label(frame00, text="cutoff frequency") + cutofflabel.grid(row=10, column=2, columnspan=2) + qminlabel = tk.Label(frame00, text="qmin [px]:") + qminlabel.grid(row=11, column=2, sticky=tk.E) + qmaxlabel = tk.Label(frame00, text="qmax [px]:") + qmaxlabel.grid(row=12, column=2, sticky=tk.E) + self.qminentry = tk.Entry(frame00, width=7) + self.qminentry.grid(row=11, column=3) + self.qmaxentry = tk.Entry(frame00, width=7) + self.qmaxentry.grid(row=12, column=3) + self.cutoff = tk.IntVar() + newcutoffbutton = Button(frame00, text="new cutoff", command=lambda: self.newcutoff()) + newcutoffbutton.grid(row=10, column=4) + cutoffon = tk.Radiobutton( + frame00, + text="on", + variable=self.cutoff, + value=1, + command=lambda: self.applycutoff(), + ) + cutoffon.grid(row=11, column=4, sticky=tk.W) + cutoffoff = tk.Radiobutton( + frame00, + text="off", + variable=self.cutoff, + value=0, + command=lambda: self.redocutuff(), + ) + cutoffoff.grid(row=12, column=4, sticky=tk.W) + + spacelabel = tk.Label(frame00, text="Space Selection") + spacelabel.grid(row=10, column=0, columnspan=2, sticky=tk.W) + self.space = tk.IntVar() + reciprocal = tk.Radiobutton( + frame00, + text="reciprocal space", + variable=self.space, + value=0, + command=lambda: self.ifft(), + pady=5, + ) + reciprocal.grid(row=11, column=0, columnspan=2, sticky=tk.W) + fft = tk.Radiobutton( + frame00, + text="real space", + variable=self.space, + value=1, + command=lambda: self.fft(), + ) + fft.grid(row=12, column=0, columnspan=2, sticky=tk.W) + + # 01 # + # frame 01, upper right + self.frame01 = tk.Frame(self, bg="#cccccc") + self.frame01.place(x=400, y=0) # , height=HEIGHT//2, width=WIDTH//2) + + self.plane_num = tk.IntVar() + + self.slider = tk.Scale( + self.frame01, + variable=self.plane_num, + from_=0, + to=500, + label="slider", + orient=tk.HORIZONTAL, + length=WIDTH // 2, # resolution=-1, + command=lambda x: self.multiple_funcs(self.plot_plane(), self.intensity_upd_local()), + ) + # command=lambda p: self.plot_plane()) + self.slider.grid(row=0, column=0, padx=10, pady=10, sticky=tk.N + tk.E + tk.S + tk.W) + + self.frame01_plotcell = tk.Frame(self.frame01) + self.frame01_plotcell.grid(row=1, column=0, padx=10, pady=10, sticky=tk.N + tk.E + tk.S + tk.W) + + self.frame01_toolbar = tk.Frame(self.frame01) + self.frame01_toolbar.grid(row=2, column=0) + + # 10 # + # frame 10, lower left + frame10 = tk.Frame(self) + frame10.place(x=5, y=HEIGHT - 30) # , height=HEIGHT//2, width=WIDTH//2) + quit = Button( + frame10, + text="exit", + command=lambda: self.multiple_funcs(print("Session ended...\n", self.quit())), + ) + quit.pack(side=tk.TOP) + + # 11 # + # frame 00, lower right + # no functionality + frame11 = tk.Frame(self) + frame11.place(x=WIDTH // 2, y=HEIGHT // 2) # , height=HEIGHT//2, width=WIDTH//2) + + def load_cube(self): + """ + loads 3D array in h5py file format from the filename input panel + 3D array is expected to be a reconstructed reciprocal scattering volume + when executed, one slide perpendicular to the selected axis will be plotted in the plot panel + """ + + filename = self.filename_entry.get() + f = h5py.File(filename, "r") + try: + if "data" in f.keys(): + self.cube = np.array(f["data"]) + elif "rebinned_data" in f.keys(): + self.cube = np.array(f["rebinned_data"]) + except Exception: + raise KeyError( + "- No data found in " + + filename + + " :( ..." + + "\nchange to alternative keys: " + + str(list(f.keys())) + ) + print("- file loaded: {}".format(filename)) + + self.slider.destroy() + self.slider = tk.Scale( + self.frame01, + variable=self.plane_num, + from_=0, + to=len(self.cube) - 1, + label="slider", + orient=tk.HORIZONTAL, + length=WIDTH // 2, # resolution=-1, + command=lambda x: self.multiple_funcs(self.plot_plane(), self.intensity_upd_local()), + ) + self.slider.grid(row=0, column=0, padx=10, pady=10, sticky=tk.N + tk.E + tk.S + tk.W) + + if not self.loaded: + + fig, ax = plt.subplots(figsize=(4.95, 4.95)) + fig = plt.gcf() + DPI = fig.get_dpi() + fig.set_size_inches(500 / float(DPI), 500 / float(DPI)) + + self.plane_num.set(np.shape(self.cube)[0] // 2) + + if self.axis.get() == 0: + self.im = plt.imshow(self.cube[self.plane_num.get(), :, :]) + elif self.axis.get() == 1: + self.im = plt.imshow(self.cube[:, self.plane_num.get(), :]) + elif self.axis.get() == 2: + self.im = plt.imshow(self.cube[:, :, self.plane_num.get()]) + else: + raise ValueError("axis must be 0,1,2") + plt.colorbar(shrink=0.81) + ax.set_xlabel("pixel") + ax.set_ylabel("pixel") + self.canvas = FigureCanvasTkAgg(fig, master=self.frame01_plotcell) + self.toolbar = NavigationToolbar2Tk(self.canvas, self.frame01_toolbar) + self.toolbar.pack(side=tk.LEFT) + # self.toolbar.children['!button6'].pack_forget() + # self.toolbar.children['!button7'].pack_forget() + self.toolbar.update() + self.canvas.draw() + self.canvas.get_tk_widget().pack(side=tk.LEFT, fill=tk.BOTH, expand=1) + self.loaded = True + + else: + self.plot_plane() + self.transformed = False + self.transcutted = False + self.cutted = False + self.cutoff.set(0) + self.space.set(0) + + self.intensity_upd_global() + + def plot_plane(self): + """update plotted plane perpendicular to the selected axis""" + if self.axis.get() == 0: + self.im.set_data(self.cube[self.plane_num.get(), :, :]) + elif self.axis.get() == 1: + self.im.set_data(self.cube[:, self.plane_num.get(), :]) + elif self.axis.get() == 2: + self.im.set_data(self.cube[:, :, self.plane_num.get()]) + else: + raise ValueError("axis must be 0,1,2") + self.canvas.draw() + + def colorrange_upd(self): + """change color range in plot""" + try: + if self.colorbarmin.get() and self.colorbarmax.get(): + vmin = float(self.colorbarmin.get()) + vmax = float(self.colorbarmax.get()) + elif self.colorbarmin.get(): + vmin = float(self.colorbarmin.get()) + vmax = self.globalmax["text"] + elif self.colorbarmax.get(): + vmin = self.globalmin["text"] + vmax = float(self.colorbarmax.get()) + else: + vmin = self.globalmin["text"] + vmax = self.globalmax["text"] + except ValueError: + print("Oops... colorbar range must be a number or empty string.") + self.im.set_clim(vmin, vmax) + self.plot_plane() + + def intensity_upd_local(self): + """show local intensity minimum, maximum and sum of current plotted plane""" + if self.axis.get() == 0: + plane = self.cube[self.plane_num.get(), :, :] + elif self.axis.get() == 1: + plane = self.cube[:, self.plane_num.get(), :] + elif self.axis.get() == 2: + plane = self.cube[:, :, self.plane_num.get()] + nan_ratio = np.count_nonzero(np.isnan(plane)) / plane.size + self.localmax["text"] = "{}".format(np.format_float_scientific(np.nanmax(plane), 1)) + self.localmin["text"] = "{}".format(np.format_float_scientific(np.nanmin(plane), 1)) + self.localsum["text"] = "{}".format(np.format_float_scientific(np.nansum(plane), 1)) + self.localnanratio["text"] = "{}".format(round(nan_ratio, 2)) + + def intensity_upd_global(self): + """show global intensity minimum, maximum and sum of 3D array""" + self.intensity_upd_local() + nan_ratio = np.count_nonzero(np.isnan(self.cube)) / self.cube.size + self.globalmax["text"] = "{}".format(np.format_float_scientific(np.nanmax(self.cube), 1)) + self.globalmin["text"] = "{}".format(np.format_float_scientific(np.nanmin(self.cube), 1)) + self.globalsum["text"] = "{}".format(np.format_float_scientific(np.nansum(self.cube), 1)) + self.globalnanratio["text"] = "{}".format(round(nan_ratio, 2)) + + def fft(self): + """ + Fourier transform 3D array from reciprocal to real space + the origin of reciprocal and real space is expected to be the central voxel + """ + + def perform_fft(fftholder): + time0 = time.time() + fftholder = np.nan_to_num(fftholder) + size = list(fftholder.shape) + fftholder = np.fft.ifftshift(fftholder) + fftholder = np.fft.fftn(fftholder, s=size, norm="ortho") + fftholder = np.fft.fftshift(fftholder) + fftholder = fftholder.real + fftdur = time.time() - time0 + print("- FFT performed in {} sec.".format(round(fftdur, 4))) + return fftholder + + if not self.transformed and not self.transcutted: # no fft at all yet + if not self.cutoff.get(): + self.cube_reci = self.cube + self.cube = perform_fft(self.cube) + self.cube_real = self.cube + self.transformed = True + else: + self.cube_recicut = self.cube + self.cube = perform_fft(self.cube) + self.cube_realcut = self.cube + self.transcutted = True + + elif not self.transformed and self.transcutted: + if not self.cutoff.get(): + self.cube = perform_fft(self.cube_reci) + self.cube_real = self.cube + self.transformed = True + else: + self.cube = self.cube_realcut + + elif self.transformed and not self.transcutted: + if not self.cutoff.get(): + self.cube_reci = self.cube + self.cube = self.cube_real + else: + self.cube = perform_fft(self.cube_recicut) + # self.cube = self.cube_realcut + self.transcutted = True + + else: + if not self.cutoff.get(): + self.cube = self.cube_real + else: + self.cube = self.cube_realcut + + print("- Switching to real space") + + self.plot_plane() + self.intensity_upd_global() + + def ifft(self): + """ + Inverse Fourier transform 3D array from real to reciprocal space + the origin of real and reciprocal space is expected to be the central voxel + """ + if not self.cutoff.get(): + self.cube_real = self.cube + self.cube = self.cube_reci + else: + self.cube_realcut = self.cube + self.cube = self.cube_recicut + + print("- Switching to reciprocal space") + + self.plot_plane() + self.intensity_upd_global() + + def applycutoff(self): + """ + reassign all voxels with distance smaller than qmin and greater than qmax + from the central voxel to 0.0 + qmin, qmax is loaded from the qmin, qmax input panel + currently opperates in units of pixels + """ + if not self.cutted: + + time0 = time.time() + X, Y, Z = self.cube.shape + sphere = np.ones((X, Y, Z)) + qmin = float(self.qminentry.get()) + qmax = float(self.qmaxentry.get()) + # convert qmin to pixels + # convert qmax to pixels + r2_inner = qmin**2 + r2_outer = qmax**2 + XS, YS, ZS = np.meshgrid(np.arange(X), np.arange(Y), np.arange(Z)) + R2 = (XS - X // 2) ** 2 + (YS - Y // 2) ** 2 + (ZS - Z // 2) ** 2 + mask = (R2 <= r2_inner) | (R2 >= r2_outer) + sphere[mask] = np.nan + cutdur = time.time() - time0 + + if self.space.get(): + self.cube_real = self.cube + self.cube = self.cube_reci * sphere + self.cube_recicut = self.cube + print("- Cutoff below {} and beyond {} in {} sec.".format(qmin, qmax, round(cutdur, 4))) + self.fft() + else: + self.cube_reci = self.cube + self.cube = self.cube * sphere + self.cube_recicut = self.cube + self.plot_plane() + self.intensity_upd_global() + print("- Cutoff below {} and beyond {} in {} sec.".format(qmin, qmax, round(cutdur, 4))) + + self.cutted = True + + else: + if self.space.get(): # in real space + self.cube = self.cube_realcut + else: + self.cube = self.cube_recicut + self.plot_plane() + self.intensity_upd_global() + + def redocutuff(self): + if self.space.get(): # in real space + self.cube_realcut = self.cube + if not self.transformed: + self.fft() + self.cube = self.cube_real + else: + self.cube_recicut = self.cube + self.cube = self.cube_reci + self.plot_plane() + self.intensity_upd_global() + + def newcutoff(self): + if self.cutoff.get(): + if self.space.get() and self.transformed: + self.cube = self.cube_real + else: + self.cube = self.cube_reci + self.cutted = False + self.transcutted = False + self.applycutoff() + + def plot_next_plane(self): + n = self.plane_num.get() + if n == len(self.cube[self.axis.get()]) - 1: + n = 0 + else: + n += 1 + self.plane_num.set(n) + self.plot_plane() + + def animation(self): + """ + slices through the 3D array along the selcted axis + """ + try: + if not self.anientry.get(): + anispeed = 1 + else: + anispeed = self.anientry.get() + except ValueError: + print("Oops... animation speed must be an integer > 0 or empty string.") + n = self.plane_num.get() - 1 + while n is not self.plane_num.get(): + self.slider.after(anispeed, self.plot_next_plane()) + self.plot_next_plane() + + def multiple_funcs(*funcs): + for func in funcs: + func + + +def main(): + root = tk.Tk() + root.geometry("{}x{}+{}+{}".format(WIDTH, HEIGHT, XPOS, YPOS)) + Gui() + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/src/diffpy/fourigui/tests/conftest.py b/tests/conftest.py similarity index 100% rename from src/diffpy/fourigui/tests/conftest.py rename to tests/conftest.py diff --git a/src/diffpy/fourigui/tests/debug.py b/tests/debug.py similarity index 100% rename from src/diffpy/fourigui/tests/debug.py rename to tests/debug.py diff --git a/tests/integration_test.py b/tests/integration_test.py new file mode 100644 index 0000000..44388dd --- /dev/null +++ b/tests/integration_test.py @@ -0,0 +1,130 @@ +import unittest + +import h5py +import numpy as np + +from diffpy.fourigui.fourigui import Gui + + +class TestGui(unittest.TestCase): + def setUp(self): + # set up gui + self.test_gui = Gui() + + # set up test data + self.test_sofq = h5py.File("tests/testdata/sofq.h5")["data"] + self.test_sofq_cut_10to40px = h5py.File("tests/testdata/sofq_cut_10to40px.h5")["data"] + self.test_sofq_cut_15to35px = h5py.File("tests/testdata/sofq_cut_15to35px.h5")["data"] + self.test_gofr = h5py.File("tests/testdata/gofr.h5")["data"] + self.test_gofr_cut_10to40px = h5py.File("tests/testdata/gofr_from_sofq_cut_10to40px.h5")["data"] + self.test_gofr_cut_15to35px = h5py.File("tests/testdata/gofr_from_sofq_cut_15to35px.h5")["data"] + + def test_load_cube_testdataset1(self): + # given + self.test_gui.filename_entry.delete(0, "end") + self.test_gui.filename_entry.insert(0, "tests/testdata/sofq.h5") + + # when + self.test_gui.load_cube() + result = self.test_gui.cube + + # then + self.assertTrue(np.allclose(result, self.test_sofq)) + + def test_load_cube_testdataset2(self): + # given + self.test_gui.filename_entry.delete(0, "end") + self.test_gui.filename_entry.insert(0, "tests/testdata/sofq_cut_10to40px.h5") + + # when + self.test_gui.load_cube() + result = self.test_gui.cube + + # then + self.assertTrue(np.allclose(np.nan_to_num(result), np.nan_to_num(self.test_sofq_cut_10to40px))) + + def test_load_cube_testdataset3(self): + # given + self.test_gui.filename_entry.delete(0, "end") + self.test_gui.filename_entry.insert(0, "tests/testdata/sofq_cut_15to35px.h5") + + # when + self.test_gui.load_cube() + result = self.test_gui.cube + + # then + self.assertTrue(np.allclose(np.nan_to_num(result), np.nan_to_num(self.test_sofq_cut_15to35px))) + + def test_fft_testdataset1(self): + # given + self.test_gui.plot_plane = ( + lambda *a, **b: () + ) # overwrite plot_plane which requires not initialized attribute im + self.test_gui.cube = self.test_sofq + + # when + self.test_gui.fft() + result = self.test_gui.cube + + # then + self.assertTrue(np.allclose(result, self.test_gofr)) + + def test_fft_testdataset2(self): + # given + self.test_gui.plot_plane = ( + lambda *a, **b: () + ) # overwrite plot_plane which requires not initialized attribute im + self.test_gui.cube = self.test_sofq_cut_10to40px + + # when + self.test_gui.fft() + result = self.test_gui.cube + + # then + self.assertTrue(np.allclose(result, self.test_gofr_cut_10to40px)) + + def test_fft_testdataset3(self): + # given + self.test_gui.plot_plane = ( + lambda *a, **b: () + ) # overwrite plot_plane which requires not initialized attribute im + self.test_gui.cube = self.test_sofq_cut_15to35px + + # when + self.test_gui.fft() + result = self.test_gui.cube + + # then + self.assertTrue(np.allclose(result, self.test_gofr_cut_15to35px)) + + def test_applycutoff_range1(self): + # given + self.test_gui.plot_plane = lambda *a, **b: () + self.test_gui.cube = self.test_sofq + self.test_gui.qminentry.insert(0, "10") + self.test_gui.qmaxentry.insert(0, "40") + + # when + self.test_gui.applycutoff() + result = self.test_gui.cube + + # then + self.assertTrue(np.allclose(np.nan_to_num(result), np.nan_to_num(self.test_sofq_cut_10to40px))) + + def test_applycutoff_range2(self): + # given + self.test_gui.plot_plane = lambda *a, **b: () + self.test_gui.cube = self.test_sofq + self.test_gui.qminentry.insert(0, "15") + self.test_gui.qmaxentry.insert(0, "35") + + # when + self.test_gui.applycutoff() + result = self.test_gui.cube + + # then + self.assertTrue(np.allclose(np.nan_to_num(result), np.nan_to_num(self.test_sofq_cut_15to35px))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..4fc6f6a --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,10 @@ +"""Unit tests for __version__.py +""" + +import diffpy.fourigui + + +def test_package_version(): + """Ensure the package version is defined and not set to the initial placeholder.""" + assert hasattr(diffpy.fourigui, "__version__") + assert diffpy.fourigui.__version__ != "0.0.0" diff --git a/src/diffpy/fourigui/tests/__init__.py b/tests/testdata/__init__.py similarity index 100% rename from src/diffpy/fourigui/tests/__init__.py rename to tests/testdata/__init__.py diff --git a/tests/testdata/dummydata.h5 b/tests/testdata/dummydata.h5 new file mode 100644 index 0000000..e6763f5 Binary files /dev/null and b/tests/testdata/dummydata.h5 differ diff --git a/tests/testdata/gofr.h5 b/tests/testdata/gofr.h5 new file mode 100644 index 0000000..ef5e93f Binary files /dev/null and b/tests/testdata/gofr.h5 differ diff --git a/tests/testdata/gofr_.h5 b/tests/testdata/gofr_.h5 new file mode 100644 index 0000000..d860773 Binary files /dev/null and b/tests/testdata/gofr_.h5 differ diff --git a/tests/testdata/gofr_cut.h5 b/tests/testdata/gofr_cut.h5 new file mode 100644 index 0000000..6a9096f Binary files /dev/null and b/tests/testdata/gofr_cut.h5 differ diff --git a/tests/testdata/gofr_from_sofq_cut_10to40px.h5 b/tests/testdata/gofr_from_sofq_cut_10to40px.h5 new file mode 100644 index 0000000..583c0f9 Binary files /dev/null and b/tests/testdata/gofr_from_sofq_cut_10to40px.h5 differ diff --git a/tests/testdata/gofr_from_sofq_cut_15to35px.h5 b/tests/testdata/gofr_from_sofq_cut_15to35px.h5 new file mode 100644 index 0000000..f7cd656 Binary files /dev/null and b/tests/testdata/gofr_from_sofq_cut_15to35px.h5 differ diff --git a/tests/testdata/make_testdata.py b/tests/testdata/make_testdata.py new file mode 100644 index 0000000..fb65ea9 --- /dev/null +++ b/tests/testdata/make_testdata.py @@ -0,0 +1,52 @@ +import h5py +import numpy as np + + +def cutcube(fname_uncut_cube, fname_cut_cube, qmin, qmax): + + cube = h5py.File(fname_uncut_cube, "r")["data"] + + X, Y, Z = cube.shape + sphere = np.ones((X, Y, Z)) + r2_inner = qmin**2 + r2_outer = qmax**2 + XS, YS, ZS = np.meshgrid(np.arange(X), np.arange(Y), np.arange(Z)) + R2 = (XS - X // 2) ** 2 + (YS - Y // 2) ** 2 + (ZS - Z // 2) ** 2 + mask = (R2 <= r2_inner) | (R2 >= r2_outer) + sphere[mask] = np.nan + + f = h5py.File(fname_cut_cube, "w") + f.create_dataset("data", data=cube * sphere) + f.close() + + +def fftcube(fname_reci, fname_real): + + fftholder = h5py.File(fname_reci, "r")["data"] + + fftholder = np.nan_to_num(fftholder) + size = list(fftholder.shape) + fftholder = np.fft.ifftshift(fftholder) + fftholder = np.fft.fftn(fftholder, s=size, norm="ortho") + fftholder = np.fft.fftshift(fftholder) + fftholder = fftholder.real + + f = h5py.File(fname_real, "w") + f.create_dataset("data", data=fftholder) + f.close() + + +def dummydata(fname="dummydata.h5"): + dummydata = np.ones((3, 3, 3)) + + f = h5py.File(fname, "w") + f.create_dataset("data", data=dummydata) + f.close() + + +# cutcube("sofq.h5", "sofq_cut_10to40px.h5", 10, 40) +# cutcube("sofq.h5", "sofq_cut_15to35px.h5", 15, 35) +# fftcube("sofq.h5", "gofr.h5") +# fftcube("sofq_cut_10to40px.h5", "gofr_from_sofq_cut_10to40px.h5") +# fftcube("sofq_cut_15to35px.h5", "gofr_from_sofq_cut_15to35px.h5") +dummydata() diff --git a/tests/testdata/sofq.h5 b/tests/testdata/sofq.h5 new file mode 100644 index 0000000..72327c7 Binary files /dev/null and b/tests/testdata/sofq.h5 differ diff --git a/tests/testdata/sofq_cut_10to40px.h5 b/tests/testdata/sofq_cut_10to40px.h5 new file mode 100644 index 0000000..a116f88 Binary files /dev/null and b/tests/testdata/sofq_cut_10to40px.h5 differ diff --git a/tests/testdata/sofq_cut_15to35px.h5 b/tests/testdata/sofq_cut_15to35px.h5 new file mode 100644 index 0000000..2b5ab43 Binary files /dev/null and b/tests/testdata/sofq_cut_15to35px.h5 differ diff --git a/tests/unit_test.py b/tests/unit_test.py new file mode 100644 index 0000000..b3b9ce3 --- /dev/null +++ b/tests/unit_test.py @@ -0,0 +1,150 @@ +import unittest + +import h5py + +from diffpy.fourigui.fourigui import Gui + + +class TestGui(unittest.TestCase): + def setUp(self): + # set up gui + self.test_gui = Gui() + + # set up dummy data + self.dummydata = h5py.File("tests/testdata/dummydata.h5")["data"] + + def test_init(self): + self.assertFalse(self.test_gui.loaded) + self.assertFalse(self.test_gui.transformed) + self.assertFalse(self.test_gui.cutted) + self.assertFalse(self.test_gui.transcutted) + self.assertFalse(self.test_gui.cutoff.get()) + self.assertFalse(self.test_gui.space.get()) + + def test_load_cube_nothing_loaded(self): + # given + self.test_gui.filename_entry.delete(0, "end") + self.test_gui.filename_entry.insert(0, "tests/testdata/dummydata.h5") + + # when + self.test_gui.load_cube() + + # then + self.assertTrue(self.test_gui.loaded) + + def test_load_cube_something_loaded(self): + # given + self.test_gui.loaded + self.test_gui.filename_entry.delete(0, "end") + self.test_gui.filename_entry.insert(0, "tests/testdata/dummydata.h5") + + # when + self.test_gui.load_cube() + + # then + self.assertTrue(self.test_gui.loaded) + + def test_fft_000(self): + # given + self.test_gui.cube = self.dummydata + self.test_gui.plot_plane = ( + lambda *a, **b: () + ) # overwrite plot_plane which requires not initialized attribute im + self.test_gui.transformed = False + self.test_gui.transcutted = False + self.test_gui.cutoff.set(0) + + # when + self.test_gui.fft() + + # then + self.assertTrue(self.test_gui.transformed and not self.test_gui.transcutted) + + def test_fft_010(self): + # given + self.test_gui.cube = self.dummydata + self.test_gui.plot_plane = ( + lambda *a, **b: () + ) # overwrite plot_plane which requires not initialized attribute im + self.test_gui.transformed = False + self.test_gui.transcutted = False + self.test_gui.cutoff.set(1) + + # when + self.test_gui.fft() + + # then + self.assertTrue(not self.test_gui.transformed and self.test_gui.transcutted) + # self.assertTrue(self.test_gui.cutted) + + def test_fft_001(self): + # given + self.test_gui.cube = self.dummydata + self.test_gui.cube_reci = self.dummydata + self.test_gui.plot_plane = ( + lambda *a, **b: () + ) # overwrite plot_plane which requires not initialized attribute im + self.test_gui.transformed = False + self.test_gui.transcutted = True + self.test_gui.cutoff.set(0) + + # when + self.test_gui.fft() + + # then + self.assertTrue(self.test_gui.transformed and self.test_gui.transcutted) + + def test_fft_011(self): + # given + self.test_gui.cube = self.dummydata + self.test_gui.cube_realcut = self.dummydata + self.test_gui.plot_plane = ( + lambda *a, **b: () + ) # overwrite plot_plane which requires not initialized attribute im + self.test_gui.transformed = False + self.test_gui.transcutted = True + self.test_gui.cutoff.set(1) + + # when + self.test_gui.fft() + + # then + self.assertTrue(not self.test_gui.transformed and self.test_gui.transcutted) + + def test_fft_101(self): + # given + self.test_gui.cube = self.dummydata + self.test_gui.cube_real = self.dummydata + self.test_gui.plot_plane = ( + lambda *a, **b: () + ) # overwrite plot_plane which requires not initialized attribute im + self.test_gui.transformed = True + self.test_gui.transcutted = True + self.test_gui.cutoff.set(0) + + # when + self.test_gui.fft() + + # then + self.assertTrue(self.test_gui.transformed and self.test_gui.transcutted) + + def test_fft_111(self): + # given + self.test_gui.cube = self.dummydata + self.test_gui.cube_realcut = self.dummydata + self.test_gui.plot_plane = ( + lambda *a, **b: () + ) # overwrite plot_plane which requires not initialized attribute im + self.test_gui.transformed = True + self.test_gui.transcutted = True + self.test_gui.cutoff.set(1) + + # when + self.test_gui.fft() + + # then + self.assertTrue(self.test_gui.transformed and self.test_gui.transcutted) + + +if __name__ == "__main__": + unittest.main()