From 10581fe55e366f54bcd290d48ac64435a7f5fafb Mon Sep 17 00:00:00 2001 From: vyrjana <41105805+vyrjana@users.noreply.github.com> Date: Mon, 3 Apr 2023 10:45:22 +0300 Subject: [PATCH] Merged dev-4-2-0-rc branch --- CHANGELOG.md | 17 + build.sh | 22 + docs/source/conf.py | 4 + docs/source/guide.rst | 2 +- docs/source/guide_data.rst | 55 +- docs/source/guide_drt.rst | 2 +- docs/source/guide_fitting.rst | 12 +- ...command_palette.rst => guide_palettes.rst} | 19 +- docs/source/guide_validation.rst | 5 + docs/source/substitutions.rst | 2 + post-build.py | 109 +++- pre-build.py | 19 + requirements.txt | 2 +- setup.py | 4 +- src/deareis/config/defaults.py | 14 + src/deareis/data/drt.py | 5 +- src/deareis/data/fitting.py | 3 + src/deareis/data/kramers_kronig.py | 3 + src/deareis/data/plotting.py | 3 + src/deareis/data/project.py | 3 + src/deareis/data/simulation.py | 3 + src/deareis/data/zhit.py | 3 + src/deareis/enums.py | 18 + src/deareis/gui/data_sets/__init__.py | 17 +- src/deareis/gui/data_sets/copy_mask.py | 19 +- .../gui/data_sets/parallel_impedance.py | 550 ++++++++++++++++++ .../gui/data_sets/subtract_impedance.py | 27 +- .../gui/data_sets/toggle_data_points.py | 19 +- src/deareis/gui/drt.py | 90 ++- src/deareis/gui/fitting/__init__.py | 58 +- src/deareis/gui/kramers_kronig/__init__.py | 6 +- src/deareis/gui/palettes/__init__.py | 22 + .../{command_palette.py => palettes/base.py} | 268 ++++----- src/deareis/gui/palettes/command.py | 104 ++++ src/deareis/gui/palettes/data_set.py | 104 ++++ src/deareis/gui/palettes/result.py | 154 +++++ src/deareis/gui/plots/base.py | 2 + src/deareis/gui/project.py | 34 +- src/deareis/keybindings/__init__.py | 9 + src/deareis/program/__init__.py | 4 + src/deareis/program/data_sets.py | 39 ++ src/deareis/signals.py | 3 + src/deareis/state.py | 26 +- src/deareis/themes.py | 70 ++- src/deareis/tooltips/data_sets.py | 5 +- src/deareis/tooltips/drt.py | 5 +- src/deareis/tooltips/fitting.py | 9 + src/deareis/tooltips/general.py | 11 +- src/deareis/version.py | 2 +- tests/test_gui.py | 134 ++++- version.txt | 2 +- 51 files changed, 1856 insertions(+), 266 deletions(-) rename docs/source/{guide_command_palette.rst => guide_palettes.rst} (55%) create mode 100644 src/deareis/gui/data_sets/parallel_impedance.py create mode 100644 src/deareis/gui/palettes/__init__.py rename src/deareis/gui/{command_palette.py => palettes/base.py} (56%) create mode 100644 src/deareis/gui/palettes/command.py create mode 100644 src/deareis/gui/palettes/data_set.py create mode 100644 src/deareis/gui/palettes/result.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3290f20..57e46da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +# 4.2.0 (2023/04/03) + +- Added support for choosing between multiple approaches to suggesting the regularization parameter (lambda) in DRT methods utilizing Tikhonov regularization. +- Added a `Data set palette` for searching and selecting data sets via a window similar to the `Command palette`. +- Added a `Result palette` for searching and selecting various results (depending on the context) via a window similar to the `Command palette`. +- Added an `Add parallel impedance` window, which is accessible via the `Process` button in the `Data sets` tab. This is useful for, e.g., Kramers-Kronig testing impedance data that include negative differential resistances. +- Updated the table of fitted parameters in the `Fitting` tab to highlight parameters with large estimated errors. +- Updated the table of statistics in the `Fitting` tab to highlight values that may be indicative of issues. +- Updated tooltips. +- Refactored code. +- Fixed a bug that caused two click of the `Accept` button in the `Subtract impedance` window after opening and closing the circuit editor. +- Fixed a bug that caused an exception when opening and closing the circuit editor in the `Subtract impedance` window two or more times in a row. +- Fixed a bug where loading a simulation result as a data set would also cause the `Simulation` tab to switch to the latest simulation result. +- Fixed a mistake in the docstring of the DRTResult class. +- Possibly fixed a bug where resizing signals could sometimes cause an exception to occur while launching the program. + + # 4.1.0 (2023/03/22) - Added a `Copy` button next to the fit results in the `Subtract impedance` window. This button can be used to copy a fitted circuit to the `Circuit` option above. diff --git a/build.sh b/build.sh index e3c0c54..8e8fbd9 100644 --- a/build.sh +++ b/build.sh @@ -1,4 +1,23 @@ #!/bin/bash +# DearEIS is licensed under the GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.html). +# Copyright 2023 DearEIS developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The licenses of DearEIS' dependencies and/or sources of portions of code are included in +# the LICENSES folder. + # Stop when a non-zero exit code is encountered set -e @@ -85,6 +104,9 @@ python3 -m build # Validate the source and wheel distributions validate_tar validate_wheel +if [ "$1" == "distros" ]; then + exit +fi # Update documentation # - The contents of ./dist/html should be committed to the gh-pages branch diff --git a/docs/source/conf.py b/docs/source/conf.py index fdc62d2..e362222 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -36,6 +36,10 @@ autodoc_typehints = "description" autodoc_typehints_format = "short" +plot_formats = ["svg", "pdf"] +plot_html_show_formats = False +plot_rcparams = {"savefig.transparent": True} + def autodoc_skip_member_handler(app, what, name, obj, skip, options): module_string = str(getmodule(obj)) diff --git a/docs/source/guide.rst b/docs/source/guide.rst index 1585c4e..c3f3d18 100644 --- a/docs/source/guide.rst +++ b/docs/source/guide.rst @@ -22,5 +22,5 @@ Here are some quick guides to getting started with DearEIS. guide_plotting guide_batch guide_settings - guide_command_palette + guide_palettes guide_api diff --git a/docs/source/guide_data.rst b/docs/source/guide_data.rst index 9aed63d..56639f5 100644 --- a/docs/source/guide_data.rst +++ b/docs/source/guide_data.rst @@ -46,7 +46,7 @@ Several different file formats are supported: Additional file formats may be supported in the future. Not all CSV files and spreadsheets are necessarily supported as-is but the parsing of those types of files should be quite flexible in terms of, e.g., the characters that are used as separators. -The parsers expect to find at least a column with frequencies (Hz) and columns for either the real and imaginary parts of the impedance (ohm), or the absolute magnitude (ohm) and the phase angle/shift (degrees). +The parsers expect to find at least a column with frequencies (Hz) and columns for either the real and imaginary parts of the impedance (|ohm|), or the absolute magnitude (|ohm|) and the phase angle/shift (degrees). The supported column headers are: - frequency: ``frequency``, ``freq``, or ``f`` @@ -104,7 +104,7 @@ If multiple data sets will need to have the same (or very similar) masks, then t Processing data sets -------------------- -DearEIS includes a few functions for processing data sets: averaging, interpolation, and subtraction. +DearEIS includes a few functions for processing data sets: averaging, interpolation, addition of parallel impedances, and subtraction of impedances. All of these functions are available via the **Process** button that can be found above the table of data points (:numref:`data_tab`). The results of these functions are added to the project as a new data set (i.e., without getting rid of the original data set). @@ -152,9 +152,54 @@ Alternatively, if the smoothing and interpolation cannot provide a reasonable re \clearpage +.. _parallel impedances: -Subtraction -~~~~~~~~~~~ +Adding parallel impedances +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Impedance data that include negative differential resistances cannot be validated directly using the included implementations of linear Kramers-Kronig tests. +Kramers-Kronig tests can be performed on such data either after transforming it into admittance data (not currently supported) or after adding a suitable parallel resistance to the impedance data. +The addition of a parallel resistance does not affect the Kramers-Kronig compliance of the data. + +.. plot:: + :alt: A circuit where the a parallel resistance, R, has been added to the original impedance data, Z. + + from pyimpspec import parse_cdc + # A Warburg impedance is used here just to have two different symbols + circuit = parse_cdc("(WR)") + elements = circuit.get_elements() + custom_labels = { + elements[0]: r"$Z_{\rm data}$", + elements[1]: r"$R_{\rm par}$", + } + circuit.to_drawing(custom_labels=custom_labels).draw() + + +The magnitude of the parallel resistance to add depends on the original impedance data. +In the example below (:numref:`parallel_figure`), a resistance of 30 |ohm| was chosen since the absolute value of the real impedance at the lowest frequency was approximately 50 |ohm| (i.e., the point near the x-axis on the left-hand side). + + +.. note:: + + Equivalent circuits can be fitted to the original impedance data that include negative differential resistances provided that negative resistances are allowed (i.e., the lower limits of resistances are disabled or modified prior to fitting). + +.. _parallel_figure: +.. figure:: https://raw.githubusercontent.com/wiki/vyrjana/DearEIS/images/data-sets-tab-parallel.png + :alt: Addition of parallel impedance to impedance data + + An impedance spectrum that includes a negative differential resistance was generated for this example (marked here as **Before**). + Performing Kramers-Kronig tests using the implementations included in DearEIS on this impedance data would incorrectly indicate non-compliance even for compliant impedance data. + Adding a parallel resistance of 30 |ohm| produces impedance data (marked here as **After**) that can be validated. + The added parallel resistance is always Kramers-Kronig compliant, which means that the compliance of the resulting circuit and its impedance data depends on the compliance of the original data. + + +.. raw:: latex + + \clearpage + + +Subtracting impedances +~~~~~~~~~~~~~~~~~~~~~~ The recorded impedances can also be corrected by subtracting one of the following (:numref:`subtraction_figure`): @@ -169,7 +214,7 @@ This feature can be used to, e.g., correct for some aspect of a measurement setu .. figure:: https://raw.githubusercontent.com/wiki/vyrjana/DearEIS/images/data-sets-tab-subtraction.png :alt: Subtraction of impedances from a recorded spectrum - A resistance of 100 ohm is subtracted from a data set. + A resistance of 100 |ohm| is subtracted from a data set. .. raw:: latex diff --git a/docs/source/guide_drt.rst b/docs/source/guide_drt.rst index 2f90e8f..d3235c9 100644 --- a/docs/source/guide_drt.rst +++ b/docs/source/guide_drt.rst @@ -57,7 +57,7 @@ The overlay plots shown below are created using the **Plotting** tab (more infor Additional DRT spectra, which were obtained by fitting **R(RC)(RQ)** circuits and calculating the DRT using the m(RQ)fit method, overlaid on top of :numref:`drt_overlay`. The presence of the outlier has shifted the peaks toward lower time constants (original). The m(RQ)fit method is less sensitive to the omission of the outlier as can be seen from the two DRT spectra (omitted and interpolated) that are almost identical. - The two latter spectra also have, e.g., their left-most peaks in the correct position of approximately 0.00016 s which is the expected value based on the known resistance and capacitance values (200 ohm and 0.8 :math:`\mathrm{\mu F}`, respectively) of the circuit that was used to generate the data sets. + The two latter spectra also have, e.g., their left-most peaks in the correct position of approximately 0.00016 s which is the expected value based on the known resistance and capacitance values (200 |ohm| and 0.8 :math:`\mathrm{\mu F}`, respectively) of the circuit that was used to generate the data sets. .. raw:: latex diff --git a/docs/source/guide_fitting.rst b/docs/source/guide_fitting.rst index 3d548d6..40d8574 100644 --- a/docs/source/guide_fitting.rst +++ b/docs/source/guide_fitting.rst @@ -25,7 +25,17 @@ Different iterative methods and weights are available. If one or both of these settings are set to **Auto**, then combinations of iterative method(s) and weight(s) are used to perform multiple fits in parallel and the best fit is returned. The results are presented in the form of a table containing the fitted parameter values (and, if possible, error estimates for the fitted parameter values), a table containing statistics pertaining to the quality of the fit, three plots (Nyquist, Bode, and relative errors of the fit), and a preview of the circuit that was fitted to the data set. -If you hover the mouse cursor over cells in the tables, then you can get additional information (e.g., more precise values or explanations). +If you hover the mouse cursor over the cells in the tables, then you can get additional information (e.g., more precise values or explanations). + +.. note:: + + It may not always be possible to estimate errors for fitted parameters. + Common causes include: + + - A parameter's fitted value is close to the parameter's lower or upper limit. + - An inappropriate equivalent circuit has been chosen. + - The maximum number of function evaluations is set too low. + - The data contains no noise and the equivalent circuit is very good at reproducing the data. Equivalent circuits diff --git a/docs/source/guide_command_palette.rst b/docs/source/guide_palettes.rst similarity index 55% rename from docs/source/guide_command_palette.rst rename to docs/source/guide_palettes.rst index 6db90d4..d967cb3 100644 --- a/docs/source/guide_command_palette.rst +++ b/docs/source/guide_palettes.rst @@ -1,14 +1,21 @@ .. include:: ./substitutions.rst +Palettes +======== + Command palette -=============== +--------------- DearEIS supports the use of keybindings to perform many but not all of the actions available in the various windows and tabs (e.g., switch to a specific tab, switch to a certain plot type, a Kramers-Kronig test, or perform a Kramers-Kronig test). These keybindings are in many cases similar from window to window and tab to tab, and the keybindings can be reassigned via the corresponding settings window. However, in some cases the keybindings are unique to the window (e.g., the file dialog). -When there isn't a modal/popup window open, then it is possible to perform actions via the **Command palette** (:numref:`command_palette`) that can be opened by default via ``Ctrl+P``. +When a modal/popup window isn't open, then it is possible to perform actions via the **Command palette** (:numref:`command_palette`) that can be opened by default via ``Ctrl+P``. The contents of the list of actions depends upon the context (e.g., which tab is currently open). +The list of actions can be navigated using, e.g., the arrow keys. +Alternatively, the input field at the top can be used to search of a specific action. +If the input field is empty, then the order of the options depends upon how recently an option was chosen. +This should help with finding actions that are used frequently. .. _command_palette: .. figure:: https://raw.githubusercontent.com/wiki/vyrjana/DearEIS/images/command-palette.png @@ -18,6 +25,14 @@ The contents of the list of actions depends upon the context (e.g., which tab is Actions can be navigated with the ``Up/Down`` arrow keys, ``Page Up/Down`` keys, and ``Home/End`` keys. The window also supports fuzzy matching for finding a specific action (e.g., ``saw`` should bring the ``Show the 'About' window`` action to the top). + +Data set and result palettes +---------------------------- + +The **Data set palette** and **Result palette** are similar to the **Command palette** but they instead facilitate switching between data sets or various results depending on the current tab (Kramers-Kronig test results, fit results, plots, etc.). +The **Data set palette** and **Result palette** can be opened by default via ``Ctrl+Shift+P`` and ``Ctrl+Shift+Alt+P``, respectively. + + .. raw:: latex \clearpage diff --git a/docs/source/guide_validation.rst b/docs/source/guide_validation.rst index a4923fe..b64ce5c 100644 --- a/docs/source/guide_validation.rst +++ b/docs/source/guide_validation.rst @@ -34,6 +34,11 @@ An additional weight is also used in the **Exploratory** mode when suggesting th The test results are presented in the form of a table of statistics (e.g., |pseudo chi-squared|) and different plots such as one of the relative residuals of the fit. +.. note:: + + See :ref:`parallel impedances` for information about how to process impedance data that include negative differential resistances before attempting to validate the data using the included implementations of the linear Kramers-Kronig tests. + + Exploratory mode ~~~~~~~~~~~~~~~~ diff --git a/docs/source/substitutions.rst b/docs/source/substitutions.rst index 7285b8b..11ccf60 100644 --- a/docs/source/substitutions.rst +++ b/docs/source/substitutions.rst @@ -26,6 +26,8 @@ .. |lambda| replace:: :math:`\lambda` .. |chi-squared| replace:: :math:`\chi^2` .. |pseudo chi-squared| replace:: :math:`\chi^2_{ps.}` +.. |ohm| replace:: :math:`\Omega` +.. |degrees| replace:: :math:`^{\circ}` .. functions .. |get_default_num_procs| replace:: :func:`~deareis.get_default_num_procs` diff --git a/post-build.py b/post-build.py index 4bf7e31..37cf1b4 100644 --- a/post-build.py +++ b/post-build.py @@ -1,3 +1,24 @@ +# DearEIS is licensed under the GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.html). +# Copyright 2023 DearEIS developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The licenses of DearEIS' dependencies and/or sources of portions of code are included in +# the LICENSES folder. + +from dataclasses import dataclass +from datetime import date from os import ( makedirs, remove, @@ -9,12 +30,19 @@ join, splitext, ) +from re import search from shutil import ( copy, copytree, rmtree, ) -from typing import List +from typing import ( + IO, + List, + Match, + Optional, + Union, +) def copy_html(src: str, dst: str): @@ -32,9 +60,9 @@ def copy_html(src: str, dst: str): in ( ".html", ".js", - ".py", ".png", - ".pdf", + ".py", + ".svg", ) ] dirs: List[str] = ["_images", "_static", "_sources"] @@ -49,6 +77,7 @@ def copy_html(src: str, dst: str): def copy_pdf(src: str, dst: str, name: str, version_path: str): version: str = "" + fp: IO with open(version_path, "r") as fp: version = fp.read().strip().replace(".", "-") assert version != "" @@ -59,6 +88,79 @@ def copy_pdf(src: str, dst: str, name: str, version_path: str): copy(src, dst) +@dataclass(frozen=True) +class Version: + major: int + minor: int + patch: int + year: int + month: int + day: int + + +def validate_changelog(path: str): + def parse_version(match: Match) -> Version: + return Version( + major=int(match.group("major")), + minor=int(match.group("minor")), + patch=int(match.group("patch")), + year=int(match.group("year")), + month=int(match.group("month")), + day=int(match.group("day")), + ) + + def validate_date( + version: Version, comparison: Union[Version, date] = date.today() + ): + assert version.year <= comparison.year, (version, comparison) + assert 1 <= version.month <= 12, version + assert 1 <= version.day <= 31, version + if version.year == comparison.year: + assert version.month <= comparison.month, (version, comparison) + if version.month == comparison.month: + assert version.day <= comparison.day, (version, comparison) + + def validate_version(earlier: Version, current: Version): + assert earlier.major <= current.major, (earlier, current) + if earlier.major < current.major: + return + assert earlier.minor <= current.minor, (earlier, current) + if earlier.minor < current.minor: + return + assert earlier.patch < current.patch, (earlier, current) + + assert exists(path), path + fp: IO + with open(path, "r") as fp: + lines: List[str] = fp.readlines() + pattern: str = ( + r"# (?P\d+)\.(?P\d+)\.(?P\d+)" + r" \((?P\d{4})/(?P\d{2})/(?P\d{2})\)" + ) + try: + match: Optional[Match] = search(pattern, lines.pop(0)) + assert match is not None, pattern + versions: List[Version] = list( + map( + parse_version, + [match] + + [ + match + for match in map(lambda _: search(pattern, _), lines) + if match is not None + ], + ) + ) + list(map(validate_date, versions)) + while len(versions) > 1: + current: Version = versions.pop(0) + earlier = versions[0] + validate_date(earlier, current) + validate_version(earlier, current) + except AssertionError: + raise Exception("The changelog needs to be updated!") + + if __name__ == "__main__": copy_html( src="./docs/build/html", @@ -70,3 +172,4 @@ def copy_pdf(src: str, dst: str, name: str, version_path: str): name="DearEIS", version_path="./version.txt", ) + validate_changelog("./CHANGELOG.md") diff --git a/pre-build.py b/pre-build.py index 43f8654..b511e9d 100644 --- a/pre-build.py +++ b/pre-build.py @@ -1,3 +1,22 @@ +# DearEIS is licensed under the GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.html). +# Copyright 2023 DearEIS developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The licenses of DearEIS' dependencies and/or sources of portions of code are included in +# the LICENSES folder. + from typing import ( List, IO, diff --git a/requirements.txt b/requirements.txt index c018e0e..18a14a8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ dearpygui==1.8.0 -pyimpspec~=4.0 +pyimpspec~=4.1 requests~=2.28 \ No newline at end of file diff --git a/setup.py b/setup.py index 0a103cc..32f9c6a 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,7 @@ dependencies = [ "dearpygui==1.8.0", # Used to implement the GUI. - "pyimpspec~=4.0", # Used for parsing, fitting, and analyzing impedance spectra. + "pyimpspec~=4.1", # Used for parsing, fitting, and analyzing impedance spectra. "requests~=2.28", # Used to check package status on PyPI. ] @@ -33,7 +33,7 @@ # The version number defined below is propagated to /src/deareis/version.py # when running this script. -version = "4.1.0" +version = "4.2.0" if __name__ == "__main__": with open("requirements.txt", "w") as fp: diff --git a/src/deareis/config/defaults.py b/src/deareis/config/defaults.py index 9608d22..908e7b9 100644 --- a/src/deareis/config/defaults.py +++ b/src/deareis/config/defaults.py @@ -127,6 +127,20 @@ mod_shift=False, action=Action.SHOW_COMMAND_PALETTE, ), + Keybinding( + key=dpg.mvKey_P, + mod_alt=False, + mod_ctrl=True, + mod_shift=True, + action=Action.SHOW_DATA_SET_PALETTE, + ), + Keybinding( + key=dpg.mvKey_P, + mod_alt=True, + mod_ctrl=True, + mod_shift=True, + action=Action.SHOW_RESULT_PALETTE, + ), Keybinding( key=dpg.mvKey_S, mod_alt=False, diff --git a/src/deareis/data/drt.py b/src/deareis/data/drt.py index a59782f..6ab2c59 100644 --- a/src/deareis/data/drt.py +++ b/src/deareis/data/drt.py @@ -309,7 +309,7 @@ class DRTResult: imaginary_gammas: Gammas The gamma values calculated based the imaginary part of the impedance data. - Only non-empty when the TR-RBF method has been used. + Only non-empty when the BHT method has been used. frequencies: Frequencies The frequencies of the analyzed data set. @@ -370,6 +370,9 @@ class DRTResult: def __repr__(self) -> str: return f"DRTResult ({self.get_label()}, {hex(id(self))})" + def __hash__(self) -> int: + return int(self.uuid, 16) + @classmethod def from_dict( Class, dictionary: dict, data: Optional[DataSet] = None diff --git a/src/deareis/data/fitting.py b/src/deareis/data/fitting.py index 3325d12..4b99f06 100644 --- a/src/deareis/data/fitting.py +++ b/src/deareis/data/fitting.py @@ -338,6 +338,9 @@ def __post_init__(self): self._cached_frequencies: Dict[int, Frequencies] = {} self._cached_impedances: Dict[int, ComplexImpedances] = {} + def __hash__(self) -> int: + return int(self.uuid, 16) + def __repr__(self) -> str: return f"FitResult ({self.get_label()}, {hex(id(self))})" diff --git a/src/deareis/data/kramers_kronig.py b/src/deareis/data/kramers_kronig.py index 6d6ac44..538d109 100644 --- a/src/deareis/data/kramers_kronig.py +++ b/src/deareis/data/kramers_kronig.py @@ -252,6 +252,9 @@ def __post_init__(self): self._cached_frequencies: Dict[int, Frequencies] = {} self._cached_impedances: Dict[int, ComplexImpedances] = {} + def __hash__(self) -> int: + return int(self.uuid, 16) + def __repr__(self) -> str: return f"TestResult ({self.get_label()}, {hex(id(self))})" diff --git a/src/deareis/data/plotting.py b/src/deareis/data/plotting.py index a6c73b4..4e7c8a6 100644 --- a/src/deareis/data/plotting.py +++ b/src/deareis/data/plotting.py @@ -248,6 +248,9 @@ class PlotSettings: themes: Dict[str, int] # UUID: DPG UUID uuid: str + def __hash__(self) -> int: + return int(self.uuid, 16) + def __eq__(self, other) -> bool: try: assert isinstance(other, type(self)), other diff --git a/src/deareis/data/project.py b/src/deareis/data/project.py index f97fe98..4139ace 100644 --- a/src/deareis/data/project.py +++ b/src/deareis/data/project.py @@ -146,6 +146,9 @@ def __init__(self, *args, **kwargs): self._is_new: bool = False self.update(*args, **kwargs) + def __hash__(self) -> int: + return int(self.uuid, 16) + def __repr(self) -> str: return f"Project ({self.get_label()}, {hex(id(self))})" diff --git a/src/deareis/data/simulation.py b/src/deareis/data/simulation.py index 324a119..2eb25ad 100644 --- a/src/deareis/data/simulation.py +++ b/src/deareis/data/simulation.py @@ -181,6 +181,9 @@ def __post_init__(self): num_per_decade=self.settings.num_per_decade ) + def __hash__(self) -> int: + return int(self.uuid, 16) + def __repr__(self) -> str: return f"SimulationResult ({self.get_label()}, {hex(id(self))})" diff --git a/src/deareis/data/zhit.py b/src/deareis/data/zhit.py index bfd44fe..6c8701f 100644 --- a/src/deareis/data/zhit.py +++ b/src/deareis/data/zhit.py @@ -228,6 +228,9 @@ class ZHITResult: def __repr__(self) -> str: return f"ZHITResult ({hex(id(self))})" + def __hash__(self) -> int: + return int(self.uuid, 16) + @classmethod def from_dict( Class, diff --git a/src/deareis/enums.py b/src/deareis/enums.py index 57ef6a2..d788c29 100644 --- a/src/deareis/enums.py +++ b/src/deareis/enums.py @@ -50,6 +50,8 @@ class Action(IntEnum): SHOW_SETTINGS_DEFAULTS = auto() SHOW_SETTINGS_KEYBINDINGS = auto() SHOW_COMMAND_PALETTE = auto() + SHOW_DATA_SET_PALETTE = auto() + SHOW_RESULT_PALETTE = auto() SHOW_CHANGELOG = auto() CHECK_UPDATES = auto() @@ -134,6 +136,7 @@ class Action(IntEnum): AVERAGE_DATA_SETS = auto() COPY_DATA_SET_MASK = auto() INTERPOLATE_POINTS = auto() + PARALLEL_IMPEDANCE = auto() SUBTRACT_IMPEDANCE = auto() TOGGLE_DATA_POINTS = auto() @@ -165,6 +168,8 @@ class Action(IntEnum): Action.SHOW_SETTINGS_DEFAULTS: [Context.PROGRAM], Action.SHOW_SETTINGS_KEYBINDINGS: [Context.PROGRAM], Action.SHOW_COMMAND_PALETTE: [Context.PROGRAM], + Action.SHOW_DATA_SET_PALETTE: [Context.PROJECT], + Action.SHOW_RESULT_PALETTE: [Context.PROJECT], Action.SHOW_CHANGELOG: [Context.PROGRAM], Action.CHECK_UPDATES: [Context.PROGRAM], Action.SAVE_PROJECT: [Context.PROJECT], @@ -340,6 +345,7 @@ class Action(IntEnum): Action.TOGGLE_DATA_POINTS: [Context.DATA_SETS_TAB], Action.COPY_DATA_SET_MASK: [Context.DATA_SETS_TAB], Action.INTERPOLATE_POINTS: [Context.DATA_SETS_TAB], + Action.PARALLEL_IMPEDANCE: [Context.DATA_SETS_TAB], Action.SUBTRACT_IMPEDANCE: [Context.DATA_SETS_TAB], Action.SELECT_ALL_PLOT_SERIES: [Context.PLOTTING_TAB], Action.UNSELECT_ALL_PLOT_SERIES: [Context.PLOTTING_TAB], @@ -405,6 +411,8 @@ class Action(IntEnum): Action.SHOW_CHANGELOG: "show-changelog", Action.SHOW_CIRCUIT_EDITOR: "show-circuit-editor", Action.SHOW_COMMAND_PALETTE: "show-command-palette", + Action.SHOW_DATA_SET_PALETTE: "show-data-set-palette", + Action.SHOW_RESULT_PALETTE: "show-result-palette", Action.SHOW_ENLARGED_BODE: "show-enlarged-bode", Action.SHOW_ENLARGED_DRT: "show-enlarged-drt", Action.SHOW_ENLARGED_IMPEDANCE: "show-enlarged-impedance", @@ -415,6 +423,7 @@ class Action(IntEnum): Action.SHOW_SETTINGS_APPEARANCE: "show-settings-appearance", Action.SHOW_SETTINGS_DEFAULTS: "show-settings-defaults", Action.SHOW_SETTINGS_KEYBINDINGS: "show-settings-keybindings", + Action.PARALLEL_IMPEDANCE: "parallel-impedance", Action.SUBTRACT_IMPEDANCE: "subtract-impedance", Action.TOGGLE_DATA_POINTS: "toggle-data-points", Action.UNDO: "undo", @@ -472,6 +481,12 @@ class Action(IntEnum): """.strip(), Action.SHOW_COMMAND_PALETTE: """ Show the command palette, which can be used as an alternative to other keybindings for performing actions. +""".strip(), + Action.SHOW_DATA_SET_PALETTE: """ +Show the data set palette, which can be used to switch between data sets. +""".strip(), + Action.SHOW_RESULT_PALETTE: """ +Show the result palette, which can be used to switch between, e.g., Kramers-Kronig test results. """.strip(), Action.SHOW_CHANGELOG: """ Show the changelog. @@ -651,6 +666,9 @@ class Action(IntEnum): """.strip(), Action.INTERPOLATE_POINTS: """ Interpolate one or more data points in the current data set. +""".strip(), + Action.PARALLEL_IMPEDANCE: """ +Select the impedance to add in parallel to the current data set. """.strip(), Action.SUBTRACT_IMPEDANCE: """ Select the impedance to subtract from the current data set. diff --git a/src/deareis/gui/data_sets/__init__.py b/src/deareis/gui/data_sets/__init__.py index 0755e13..1fa5b92 100644 --- a/src/deareis/gui/data_sets/__init__.py +++ b/src/deareis/gui/data_sets/__init__.py @@ -347,7 +347,7 @@ def create_process_menu(self): attach_tooltip(tooltips.data_sets.process) process_popup_dimensions: Tuple[int, int] = ( 110, - 82, + 104, ) process_popup: int with dpg.popup( @@ -381,6 +381,19 @@ def create_process_menu(self): ) attach_tooltip(tooltips.data_sets.interpolate) # + self.parallel_impedance_button: int = dpg.generate_uuid() + dpg.add_button( + label="Parallel", + callback=lambda s, a, u: signals.emit( + Signal.SELECT_PARALLEL_IMPEDANCE, + data=u, + popup=process_popup, + ), + width=-1, + tag=self.parallel_impedance_button, + ) + attach_tooltip(tooltips.data_sets.parallel) + # self.subtract_impedance_button: int = dpg.generate_uuid() dpg.add_button( label="Subtract", @@ -662,6 +675,7 @@ def clear(self): dpg.set_item_user_data(self.delete_button, None) dpg.set_item_user_data(self.toggle_points_button, None) dpg.set_item_user_data(self.copy_mask_button, None) + dpg.set_item_user_data(self.parallel_impedance_button, None) dpg.set_item_user_data(self.subtract_impedance_button, None) dpg.set_item_user_data(self.interpolation_button, None) self.data_table.clear() @@ -724,6 +738,7 @@ def select_data_set(self, data: Optional[DataSet]): dpg.set_item_user_data(self.delete_button, data) dpg.set_item_user_data(self.toggle_points_button, data) dpg.set_item_user_data(self.copy_mask_button, data) + dpg.set_item_user_data(self.parallel_impedance_button, data) dpg.set_item_user_data(self.subtract_impedance_button, data) dpg.set_item_user_data(self.interpolation_button, data) real: ndarray diff --git a/src/deareis/gui/data_sets/copy_mask.py b/src/deareis/gui/data_sets/copy_mask.py index 3b63463..0a82b30 100644 --- a/src/deareis/gui/data_sets/copy_mask.py +++ b/src/deareis/gui/data_sets/copy_mask.py @@ -205,16 +205,17 @@ def create_plots(self): "theme": themes.bode.phase_data, }, ] - self.plot_tab_bar: int = dpg.generate_uuid() - with dpg.tab_bar(tag=self.plot_tab_bar): - self.create_nyquist_plot(settings) - self.create_magnitude_plot(settings) - self.create_phase_plot(settings) - pad_tab_labels(self.plot_tab_bar) + with dpg.child_window(height=-24, border=False): + self.plot_tab_bar: int = dpg.generate_uuid() + with dpg.tab_bar(tag=self.plot_tab_bar): + self.create_nyquist_plot(settings) + self.create_magnitude_plot(settings) + self.create_phase_plot(settings) + pad_tab_labels(self.plot_tab_bar) def create_nyquist_plot(self, settings: List[dict]): with dpg.tab(label="Nyquist"): - self.nyquist_plot: Nyquist = Nyquist(width=-1, height=-24) + self.nyquist_plot: Nyquist = Nyquist(width=-1, height=-1) for kwargs in settings: self.nyquist_plot.plot( real=array([]), @@ -224,7 +225,7 @@ def create_nyquist_plot(self, settings: List[dict]): def create_magnitude_plot(self, settings: List[dict]): with dpg.tab(label="Bode - magnitude"): - self.magnitude_plot: BodeMagnitude = BodeMagnitude(width=-1, height=-24) + self.magnitude_plot: BodeMagnitude = BodeMagnitude(width=-1, height=-1) for kwargs in settings: self.magnitude_plot.plot( frequency=array([]), @@ -234,7 +235,7 @@ def create_magnitude_plot(self, settings: List[dict]): def create_phase_plot(self, settings: List[dict]): with dpg.tab(label="Bode - phase"): - self.phase_plot: BodePhase = BodePhase(width=-1, height=-24) + self.phase_plot: BodePhase = BodePhase(width=-1, height=-1) for kwargs in settings: self.phase_plot.plot( frequency=array([]), diff --git a/src/deareis/gui/data_sets/parallel_impedance.py b/src/deareis/gui/data_sets/parallel_impedance.py new file mode 100644 index 0000000..75e75c9 --- /dev/null +++ b/src/deareis/gui/data_sets/parallel_impedance.py @@ -0,0 +1,550 @@ +# DearEIS is licensed under the GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.html). +# Copyright 2023 DearEIS developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The licenses of DearEIS' dependencies and/or sources of portions of code are included in +# the LICENSES folder. + +from warnings import ( + catch_warnings, + filterwarnings, +) +from pyimpspec import Circuit +from threading import Timer +from typing import ( + Callable, + Dict, + List, + Optional, + Tuple, +) +from numpy import ( + array, + ndarray, +) +import dearpygui.dearpygui as dpg +from deareis.gui.plots import ( + BodeMagnitude, + BodePhase, + Nyquist, +) +import deareis.themes as themes +from deareis.utility import ( + calculate_window_position_dimensions, + pad_tab_labels, + process_cdc, +) +from deareis.tooltips import attach_tooltip +import deareis.tooltips as tooltips +from deareis.gui.circuit_editor import CircuitEditor +from deareis.signals import Signal +import deareis.signals as signals +from deareis.data import DataSet +from deareis.state import STATE +from deareis.enums import Action +from deareis.keybindings import ( + Keybinding, + TemporaryKeybindingHandler, +) + + +class ParallelImpedance: + def __init__( + self, + data: DataSet, + callback: Callable, + ): + assert type(data) is DataSet, data + self.data: DataSet = data + self.preview_data: DataSet = DataSet.from_dict(data.to_dict()) + self.callback: Callable = callback + self.create_window() + self.register_keybindings() + # This bool is used to prevent the Escape key from closing the window + # when the intention is to only hide/close the circuit editor. + self.editing_circuit: bool = False + self.select_option(self.radio_buttons, self.options[0]) + + def register_keybindings(self): + callbacks: Dict[Keybinding, Callable] = {} + # Cancel + kb: Keybinding = Keybinding( + key=dpg.mvKey_Escape, + mod_alt=False, + mod_ctrl=False, + mod_shift=False, + action=Action.CANCEL, + ) + callbacks[kb] = self.close + # Accept + for kb in STATE.config.keybindings: + if kb.action is Action.PERFORM_ACTION: + break + else: + kb = Keybinding( + key=dpg.mvKey_Return, + mod_alt=True, + mod_ctrl=False, + mod_shift=False, + action=Action.PERFORM_ACTION, + ) + callbacks[kb] = self.accept + # Previous option + for kb in STATE.config.keybindings: + if kb.action is Action.PREVIOUS_PRIMARY_RESULT: + break + else: + kb = Keybinding( + key=dpg.mvKey_Prior, + mod_alt=False, + mod_ctrl=False, + mod_shift=False, + action=Action.PREVIOUS_PRIMARY_RESULT, + ) + callbacks[kb] = lambda: self.cycle_options(step=-1) + # Next option + for kb in STATE.config.keybindings: + if kb.action is Action.NEXT_PRIMARY_RESULT: + break + else: + kb = Keybinding( + key=dpg.mvKey_Next, + mod_alt=False, + mod_ctrl=False, + mod_shift=False, + action=Action.NEXT_PRIMARY_RESULT, + ) + callbacks[kb] = lambda: self.cycle_options(step=1) + # Previous plot tab + for kb in STATE.config.keybindings: + if kb.action is Action.PREVIOUS_PLOT_TAB: + break + else: + kb = Keybinding( + key=dpg.mvKey_Prior, + mod_alt=True, + mod_ctrl=False, + mod_shift=True, + action=Action.PREVIOUS_PLOT_TAB, + ) + callbacks[kb] = lambda: self.cycle_plot_tab(step=-1) + # Next plot tab + for kb in STATE.config.keybindings: + if kb.action is Action.NEXT_PLOT_TAB: + break + else: + kb = Keybinding( + key=dpg.mvKey_Next, + mod_alt=True, + mod_ctrl=False, + mod_shift=True, + action=Action.NEXT_PLOT_TAB, + ) + callbacks[kb] = lambda: self.cycle_plot_tab(step=1) + # Open circuit editor + for kb in STATE.config.keybindings: + if kb.action is Action.SHOW_CIRCUIT_EDITOR: + break + else: + kb = Keybinding( + key=dpg.mvKey_E, + mod_alt=True, + mod_ctrl=False, + mod_shift=False, + action=Action.SHOW_CIRCUIT_EDITOR, + ) + callbacks[kb] = self.edit_circuit + # Create the handler + self.keybinding_handler: TemporaryKeybindingHandler = ( + TemporaryKeybindingHandler(callbacks=callbacks) + ) + + def create_window(self): + self.options: List[str] = [ + "Constant:", + "Circuit:", + ] + label_pad: int = max(map(len, self.options)) + self.options = list(map(lambda _: _.rjust(label_pad), self.options)) + self.circuit_editor_window: int = -1 + x: int + y: int + w: int + h: int + x, y, w, h = calculate_window_position_dimensions() + self.window: int = dpg.generate_uuid() + with dpg.window( + label="Add parallel impedance", + modal=True, + pos=(x, y), + width=w, + height=h, + tag=self.window, + on_close=self.close, + ): + self.preview_window: int = dpg.generate_uuid() + with dpg.child_window(border=False, tag=self.preview_window): + self.create_preview_window() + self.circuit_editor_window = dpg.generate_uuid() + with dpg.child_window( + border=False, + show=False, + tag=self.circuit_editor_window, + ): + self.circuit_editor: CircuitEditor = CircuitEditor( + window=self.circuit_editor_window, + callback=self.accept_circuit, + keybindings=STATE.config.keybindings, + ) + + def create_preview_window(self): + with dpg.child_window( + width=-1, + height=58, + ): + self.radio_buttons: int = dpg.generate_uuid() + with dpg.group(horizontal=True): + dpg.add_radio_button( + items=self.options, + default_value=self.options[0], + callback=self.select_option, + tag=self.radio_buttons, + ) + with dpg.group(): + self.constant_group: int = dpg.generate_uuid() + with dpg.group(horizontal=True, tag=self.constant_group): + dpg.add_text("Re(Z) = ") + self.constant_real: int = dpg.generate_uuid() + dpg.add_input_float( + label="ohm,", + default_value=0.0, + step=0.0, + format="%.3g", + on_enter=True, + width=100, + tag=self.constant_real, + callback=self.update_preview, + ) + dpg.add_text("-Im(Z) = ") + self.constant_imag: int = dpg.generate_uuid() + dpg.add_input_float( + label="ohm", + default_value=0.0, + step=0.0, + format="%.3g", + on_enter=True, + width=100, + tag=self.constant_imag, + callback=self.update_preview, + ) + self.circuit_group: int = dpg.generate_uuid() + with dpg.group(horizontal=True, tag=self.circuit_group): + self.circuit_cdc: int = dpg.generate_uuid() + dpg.add_input_text( + hint="Input CDC", + on_enter=True, + width=361, + tag=self.circuit_cdc, + callback=self.update_preview, + ) + self.circuit_editor_button: int = dpg.generate_uuid() + dpg.add_button( + label="Edit", + callback=self.edit_circuit, + tag=self.circuit_editor_button, + ) + attach_tooltip(tooltips.general.open_circuit_editor) + self.groups: List[int] = [ + self.constant_group, + self.circuit_group, + ] + self.create_plots() + dpg.add_button( + label="Accept".ljust(12), + callback=self.accept, + ) + + def create_plots(self): + settings: List[dict] = [ + { + "label": "Before", + "theme": themes.nyquist.data, + "show_label": False, + }, + { + "label": "Before", + "line": True, + "theme": themes.nyquist.data, + }, + { + "label": "After", + "theme": themes.bode.phase_data, + "show_label": False, + }, + { + "label": "After", + "line": True, + "theme": themes.bode.phase_data, + }, + ] + with dpg.child_window(height=-24, border=False): + self.plot_tab_bar: int = dpg.generate_uuid() + with dpg.tab_bar(tag=self.plot_tab_bar): + self.create_nyquist_plot(settings) + self.create_magnitude_plot(settings) + self.create_phase_plot(settings) + pad_tab_labels(self.plot_tab_bar) + + def create_nyquist_plot(self, settings: List[dict]): + with dpg.tab(label="Nyquist"): + self.nyquist_plot: Nyquist = Nyquist(width=-1, height=-1) + for kwargs in settings: + self.nyquist_plot.plot( + real=array([]), + imaginary=array([]), + **kwargs, + ) + + def create_magnitude_plot(self, settings: List[dict]): + with dpg.tab(label="Bode - magnitude"): + self.magnitude_plot: BodeMagnitude = BodeMagnitude(width=-1, height=-1) + for kwargs in settings: + self.magnitude_plot.plot( + frequency=array([]), + magnitude=array([]), + **kwargs, + ) + + def create_phase_plot(self, settings: List[dict]): + with dpg.tab(label="Bode - phase"): + self.phase_plot: BodePhase = BodePhase(width=-1, height=-1) + for kwargs in settings: + self.phase_plot.plot( + frequency=array([]), + phase=array([]), + **kwargs, + ) + + def close(self): + if not dpg.is_item_visible(self.constant_real): + return + elif self.circuit_editor.is_shown(): + return + elif self.editing_circuit is True: + self.editing_circuit = False + return + self.circuit_editor.keybinding_handler.delete() + dpg.hide_item(self.window) + dpg.delete_item(self.window) + self.keybinding_handler.delete() + signals.emit(Signal.UNBLOCK_KEYBINDINGS) + + def accept(self): + if not dpg.is_item_visible(self.constant_real): + return + elif self.circuit_editor.is_shown(): + return + elif self.editing_circuit is True: + self.editing_circuit = False + return + self.close() + dictionary: dict = self.preview_data.to_dict() + del dictionary["uuid"] + data: DataSet = DataSet.from_dict(dictionary) + data.set_mask({}) + data.set_label(f"{self.preview_data.get_label()} - added parallel impedance") + self.callback(data) + + def select_option(self, sender: int, value: str): + def disable_group(group: int): + for item in dpg.get_item_children(group, slot=1): + item_type: str = dpg.get_item_type(item) + if item_type.endswith("mvText") or item_type.endswith("mvTooltip"): + continue + dpg.disable_item(item) + + def enable_group(group: int): + for item in dpg.get_item_children(group, slot=1): + item_type: str = dpg.get_item_type(item) + if item_type.endswith("mvText") or item_type.endswith("mvTooltip"): + continue + dpg.enable_item(item) + + index: int = self.options.index(value) + assert 0 <= index < len(self.groups), "Unsupported option!" + i: int + group: int + for i, group in enumerate(self.groups): + if i == index: + enable_group(group) + else: + disable_group(group) + self.update_preview() + + def update_preview(self): + index: int = self.options.index(dpg.get_value(self.radio_buttons)) + f: ndarray = self.data.get_frequencies(masked=None) + Z: ndarray = self.data.get_impedances(masked=None) + if index == 0: # Constant + Z_const: complex = complex( + dpg.get_value(self.constant_real), + -dpg.get_value(self.constant_imag), + ) + try: + Z = 1 / (1 / Z + 1 / Z_const) + except ZeroDivisionError: + pass + elif index == 1: # Circuit + cdc: str = dpg.get_value(self.circuit_cdc) + circuit: Optional[Circuit] = dpg.get_item_user_data(self.circuit_cdc) + if circuit is None or circuit.to_string() != cdc: + try: + circuit, _ = process_cdc(cdc) + except Exception: + return + if circuit is None: + return + with catch_warnings(): + filterwarnings("error", message="divide by zero encountered in divide") + try: + Z = 1 / (1 / Z + 1 / circuit.get_impedances(f)) + except RuntimeWarning: + pass + else: + raise Exception("Unsupported option!") + dictionary: dict = self.preview_data.to_dict() + dictionary.update( + { + "real_impedances": list(Z.real), + "imaginary_impedances": list(Z.imag), + } + ) + self.preview_data = DataSet.from_dict(dictionary) + self.update_plots() + + def update_plots(self): + self.update_nyquist_plot(self.data, self.preview_data) + self.update_magnitude_plot(self.data, self.preview_data) + self.update_phase_plot(self.data, self.preview_data) + + def update_nyquist_plot(self, original: DataSet, preview: DataSet): + data: List[Tuple[ndarray, ndarray]] = [ + original.get_nyquist_data(masked=None), + original.get_nyquist_data(masked=None), + preview.get_nyquist_data(masked=None), + preview.get_nyquist_data(masked=None), + ] + i: int + real: ndarray + imag: ndarray + for i, (real, imag) in enumerate(data): + self.nyquist_plot.update( + index=i, + real=real, + imaginary=imag, + ) + self.nyquist_plot.queue_limits_adjustment() + + def update_magnitude_plot(self, original: DataSet, preview: DataSet): + data: List[Tuple[ndarray, ndarray, ndarray]] = [ + original.get_bode_data(masked=None), + original.get_bode_data(masked=None), + preview.get_bode_data(masked=None), + preview.get_bode_data(masked=None), + ] + i: int + freq: ndarray + mag: ndarray + for i, (freq, mag, _) in enumerate(data): + self.magnitude_plot.update( + index=i, + frequency=freq, + magnitude=mag, + ) + self.magnitude_plot.queue_limits_adjustment() + + def update_phase_plot(self, original: DataSet, preview: DataSet): + data: List[Tuple[ndarray, ndarray, ndarray]] = [ + original.get_bode_data(masked=None), + original.get_bode_data(masked=None), + preview.get_bode_data(masked=None), + preview.get_bode_data(masked=None), + ] + i: int + freq: ndarray + phase: ndarray + for i, (freq, _, phase) in enumerate(data): + self.phase_plot.update( + index=i, + frequency=freq, + phase=phase, + ) + self.phase_plot.queue_limits_adjustment() + + def edit_circuit(self): + if not dpg.is_item_enabled(self.circuit_editor_button): + return + self.editing_circuit = True + self.keybinding_handler.block() + dpg.hide_item(self.preview_window) + circuit: Optional[Circuit] + circuit, _ = process_cdc(dpg.get_value(self.circuit_cdc) or "[]") + self.circuit_editor.show(circuit) + + def accept_circuit(self, circuit: Optional[Circuit]): + self.circuit_editor.hide() + dpg.show_item(self.preview_window) + self.update_cdc(circuit) + self.keybinding_handler.unblock() + t: Timer = Timer(0.5, self.disable_keybinding_override) + t.start() + + def disable_keybinding_override(self): + self.editing_circuit = False + + def update_cdc(self, circuit: Optional[Circuit]): + if circuit is not None: + for element in circuit.get_elements(): + element.set_label("") + for param in element.get_values(): + element.set_fixed(param, True) + assert dpg.does_item_exist(self.circuit_cdc) + dpg.configure_item( + self.circuit_cdc, + default_value=circuit.to_string() if circuit is not None else "", + user_data=circuit, + ) + dpg.show_item(self.preview_window) + dpg.split_frame(delay=33) + self.update_preview() + + def cycle_options(self, step: int): + if self.has_active_input(): + return + index: int = self.options.index(dpg.get_value(self.radio_buttons)) + step + dpg.set_value(self.radio_buttons, self.options[index % len(self.options)]) + self.select_option(self.radio_buttons, self.options[index % len(self.options)]) + + def cycle_plot_tab(self, step: int): + tabs: List[int] = dpg.get_item_children(self.plot_tab_bar, slot=1) + index: int = tabs.index(dpg.get_value(self.plot_tab_bar)) + step + dpg.set_value(self.plot_tab_bar, tabs[index % len(tabs)]) + + def has_active_input(self) -> bool: + return ( + dpg.is_item_active(self.constant_real) + or dpg.is_item_active(self.constant_imag) + or dpg.is_item_active(self.circuit_cdc) + ) diff --git a/src/deareis/gui/data_sets/subtract_impedance.py b/src/deareis/gui/data_sets/subtract_impedance.py index dd8bb35..e0c31d6 100644 --- a/src/deareis/gui/data_sets/subtract_impedance.py +++ b/src/deareis/gui/data_sets/subtract_impedance.py @@ -18,6 +18,7 @@ # the LICENSES folder. from pyimpspec import Circuit +from threading import Timer from typing import ( Callable, Dict, @@ -102,6 +103,8 @@ def __init__( self.callback: Callable = callback self.create_window() self.register_keybindings() + # This bool is used to prevent the Escape key from closing the window + # when the intention is to only hide/close the circuit editor. self.editing_circuit: bool = False self.select_option(self.radio_buttons, self.options[0]) @@ -389,16 +392,17 @@ def create_plots(self): "theme": themes.bode.phase_data, }, ] - self.plot_tab_bar: int = dpg.generate_uuid() - with dpg.tab_bar(tag=self.plot_tab_bar): - self.create_nyquist_plot(settings) - self.create_magnitude_plot(settings) - self.create_phase_plot(settings) - pad_tab_labels(self.plot_tab_bar) + with dpg.child_window(height=-24, border=False): + self.plot_tab_bar: int = dpg.generate_uuid() + with dpg.tab_bar(tag=self.plot_tab_bar): + self.create_nyquist_plot(settings) + self.create_magnitude_plot(settings) + self.create_phase_plot(settings) + pad_tab_labels(self.plot_tab_bar) def create_nyquist_plot(self, settings: List[dict]): with dpg.tab(label="Nyquist"): - self.nyquist_plot: Nyquist = Nyquist(width=-1, height=-24) + self.nyquist_plot: Nyquist = Nyquist(width=-1, height=-1) for kwargs in settings: self.nyquist_plot.plot( real=array([]), @@ -408,7 +412,7 @@ def create_nyquist_plot(self, settings: List[dict]): def create_magnitude_plot(self, settings: List[dict]): with dpg.tab(label="Bode - magnitude"): - self.magnitude_plot: BodeMagnitude = BodeMagnitude(width=-1, height=-24) + self.magnitude_plot: BodeMagnitude = BodeMagnitude(width=-1, height=-1) for kwargs in settings: self.magnitude_plot.plot( frequency=array([]), @@ -418,7 +422,7 @@ def create_magnitude_plot(self, settings: List[dict]): def create_phase_plot(self, settings: List[dict]): with dpg.tab(label="Bode - phase"): - self.phase_plot: BodePhase = BodePhase(width=-1, height=-24) + self.phase_plot: BodePhase = BodePhase(width=-1, height=-1) for kwargs in settings: self.phase_plot.plot( frequency=array([]), @@ -612,6 +616,11 @@ def accept_circuit(self, circuit: Optional[Circuit]): dpg.show_item(self.preview_window) self.update_cdc(circuit) self.keybinding_handler.unblock() + t: Timer = Timer(0.5, self.disable_keybinding_override) + t.start() + + def disable_keybinding_override(self): + self.editing_circuit = False def update_cdc(self, circuit: Optional[Circuit]): if circuit is not None: diff --git a/src/deareis/gui/data_sets/toggle_data_points.py b/src/deareis/gui/data_sets/toggle_data_points.py index b65b48e..cab914c 100644 --- a/src/deareis/gui/data_sets/toggle_data_points.py +++ b/src/deareis/gui/data_sets/toggle_data_points.py @@ -323,17 +323,18 @@ def create_plots(self): "theme": themes.bode.phase_data, }, ] - self.plot_tab_bar: int = dpg.generate_uuid() - with dpg.tab_bar(tag=self.plot_tab_bar): - self.create_nyquist_plot(settings) - self.create_magnitude_plot(settings) - self.create_phase_plot(settings) - pad_tab_labels(self.plot_tab_bar) + with dpg.child_window(height=-24, border=False): + self.plot_tab_bar: int = dpg.generate_uuid() + with dpg.tab_bar(tag=self.plot_tab_bar): + self.create_nyquist_plot(settings) + self.create_magnitude_plot(settings) + self.create_phase_plot(settings) + pad_tab_labels(self.plot_tab_bar) def create_nyquist_plot(self, settings: List[dict]): tab: int with dpg.tab(label="Nyquist") as tab: - self.nyquist_plot: Nyquist = Nyquist(width=-1, height=-24) + self.nyquist_plot: Nyquist = Nyquist(width=-1, height=-1) self.plot_tabs[tab] = self.nyquist_plot for kwargs in settings: self.nyquist_plot.plot( @@ -346,7 +347,7 @@ def create_nyquist_plot(self, settings: List[dict]): def create_magnitude_plot(self, settings: List[dict]): tab: int with dpg.tab(label="Bode - magnitude") as tab: - self.magnitude_plot: BodeMagnitude = BodeMagnitude(width=-1, height=-24) + self.magnitude_plot: BodeMagnitude = BodeMagnitude(width=-1, height=-1) self.plot_tabs[tab] = self.magnitude_plot for kwargs in settings: self.magnitude_plot.plot( @@ -359,7 +360,7 @@ def create_magnitude_plot(self, settings: List[dict]): def create_phase_plot(self, settings: List[dict]): tab: int with dpg.tab(label="Bode - phase") as tab: - self.phase_plot: BodePhase = BodePhase(width=-1, height=-24) + self.phase_plot: BodePhase = BodePhase(width=-1, height=-1) self.plot_tabs[tab] = self.phase_plot for kwargs in settings: self.phase_plot.plot( diff --git a/src/deareis/gui/drt.py b/src/deareis/gui/drt.py index 327d7aa..3b56c13 100644 --- a/src/deareis/gui/drt.py +++ b/src/deareis/gui/drt.py @@ -249,13 +249,16 @@ def __init__(self, default_settings: DRTSettings, label_pad: int): if default_settings.lambda_value > 0.0 else 1e-3, width=-1, - min_value=1e-16, - min_clamped=True, step=0.0, format="%.3g", on_enter=True, tag=self.lambda_input, ) + self.lambda_combo: int = dpg.generate_uuid() + dpg.add_combo( + tag=self.lambda_combo, + width=-1, + ) with dpg.group(horizontal=True): dpg.add_text("Derivative order".rjust(label_pad)) with dpg.tooltip(dpg.last_item()): @@ -532,6 +535,16 @@ def __init__(self, default_settings: DRTSettings, label_pad: int): on_enter=True, tag=self.num_per_decade_input, ) + self.auto_lambda_options: Dict[DRTMethod, Dict[str, float]] = { + DRTMethod.TR_NNLS: { + "Custom": -1.0, + "L-curve corner search": -2.0, + }, + DRTMethod.TR_RBF: { + "Custom": -1.0, + "L-curve corner search": -2.0, + }, + } self.update_settings() def update_valid_circuits(self, fits: Dict[str, FitResult]): @@ -565,6 +578,38 @@ def update_valid_circuits(self, fits: Dict[str, FitResult]): ) self.update_settings() + def get_lambda_value(self, method: DRTMethod) -> float: + if method not in (DRTMethod.TR_NNLS, DRTMethod.TR_RBF): + return -1.0 + elif not dpg.get_value(self.lambda_checkbox) is True: + return dpg.get_value(self.lambda_input) + option: str = dpg.get_value(self.lambda_combo) + return self.auto_lambda_options[method].get(option, -1.0) + + def get_lambda_label(self, lambda_value: float, method: DRTMethod) -> str: + if method not in (DRTMethod.TR_NNLS, DRTMethod.TR_RBF): + return "" + options: List[str] = list(self.auto_lambda_options[method].keys()) + i: int + value: float + for i, value in enumerate(self.auto_lambda_options[method].values()): + if lambda_value >= value - 0.5: + return options[i] + return "" + + def set_lambda_value(self, lambda_value: float, method: DRTMethod): + if method not in (DRTMethod.TR_NNLS, DRTMethod.TR_RBF): + return + dpg.set_value(self.lambda_checkbox, lambda_value <= 0.0) + if lambda_value > 0.0: + dpg.set_value(self.lambda_input, lambda_value) + return + dpg.configure_item( + self.lambda_combo, + default_value=self.get_lambda_label(lambda_value, method), + items=list(self.auto_lambda_options[method].keys()), + ) + def get_settings(self) -> DRTSettings: fit: Optional[FitResult] = dpg.get_item_user_data(self.circuit_combo).get( dpg.get_value(self.circuit_combo), @@ -573,9 +618,7 @@ def get_settings(self) -> DRTSettings: return DRTSettings( method=method, mode=label_to_drt_mode[dpg.get_value(self.mode_combo)], - lambda_value=dpg.get_value(self.lambda_input) - if not dpg.get_value(self.lambda_checkbox) - else -1.0, + lambda_value=self.get_lambda_value(method), rbf_type=label_to_rbf_type[dpg.get_value(self.rbf_type_combo)], derivative_order=label_to_derivative_order[ dpg.get_value(self.derivative_order_combo) @@ -597,11 +640,7 @@ def set_settings(self, settings: DRTSettings): dpg.set_value(self.method_combo, drt_method_to_label[settings.method]) self.update_settings() dpg.set_value(self.mode_combo, drt_mode_to_label[settings.mode]) - dpg.set_value(self.lambda_checkbox, settings.lambda_value <= 0.0) - dpg.set_value( - self.lambda_input, - settings.lambda_value if settings.lambda_value > 0.0 else 1e-3, - ) + self.set_lambda_value(settings.lambda_value, settings.method) dpg.set_value(self.rbf_type_combo, rbf_type_to_label[settings.rbf_type]) dpg.set_value( self.derivative_order_combo, @@ -672,9 +711,12 @@ def update_settings(self, settings: Optional[DRTSettings] = None): if settings.method == DRTMethod.TR_RBF or settings.method == DRTMethod.TR_NNLS: self.show_setting(self.lambda_checkbox) if dpg.get_value(self.lambda_checkbox): - dpg.disable_item(self.lambda_input) + dpg.hide_item(self.lambda_input) + dpg.show_item(self.lambda_combo) + self.set_lambda_value(settings.lambda_value, settings.method) else: - dpg.enable_item(self.lambda_input) + dpg.show_item(self.lambda_input) + dpg.hide_item(self.lambda_combo) else: self.hide_setting(self.lambda_checkbox) dpg.disable_item(self.lambda_input) @@ -1112,12 +1154,18 @@ def populate(self, drt: DRTResult, data: DataSet): dpg.show_item(self._header) filter_key: str = drt_method_to_label[drt.settings.method] dpg.set_value(self._table, filter_key) + lambda_label: str = "" + if drt.settings.lambda_value > 0.0: + lambda_label = f"{drt.settings.lambda_value:.3e}" + else: + lambda_label = self.get_lambda_label( + drt.settings.lambda_value, + drt.settings.method, + ) values: List[str] = [ drt_method_to_label[drt.settings.method], drt_mode_to_label[drt.settings.mode], - f"{drt.settings.lambda_value:.3e}" - if drt.settings.lambda_value > 0.0 - else "Automatic", + lambda_label, rbf_type_to_label[drt.settings.rbf_type], derivative_order_to_label[drt.settings.derivative_order], rbf_shape_to_label[drt.settings.rbf_shape], @@ -1168,12 +1216,10 @@ def selection_callback(self, sender: int, app_data: str, user_data: tuple): ) def adjust_label(self, old: str, longest: int) -> str: + i: int = old.rfind(" (") label: str timestamp: str - label, timestamp = ( - old[: old.find(" ")], - old[old.find(" ") + 1 :], - ) + label, timestamp = (old[:i], old[i + 1 :]) return f"{label.ljust(longest)} {timestamp}" @@ -1304,6 +1350,12 @@ def create_results_tables(self): self.statistics_table: StatisticsTable = StatisticsTable() self.scores_table: ScoresTable = ScoresTable() self.settings_table: SettingsTable = SettingsTable() + self.settings_table.auto_lambda_options = ( + self.settings_menu.auto_lambda_options + ) + self.settings_table.get_lambda_label = ( + self.settings_menu.get_lambda_label + ) def create_plots(self): self.plot_window: int = dpg.generate_uuid() diff --git a/src/deareis/gui/fitting/__init__.py b/src/deareis/gui/fitting/__init__.py index 5b04374..31c64d0 100644 --- a/src/deareis/gui/fitting/__init__.py +++ b/src/deareis/gui/fitting/__init__.py @@ -383,7 +383,10 @@ def clear(self, hide: bool): dpg.delete_item(self._table, children_only=True, slot=1) def limited_parameter( - self, key: str, value: float, element: Element + self, + key: str, + value: float, + element: Element, ) -> Tuple[str, int]: lower_limit: float = element.get_lower_limit(key) upper_limit: float = element.get_upper_limit(key) @@ -427,6 +430,7 @@ def populate(self, fit: FitResult): value_themes: List[int] = [] error_values: List[str] = [] error_tooltips: List[str] = [] + error_themes: List[int] = [] internal_identifiers: Dict[int, Element] = { v: k for k, v in fit.circuit.generate_element_identifiers(running=True).items() @@ -499,24 +503,29 @@ def populate(self, fit: FitResult): f"{format_number(parameter.value, decimals=6).strip()} {unit}".strip() ) value_tooltip_appendix: str - value_theme: int - value_tooltip_appendix, value_theme = self.limited_parameter( - parameter_label, - parameter.value, - element, - ) - value_tooltips[-1] += value_tooltip_appendix + value_theme: int = -1 + if not parameter.fixed: + value_tooltip_appendix, value_theme = self.limited_parameter( + parameter_label, + parameter.value, + element, + ) + value_tooltips[-1] += value_tooltip_appendix value_themes.append(value_theme) + error_theme: int = -1 if not isnan(parameter.stderr): error: float = parameter.get_relative_error() * 100 if error > 100.0: error_value = ">100" + error_theme = themes.fitting.huge_error elif error < 0.01: error_value = "<0.01" else: error_value = ( f"{format_number(error, exponent=False, significants=3)}" ) + if error >= 5.0: + error_theme = themes.fitting.large_error error_tooltip = f"±{format_number(parameter.stderr, decimals=6).strip()} {parameter.unit}".strip() else: error_value = "-" @@ -526,6 +535,7 @@ def populate(self, fit: FitResult): error_tooltip = "Fixed parameter." error_values.append(error_value) error_tooltips.append(error_tooltip) + error_themes.append(error_theme) values = align_numbers(values) error_values = align_numbers(error_values) num_rows: int = 0 @@ -539,6 +549,7 @@ def populate(self, fit: FitResult): value_theme, error_value, error_tooltip, + error_theme, ) in zip( element_names, element_tooltips, @@ -549,6 +560,7 @@ def populate(self, fit: FitResult): value_themes, error_values, error_tooltips, + error_themes, ): with dpg.table_row(parent=self._table): dpg.add_text(element_name.ljust(column_pads[0])) @@ -559,9 +571,11 @@ def populate(self, fit: FitResult): attach_tooltip(value_tooltip) if value_theme > 0: dpg.bind_item_theme(value_widget, value_theme) - dpg.add_text(error_value.ljust(column_pads[3])) + error_widget: int = dpg.add_text(error_value.ljust(column_pads[3])) if error_tooltip != "": attach_tooltip(error_tooltip) + if error_theme > 0: + dpg.bind_item_theme(error_widget, error_theme) num_rows += 1 dpg.set_item_height(self._table, 18 + 23 * max(1, num_rows)) @@ -590,6 +604,7 @@ def __init__(self): label="Value", width_fixed=True, ) + attach_tooltip(tooltips.fitting.statistics) label: str tooltip: str for (label, tooltip) in [ @@ -659,51 +674,68 @@ def populate(self, fit: FitResult): assert len(cells) == 10, cells tag: int value: str - for (tag, value) in [ + theme: int + for (tag, value, theme) in [ ( cells[0], f"{log(fit.pseudo_chisqr):.3f}", + -1, ), ( cells[1], f"{log(fit.chisqr):.3f}", + -1, ), ( cells[2], f"{log(fit.red_chisqr):.3f}", + -1, ), ( cells[3], format_number(fit.aic, decimals=3), + -1, ), ( cells[4], format_number(fit.bic, decimals=3), + -1, ), ( cells[5], f"{fit.nfree}", + -1, ), ( cells[6], f"{fit.ndata}", + -1, ), ( cells[7], f"{fit.nfev}", + themes.fitting.highlighted_statistc + if fit.settings.max_nfev > 0 and fit.nfev >= fit.settings.max_nfev + else -1, ), ( cells[8], cnls_method_to_label.get(fit.method, ""), + -1, ), ( cells[9], weight_to_label.get(fit.weight, ""), + -1, ), ]: dpg.set_value(tag, value) update_tooltip(dpg.get_item_user_data(tag), value) dpg.show_item(dpg.get_item_parent(dpg.get_item_user_data(tag))) + dpg.bind_item_theme( + tag, + theme if theme > 0 else themes.fitting.default_statistic, + ) dpg.set_item_height(self._table, 18 + 23 * len(cells)) @@ -841,12 +873,10 @@ def selection_callback(self, sender: int, app_data: str, user_data: tuple): ) def adjust_label(self, old: str, longest: int) -> str: + i: int = old.rfind(" (") cdc: str timestamp: str - cdc, timestamp = ( - old[: old.find(" ")], - old[old.find(" ") + 1 :], - ) + cdc, timestamp = (old[:i], old[i + 1 :]) return f"{cdc.ljust(longest)} {timestamp}" diff --git a/src/deareis/gui/kramers_kronig/__init__.py b/src/deareis/gui/kramers_kronig/__init__.py index 7a575d7..ee50731 100644 --- a/src/deareis/gui/kramers_kronig/__init__.py +++ b/src/deareis/gui/kramers_kronig/__init__.py @@ -531,12 +531,10 @@ def selection_callback(self, sender: int, app_data: str, user_data: tuple): ) def adjust_label(self, old: str, longest: int) -> str: + i: int = old.rfind(" (") label: str timestamp: str - label, timestamp = ( - old[: old.find(" (")], - old[old.find(" (") + 1 :], - ) + label, timestamp = (old[:i], old[i + 1 :]) return f"{label.ljust(longest)} {timestamp}" diff --git a/src/deareis/gui/palettes/__init__.py b/src/deareis/gui/palettes/__init__.py new file mode 100644 index 0000000..2daee07 --- /dev/null +++ b/src/deareis/gui/palettes/__init__.py @@ -0,0 +1,22 @@ +# DearEIS is licensed under the GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.html). +# Copyright 2023 DearEIS developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The licenses of DearEIS' dependencies and/or sources of portions of code are included in +# the LICENSES folder. + +from .command import CommandPalette +from .data_set import DataSetPalette +from .result import ResultPalette diff --git a/src/deareis/gui/command_palette.py b/src/deareis/gui/palettes/base.py similarity index 56% rename from src/deareis/gui/command_palette.py rename to src/deareis/gui/palettes/base.py index 94aadbd..5dd72a5 100644 --- a/src/deareis/gui/command_palette.py +++ b/src/deareis/gui/palettes/base.py @@ -17,35 +17,40 @@ # The licenses of DearEIS' dependencies and/or sources of portions of code are included in # the LICENSES folder. -from typing import Dict, List, Set, Tuple, Optional +from dataclasses import dataclass +from typing import ( + Any, + Dict, + List, + Optional, + Tuple, +) import dearpygui.dearpygui as dpg -from numpy import array, ndarray from deareis.signals import Signal import deareis.signals as signals -from deareis.enums import Action, Context, action_contexts, action_descriptions from deareis.utility import calculate_window_position_dimensions -from deareis.data.project import Project -from deareis.gui.project import ProjectTab -from deareis.keybindings import KeybindingHandler import deareis.themes as themes from deareis.tooltips import attach_tooltip import deareis.tooltips as tooltips -class CommandPalette: - def __init__(self, keybinding_handler: KeybindingHandler): - assert type(keybinding_handler), keybinding_handler - self.keybinding_handler: KeybindingHandler = keybinding_handler - self.action_contexts: Dict[Action, Set[Context]] = { - k: set(v) - for k, v in action_contexts.items() - if k != Action.SHOW_COMMAND_PALETTE - } - self.valid_actions: List[Tuple[Action, str]] = [] - self.action_history: List[Action] = [] +@dataclass(frozen=True) +class Option: + description: str + rank: int + data: Any + + +class BasePalette: + def __init__(self, title: str, tooltip: str): + self.create_window(title=title, tooltip=tooltip) + self.options: List[Option] = [] + self.options_history: List[Option] = [] + + def create_window(self, title: str, tooltip: str): self.window: int = dpg.generate_uuid() with dpg.window( - label="Command palette", + label=title, no_close=True, modal=True, no_resize=True, @@ -54,13 +59,12 @@ def __init__(self, keybinding_handler: KeybindingHandler): ): self.filter_input: int = dpg.generate_uuid() dpg.add_input_text( - hint="Search...", tag=self.filter_input, width=-1, - callback=lambda s, a, u: self.filter_results(a.strip().lower()), + callback=lambda s, a, u: self.filter_options(a.strip().lower()), ) - attach_tooltip(tooltips.general.command_palette) - self.results_table: int = dpg.generate_uuid() + attach_tooltip(tooltip + tooltips.general.palette) + self.options_table: int = dpg.generate_uuid() self.num_rows: int = 9 with dpg.table( borders_outerV=True, @@ -69,55 +73,18 @@ def __init__(self, keybinding_handler: KeybindingHandler): borders_innerH=True, header_row=False, height=self.num_rows * 23, - tag=self.results_table, + tag=self.options_table, ): dpg.add_table_column() for i in range(0, self.num_rows): with dpg.table_row(): dpg.add_button( label="", - callback=lambda s, a, u: self.perform_action(u, True), + callback=lambda s, a, u: self.select_option(u, click=True), width=-1, ) - def show(self, contexts: List[Context], project: Project, tab: ProjectTab): - self.current_contexts = contexts - self.current_project: Project = project - self.current_tab: ProjectTab = tab - self.valid_actions = [] - contexts_set: Set[Context] = set(contexts) - action: Action - cons: Set[Context] - for action, cons in self.action_contexts.items(): - if len(cons.intersection(contexts_set)) == 0: - continue - description: str = action_descriptions[action].split("\n")[0] - if description.endswith(":"): - description = description[:-1] - elif description.endswith("."): - description = description[:-1] - self.valid_actions.append( - ( - action, - description, - ) - ) - signals.emit(Signal.BLOCK_KEYBINDINGS, window=self.window, window_object=self) - dpg.show_item(self.window) - dpg.split_frame() - x: int - y: int - w: int - h: int = 24 + self.num_rows * 23 + 34 - x, y, w, h = calculate_window_position_dimensions(720, h) - dpg.configure_item( - self.window, - pos=(x, y), - width=w, - height=h, - ) - dpg.set_value(self.filter_input, "") - self.filter_results("") + def register_keybindings(self): self.key_handler: int = dpg.generate_uuid() with dpg.handler_registry(tag=self.key_handler): dpg.add_key_release_handler( @@ -126,34 +93,59 @@ def show(self, contexts: List[Context], project: Project, tab: ProjectTab): ) dpg.add_key_release_handler( key=dpg.mvKey_Return, - callback=lambda s, a, u: self.perform_action(), + callback=lambda s, a, u: self.select_option( + option=self.options[self.option_index] + ), ) dpg.add_key_release_handler( key=dpg.mvKey_Up, - callback=lambda: self.navigate_result(step=-1), + callback=lambda: self.navigate_option(step=-1), ) dpg.add_key_release_handler( key=dpg.mvKey_Prior, - callback=lambda: self.navigate_result(step=-5), + callback=lambda: self.navigate_option(step=-5), ) dpg.add_key_release_handler( key=dpg.mvKey_Home, - callback=lambda: self.navigate_result(index=0), + callback=lambda: self.navigate_option(index=0), ) dpg.add_key_release_handler( key=dpg.mvKey_Down, - callback=lambda: self.navigate_result(step=1), + callback=lambda: self.navigate_option(step=1), ) dpg.add_key_release_handler( key=dpg.mvKey_Next, - callback=lambda: self.navigate_result(step=5), + callback=lambda: self.navigate_option(step=5), ) dpg.add_key_release_handler( key=dpg.mvKey_End, - callback=lambda: self.navigate_result( - index=len(self.valid_actions) - 1 - ), + callback=lambda: self.navigate_option(index=len(self.options) - 1), ) + + def show(self): + signals.emit(Signal.BLOCK_KEYBINDINGS, window=self.window, window_object=self) + dpg.show_item(self.window) + dpg.split_frame() + x: int + y: int + w: int + h: int = 24 + self.num_rows * 23 + 34 + x, y, w, h = calculate_window_position_dimensions(720, h) + dpg.configure_item( + self.window, + pos=(x, y), + width=w, + height=h, + ) + self.register_keybindings() + dpg.configure_item( + self.filter_input, + default_value="", + hint="Search..." + if len(self.options) > 0 + else "No (other) options to select!", + ) + self.filter_options("") dpg.split_frame() dpg.focus_item(self.filter_input) @@ -163,7 +155,11 @@ def hide(self): dpg.split_frame() signals.emit(Signal.UNBLOCK_KEYBINDINGS) - def get_consecutive_letter_indices(self, source: str, target: str) -> List[int]: + def get_consecutive_letter_indices( + self, + source: str, + target: str, + ) -> List[int]: indices: List[int] = [] letter: str for letter in source: @@ -174,7 +170,9 @@ def get_consecutive_letter_indices(self, source: str, target: str) -> List[int]: return indices def get_distances_from_word_beginning( - self, letter_indices: List[int], target: str + self, + letter_indices: List[int], + target: str, ) -> Tuple[List[int], List[int]]: if not letter_indices: return ( @@ -207,16 +205,22 @@ def get_distances_from_word_beginning( word_lengths, ) - def score_result(self, source: str, target: str, action: Action) -> float: + def score_option( + self, + source: str, + option: Option, + ) -> float: + assert isinstance(source, str), source + assert isinstance(option, Option), option + target: str = option.description.lower() score: float = 0.0 - num_valid_actions: int = len(self.valid_actions) if source == "": - score -= int(action) + 1 - if action in self.action_history: - recency_bonus: float = float(num_valid_actions) + score -= option.rank + 1 + if option in self.options_history: + recency_bonus: float = float(len(self.options)) score += ( - len(self.action_history) - self.action_history.index(action) - ) * recency_bonus + len(self.options_history) - self.options_history.index(option) + ) * recency_bonus * 2 else: if source in target: score += 100 @@ -226,7 +230,8 @@ def score_result(self, source: str, target: str, action: Action) -> float: num_matches: int = len(list(filter(lambda _: _ >= 0, indices))) # Penalize for source letters not found in the target score -= abs(sum(filter(lambda _: _ < 0, indices))) * 25.0 - # Reward for source letters found in the target based on proximity to the beginning + # Reward for source letters found in the target based on proximity + # to the beginning score += ( sum( map( @@ -236,11 +241,12 @@ def score_result(self, source: str, target: str, action: Action) -> float: ) * 100.0 ) - # Penalize the difference between the number of found source letters and the length - # of the target and normalize using the length of the source + # Penalize the difference between the number of found source + # letters and the length of the target and normalize using the + # length of the source score -= (len_target - num_matches) / len_source * 10.0 - # Reward for the proximity of found source letters to the beginning of the word in - # which the letter was found + # Reward for the proximity of found source letters to the beginning + # of the word in which the letter was found distances: List[int] word_lengths: List[int] distances, word_lengths = self.get_distances_from_word_beginning( @@ -250,93 +256,75 @@ def score_result(self, source: str, target: str, action: Action) -> float: score += (word_length - distance) / word_length * 15.0 return score - def filter_results(self, string: str): - self.result_index = 0 - scores: Dict[Action, float] = {} - for (action, description) in self.valid_actions: - scores[action] = self.score_result( + def filter_options(self, string: str): + self.option_index = 0 + scores: Dict[Option, float] = {} + for option in self.options: + scores[option] = self.score_option( string, - description.lower(), - action, + option, ) - self.valid_actions.sort(key=lambda _: scores[_[0]], reverse=True) - self.update_results() + self.options.sort(key=lambda _: scores[_], reverse=True) + self.update_options() - def update_results(self): + def update_options(self): i: int row: int cell: int - action: Optional[Action] - description: str + option: Optional[Option] index: int - for i, row in enumerate(dpg.get_item_children(self.results_table, slot=1)): + for i, row in enumerate(dpg.get_item_children(self.options_table, slot=1)): cell = dpg.get_item_children(row, slot=1)[0] - action = None - description = "" - if self.result_index < 4: + option = None + if len(self.options) < self.num_rows or self.option_index < 4: # A row in the upper half index = i dpg.bind_item_theme( cell, - themes.command_palette.result_highlighted - if i == self.result_index - else themes.command_palette.result, + themes.palette.option_highlighted + if i == self.option_index + else themes.palette.option, ) - elif len(self.valid_actions) - self.result_index < 5: + elif len(self.options) - self.option_index < 5: # A row in the lower half - index = len(self.valid_actions) + i - self.num_rows + index = len(self.options) + i - self.num_rows dpg.bind_item_theme( cell, - themes.command_palette.result_highlighted - if i - == self.num_rows - (len(self.valid_actions) - self.result_index) - else themes.command_palette.result, + themes.palette.option_highlighted + if i == self.num_rows - (len(self.options) - self.option_index) + else themes.palette.option, ) else: # The row in the middle - index = self.result_index + i - 4 + index = self.option_index + i - 4 dpg.bind_item_theme( cell, - themes.command_palette.result_highlighted + themes.palette.option_highlighted if i == 4 - else themes.command_palette.result, + else themes.palette.option, ) - if index >= 0 and index < len(self.valid_actions): - action, description = self.valid_actions[index] - dpg.set_item_label(cell, description) - dpg.set_item_user_data(cell, action) + if index >= 0 and index < len(self.options): + option = self.options[index] + dpg.set_item_label(cell, option.description if option is not None else "") + dpg.set_item_user_data(cell, option) - def navigate_result(self, index: Optional[int] = None, step: Optional[int] = None): + def navigate_option(self, index: Optional[int] = None, step: Optional[int] = None): assert type(index) is int or index is None, index assert type(step) is int or step is None, step if index is None and step is None: return elif index is not None: - if index >= len(self.valid_actions): + if index >= len(self.options): return elif step is not None: - index = self.result_index + step + index = self.option_index + step if index < 0: index = 0 - elif index >= len(self.valid_actions): - index = len(self.valid_actions) - 1 + elif index >= len(self.options): + index = len(self.options) - 1 assert index is not None - self.result_index = index - self.update_results() + self.option_index = index + self.update_options() - def perform_action(self, action: Optional[Action] = None, click: bool = False): - if click and action is None: - return - self.hide() - if not click: - action = self.valid_actions[self.result_index][0] - assert type(action) is Action, action - if action in self.action_history: - self.action_history.remove(action) - self.action_history.insert(0, action) - self.keybinding_handler.perform_action( - action, - self.current_contexts[-1], - self.current_project, - self.current_tab, - ) + def select_option(self, option: Optional[Option] = None, click: bool = False): + raise NotImplementedError("NOT YET IMPLEMENTED FOR THIS PALETTE") diff --git a/src/deareis/gui/palettes/command.py b/src/deareis/gui/palettes/command.py new file mode 100644 index 0000000..e91f803 --- /dev/null +++ b/src/deareis/gui/palettes/command.py @@ -0,0 +1,104 @@ +# DearEIS is licensed under the GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.html). +# Copyright 2023 DearEIS developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The licenses of DearEIS' dependencies and/or sources of portions of code are included in +# the LICENSES folder. + +from typing import ( + Dict, + List, + Optional, + Set, + Tuple, +) +from deareis.keybindings import KeybindingHandler +from deareis.enums import ( + Action, + Context, + action_contexts, + action_descriptions, +) +from deareis.data.project import Project +from deareis.gui.project import ProjectTab +from .base import ( + BasePalette, + Option, +) + + +class CommandPalette(BasePalette): + def __init__(self, keybinding_handler: KeybindingHandler): + super().__init__( + title="Command palette", + tooltip="Select an action to perform.\n\n", + ) + self.keybinding_handler: KeybindingHandler = keybinding_handler + self.action_contexts: Dict[Action, Set[Context]] = { + k: set(v) + for k, v in action_contexts.items() + if k + not in ( + Action.SHOW_COMMAND_PALETTE, + Action.SHOW_DATA_SET_PALETTE, + Action.SHOW_RESULT_PALETTE, + ) + } + self.valid_actions: List[Tuple[Action, str]] = [] + self.action_history: List[Action] = [] + + def show( + self, + contexts: List[Context], + project: Optional[Project], + tab: Optional[ProjectTab], + ): + self.context: Context = contexts[-1] + self.project: Optional[Project] = project + self.tab: Optional[ProjectTab] = tab + contexts_set: Set[Context] = set(contexts) + self.options = [] + action: Action + cons: Set[Context] + for action, cons in self.action_contexts.items(): + if len(cons.intersection(contexts_set)) == 0: + continue + description: str = action_descriptions[action].split("\n")[0] + if description.endswith(":"): + description = description[:-1] + elif description.endswith("."): + description = description[:-1] + self.options.append( + Option( + description=description, + rank=int(action), + data=action, + ) + ) + super().show() + + def select_option(self, option: Optional[Option] = None, click: bool = False): + if option is None: + return + self.hide() + if option in self.options_history: + self.options_history.remove(option) + self.options_history.insert(0, option) + self.keybinding_handler.perform_action( + option.data, + self.context, + self.project, + self.tab, + ) diff --git a/src/deareis/gui/palettes/data_set.py b/src/deareis/gui/palettes/data_set.py new file mode 100644 index 0000000..3f9f8f1 --- /dev/null +++ b/src/deareis/gui/palettes/data_set.py @@ -0,0 +1,104 @@ +# DearEIS is licensed under the GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.html). +# Copyright 2023 DearEIS developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The licenses of DearEIS' dependencies and/or sources of portions of code are included in +# the LICENSES folder. + +from typing import ( + Dict, + List, + Optional, +) +from deareis.enums import Context +from deareis.data import DataSet +from deareis.signals import Signal +import deareis.signals as signals +from deareis.data.project import Project +from deareis.gui.project import ProjectTab +from .base import ( + BasePalette, + Option, +) + + +class DataSetPalette(BasePalette): + def __init__(self): + super().__init__( + title="Data set palette", + tooltip="Select a data set to show.\n\n", + ) + self.histories: Dict[Project, List[Option]] = {} + + def show( + self, + project: Optional[Project], + tab: Optional[ProjectTab], + ): + context: Context = tab.get_active_context() + if context not in ( + Context.DATA_SETS_TAB, + Context.KRAMERS_KRONIG_TAB, + Context.ZHIT_TAB, + Context.DRT_TAB, + Context.FITTING_TAB, + Context.SIMULATION_TAB, + ): + return + self.context: Context = tab.get_active_context() + self.project: Optional[Project] = project + self.tab: Optional[ProjectTab] = tab + if project not in self.histories: + self.histories[project] = [] + self.options_history = self.histories[project] + self.options = [] + active_data_set: Optional[DataSet] = tab.get_active_data_set() + i: int = 0 + data: DataSet + for i, data in enumerate(project.get_data_sets()): + if data == active_data_set: + continue + self.options.append( + Option( + description=data.get_label(), + rank=i, + data=data, + ) + ) + if self.context == Context.SIMULATION_TAB: + self.options.append( + Option( + description="None", + rank=i+1, + data=None, + ) + ) + super().show() + + def select_option(self, option: Optional[Option] = None, click: bool = False): + if option is None: + return + self.hide() + if option in self.options_history: + self.options_history.remove(option) + self.options_history.insert(0, option) + if self.context == Context.SIMULATION_TAB: + signals.emit( + Signal.SELECT_SIMULATION_RESULT, + simulation=self.tab.get_active_simulation(), + data=option.data, + ) + else: + signals.emit(Signal.SELECT_DATA_SET, data=option.data) diff --git a/src/deareis/gui/palettes/result.py b/src/deareis/gui/palettes/result.py new file mode 100644 index 0000000..0d1bfb5 --- /dev/null +++ b/src/deareis/gui/palettes/result.py @@ -0,0 +1,154 @@ +# DearEIS is licensed under the GPLv3 or later (https://www.gnu.org/licenses/gpl-3.0.html). +# Copyright 2023 DearEIS developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# The licenses of DearEIS' dependencies and/or sources of portions of code are included in +# the LICENSES folder. + +from typing import ( + Any, + Callable, + Dict, + List, + Optional, +) +from deareis.enums import Context +from deareis.data import DataSet +from deareis.signals import Signal +import deareis.signals as signals +from deareis.data.project import Project +from deareis.gui.project import ProjectTab +from .base import ( + BasePalette, + Option, +) + + +class ResultPalette(BasePalette): + def __init__(self): + super().__init__( + title="Result palette", + tooltip="Select a result to show.\n\n", + ) + self.histories: Dict[Project, Dict[Context, Dict[DataSet, List[Option]]]] = {} + + def show( + self, + project: Optional[Project], + tab: Optional[ProjectTab], + ): + active_result_lookup: Dict[Context, Callable] = { + Context.KRAMERS_KRONIG_TAB: tab.get_active_test, + Context.ZHIT_TAB: tab.get_active_zhit, + Context.DRT_TAB: tab.get_active_drt, + Context.FITTING_TAB: tab.get_active_fit, + Context.SIMULATION_TAB: tab.get_active_simulation, + Context.PLOTTING_TAB: tab.get_active_plot, + } + context: Context = tab.get_active_context() + if context not in active_result_lookup: + return + self.active_data_set: Optional[DataSet] = tab.get_active_data_set() + self.context = context + self.project: Optional[Project] = project + self.tab: Optional[ProjectTab] = tab + if project not in self.histories: + self.histories[project] = {} + if context not in self.histories[project]: + self.histories[project][context] = {} + if self.active_data_set not in self.histories[project][context]: + self.histories[project][context][self.active_data_set] = [] + self.options_history = self.histories[project][context][self.active_data_set] + self.options = [] + active_result: Optional[Any] = active_result_lookup[context]() + i: int + result: Any + for i, result in enumerate(self.generate_options()): + if result == active_result: + continue + self.options.append( + Option( + description=self.format_result_label(result.get_label()), + rank=i, + data=result, + ) + ) + super().show() + + def format_result_label(self, label: str) -> str: + i: int = label.rfind(" (") + if i < 0: + return label + timestamp: str + label, timestamp = (label[:i], label[i + 1 :]) + max_length: int = 96 - len(timestamp) + if max_length < 5: + max_length = 5 + label = label.ljust(max_length) + if len(label.strip()) > max_length: + label = label[: max_length - 3] + "..." + return f"{label} {timestamp}" + + def generate_options(self) -> List[Any]: + if self.active_data_set is None and self.context not in ( + Context.SIMULATION_TAB, + Context.PLOTTING_TAB, + ): + return [] + return { + Context.KRAMERS_KRONIG_TAB: self.project.get_tests, + Context.ZHIT_TAB: self.project.get_zhits, + Context.DRT_TAB: self.project.get_drts, + Context.FITTING_TAB: self.project.get_fits, + Context.SIMULATION_TAB: lambda _: self.project.get_simulations(), + Context.PLOTTING_TAB: lambda _: self.project.get_plots(), + }.get(self.context, lambda _: [])(self.active_data_set) + + def select_option(self, option: Optional[Option] = None, click: bool = False): + if option is None: + return + self.hide() + if option in self.options_history: + self.options_history.remove(option) + self.options_history.insert(0, option) + signal: Signal = { + Context.KRAMERS_KRONIG_TAB: Signal.SELECT_TEST_RESULT, + Context.ZHIT_TAB: Signal.SELECT_ZHIT_RESULT, + Context.DRT_TAB: Signal.SELECT_DRT_RESULT, + Context.FITTING_TAB: Signal.SELECT_FIT_RESULT, + Context.SIMULATION_TAB: Signal.SELECT_SIMULATION_RESULT, + Context.PLOTTING_TAB: Signal.SELECT_PLOT_SETTINGS, + }[self.context] + kwargs: dict + if self.context == Context.PLOTTING_TAB: + kwargs = { + "settings": option.data, + } + else: + key: str = { + Context.KRAMERS_KRONIG_TAB: "test", + Context.ZHIT_TAB: "zhit", + Context.DRT_TAB: "drt", + Context.FITTING_TAB: "fit", + Context.SIMULATION_TAB: "simulation", + }[self.context] + kwargs = { + key: option.data, + "data": self.active_data_set, + } + signals.emit( + signal, + **kwargs, + ) diff --git a/src/deareis/gui/plots/base.py b/src/deareis/gui/plots/base.py index 7d50558..14572a5 100644 --- a/src/deareis/gui/plots/base.py +++ b/src/deareis/gui/plots/base.py @@ -33,6 +33,8 @@ def __init__(self): def _visibility_handler(self, sender, app): if not self._is_limits_adjustment_queued: return + elif not dpg.does_item_exist(self._plot): + return dpg.split_frame() self.adjust_limits() self._is_limits_adjustment_queued = False diff --git a/src/deareis/gui/project.py b/src/deareis/gui/project.py index ce09e76..5f7d900 100644 --- a/src/deareis/gui/project.py +++ b/src/deareis/gui/project.py @@ -146,7 +146,10 @@ def visibility_handler(tab): def resize(self, width: int, height: int): assert type(width) is int and width > 0, width assert type(height) is int and height > 0, height - self.tab_lookup[dpg.get_value(self.tab_bar)].resize(width, height) + tab: int = dpg.get_value(self.tab_bar) + if tab not in self.tab_lookup: + return + self.tab_lookup[tab].resize(width, height) def select_next_tab(self): current_tab: int = dpg.get_value(self.tab_bar) @@ -677,23 +680,16 @@ def get_filtered_plot_series( ) def has_active_input(self, context: Optional[Context] = None) -> bool: - assert type(context) is Context or context is None, context if context is None: context = self.get_active_context() - if context == Context.OVERVIEW_TAB: - return self.overview_tab.has_active_input() - elif context == Context.DATA_SETS_TAB: - return self.data_sets_tab.has_active_input() - elif context == Context.KRAMERS_KRONIG_TAB: - return self.kramers_kronig_tab.has_active_input() - elif context == Context.ZHIT_TAB: - return self.zhit_tab.has_active_input() - elif context == Context.DRT_TAB: - return self.drt_tab.has_active_input() - elif context == Context.FITTING_TAB: - return self.fitting_tab.has_active_input() - elif context == Context.SIMULATION_TAB: - return self.simulation_tab.has_active_input() - elif context == Context.PLOTTING_TAB: - return self.plotting_tab.has_active_input() - return False + assert type(context) is Context, context + return { + Context.OVERVIEW_TAB: self.overview_tab.has_active_input, + Context.DATA_SETS_TAB: self.data_sets_tab.has_active_input, + Context.KRAMERS_KRONIG_TAB: self.kramers_kronig_tab.has_active_input, + Context.ZHIT_TAB: self.zhit_tab.has_active_input, + Context.DRT_TAB: self.drt_tab.has_active_input, + Context.FITTING_TAB: self.fitting_tab.has_active_input, + Context.SIMULATION_TAB: self.simulation_tab.has_active_input, + Context.PLOTTING_TAB: self.plotting_tab.has_active_input, + }.get(context, lambda: False)() diff --git a/src/deareis/keybindings/__init__.py b/src/deareis/keybindings/__init__.py index 9ed3e15..14cfb24 100644 --- a/src/deareis/keybindings/__init__.py +++ b/src/deareis/keybindings/__init__.py @@ -214,6 +214,10 @@ def perform_action( signals.emit(Signal.SHOW_SETTINGS_KEYBINDINGS) elif action == Action.SHOW_COMMAND_PALETTE: signals.emit(Signal.SHOW_COMMAND_PALETTE) + elif action == Action.SHOW_DATA_SET_PALETTE: + signals.emit(Signal.SHOW_DATA_SET_PALETTE) + elif action == Action.SHOW_RESULT_PALETTE: + signals.emit(Signal.SHOW_RESULT_PALETTE) elif action == Action.SHOW_CHANGELOG: signals.emit(Signal.SHOW_CHANGELOG) elif action == Action.CHECK_UPDATES: @@ -695,6 +699,11 @@ def perform_action( Signal.SELECT_POINTS_TO_INTERPOLATE, data=data, ) + elif action == Action.PARALLEL_IMPEDANCE: + signals.emit( + Signal.SELECT_PARALLEL_IMPEDANCE, + data=data, + ) elif action == Action.SUBTRACT_IMPEDANCE: signals.emit( Signal.SELECT_IMPEDANCE_TO_SUBTRACT, diff --git a/src/deareis/program/__init__.py b/src/deareis/program/__init__.py index f9e851f..3c515e8 100644 --- a/src/deareis/program/__init__.py +++ b/src/deareis/program/__init__.py @@ -89,6 +89,7 @@ select_data_set_mask_to_copy, select_data_sets_to_average, select_impedance_to_subtract, + select_parallel_impedance, select_points_to_interpolate, toggle_data_point, ) @@ -842,6 +843,8 @@ def initialize_program(args: Namespace): ) signals.register(Signal.SHOW_ERROR_MESSAGE, STATE.program_window.error_message.show) signals.register(Signal.SHOW_COMMAND_PALETTE, STATE.show_command_palette) + signals.register(Signal.SHOW_DATA_SET_PALETTE, STATE.show_data_set_palette) + signals.register(Signal.SHOW_RESULT_PALETTE, STATE.show_result_palette) # Settings and help windows signals.register(Signal.SHOW_HELP_ABOUT, show_help_about) signals.register(Signal.SHOW_HELP_LICENSES, show_license_window) @@ -888,6 +891,7 @@ def initialize_program(args: Namespace): signals.register(Signal.SELECT_DATA_POINTS_TO_TOGGLE, select_data_points_to_toggle) signals.register(Signal.SELECT_DATA_SET_MASK_TO_COPY, select_data_set_mask_to_copy) signals.register(Signal.SELECT_IMPEDANCE_TO_SUBTRACT, select_impedance_to_subtract) + signals.register(Signal.SELECT_PARALLEL_IMPEDANCE, select_parallel_impedance) signals.register(Signal.SELECT_POINTS_TO_INTERPOLATE, select_points_to_interpolate) signals.register(Signal.TOGGLE_DATA_POINT, toggle_data_point) signals.register(Signal.APPLY_DATA_SET_MASK, apply_data_set_mask) diff --git a/src/deareis/program/data_sets.py b/src/deareis/program/data_sets.py index 16b4a9d..483eb19 100644 --- a/src/deareis/program/data_sets.py +++ b/src/deareis/program/data_sets.py @@ -42,6 +42,7 @@ from deareis.gui.data_sets.average_data_sets import AverageDataSets from deareis.gui.data_sets.copy_mask import CopyMask from deareis.gui.data_sets.interpolate_points import InterpolatePoints +from deareis.gui.data_sets.parallel_impedance import ParallelImpedance from deareis.gui.data_sets.subtract_impedance import SubtractImpedance from deareis.gui.data_sets.toggle_data_points import ToggleDataPoints from deareis.gui.file_dialog import FileDialog @@ -120,6 +121,11 @@ def load_simulation_as_data_set(*args, **kwargs): project.add_data_set(data) project_tab.populate_data_sets(project) signals.emit(Signal.SELECT_DATA_SET, data=data) + signals.emit( + Signal.SELECT_SIMULATION_RESULT, + simulation=simulation, + data=project_tab.get_active_data_set(context=Context.SIMULATION_TAB), + ) signals.emit( Signal.SELECT_PLOT_SETTINGS, settings=project_tab.get_active_plot(), @@ -471,3 +477,36 @@ def add_data(new: DataSet): window=interpolate_points.window, window_object=interpolate_points, ) + + +def select_parallel_impedance(*args, **kwargs): + if "popup" in kwargs: + dpg.hide_item(kwargs["popup"]) + dpg.split_frame(delay=33) + project: Optional[Project] = STATE.get_active_project() + project_tab: Optional[ProjectTab] = STATE.get_active_project_tab() + data: Optional[DataSet] = kwargs.get("data") + if project is None or project_tab is None or data is None: + return + + def add_data(new: DataSet): + assert project is not None + assert project_tab is not None + project.add_data_set(new) + project_tab.populate_data_sets(project) + signals.emit( + Signal.SELECT_PLOT_SETTINGS, + settings=project_tab.get_active_plot(), + ) + signals.emit(Signal.SELECT_DATA_SET, data=new) + signals.emit(Signal.CREATE_PROJECT_SNAPSHOT) + + parallel_impedance_window: ParallelImpedance = ParallelImpedance( + data=data, + callback=add_data, + ) + signals.emit( + Signal.BLOCK_KEYBINDINGS, + window=parallel_impedance_window.window, + window_object=parallel_impedance_window, + ) diff --git a/src/deareis/signals.py b/src/deareis/signals.py index 9f3f443..0cb3226 100644 --- a/src/deareis/signals.py +++ b/src/deareis/signals.py @@ -98,6 +98,7 @@ class Signal(IntEnum): SELECT_FIT_RESULT = auto() SELECT_HOME_TAB = auto() SELECT_IMPEDANCE_TO_SUBTRACT = auto() + SELECT_PARALLEL_IMPEDANCE = auto() SELECT_PLOT_APPEARANCE_SETTINGS = auto() SELECT_PLOT_SETTINGS = auto() SELECT_PLOT_TYPE = auto() @@ -110,11 +111,13 @@ class Signal(IntEnum): SHOW_BUSY_MESSAGE = auto() SHOW_CHANGELOG = auto() SHOW_COMMAND_PALETTE = auto() + SHOW_DATA_SET_PALETTE = auto() SHOW_ENLARGED_PLOT = auto() SHOW_ERROR_MESSAGE = auto() SHOW_GETTING_STARTED_WINDOW = auto() SHOW_HELP_ABOUT = auto() SHOW_HELP_LICENSES = auto() + SHOW_RESULT_PALETTE = auto() SHOW_SETTINGS_APPEARANCE = auto() SHOW_SETTINGS_DEFAULTS = auto() SHOW_SETTINGS_KEYBINDINGS = auto() diff --git a/src/deareis/state.py b/src/deareis/state.py index 2499a29..7615ebe 100644 --- a/src/deareis/state.py +++ b/src/deareis/state.py @@ -51,7 +51,11 @@ ) from deareis.gui.project import ProjectTab from deareis.gui.program import ProgramWindow -from deareis.gui.command_palette import CommandPalette +from deareis.gui.palettes import ( + CommandPalette, + DataSetPalette, + ResultPalette, +) from deareis.keybindings import KeybindingHandler from deareis.utility import calculate_checksum from deareis.gui.plotting.export import PlotExporter @@ -90,7 +94,11 @@ def __init__(self): self.project_state_snapshots: Dict[str, List[str]] = {} self.project_state_snapshot_indices: Dict[str, int] = {} self.project_state_saved_indices: Dict[str, int] = {} - self.command_palette: CommandPalette = CommandPalette(self.keybinding_handler) + self.command_palette: CommandPalette = CommandPalette( + keybinding_handler=self.keybinding_handler + ) + self.data_set_palette: DataSetPalette = DataSetPalette() + self.result_palette: ResultPalette = ResultPalette() self.plot_exporter: PlotExporter = PlotExporter(self.config) def set_active_modal_window(self, window: int, window_object: Any): @@ -314,6 +322,20 @@ def show_command_palette(self): contexts.extend([Context.PROJECT, project_tab.get_active_context()]) self.command_palette.show(contexts, project, project_tab) + def show_data_set_palette(self): + project: Optional[Project] = self.get_active_project() + project_tab: Optional[ProjectTab] = self.get_active_project_tab() + if project is None or project_tab is None: + return + self.data_set_palette.show(project, project_tab) + + def show_result_palette(self): + project: Optional[Project] = self.get_active_project() + project_tab: Optional[ProjectTab] = self.get_active_project_tab() + if project is None or project_tab is None: + return + self.result_palette.show(project, project_tab) + def show_plot_exporter(self, settings: PlotSettings, project: Project): self.plot_exporter.show(settings, project) diff --git a/src/deareis/themes.py b/src/deareis/themes.py index cc109e8..c05c3b2 100644 --- a/src/deareis/themes.py +++ b/src/deareis/themes.py @@ -965,15 +965,71 @@ def update_plot_series_theme_marker(parent: int, marker: int): ), category=dpg.mvThemeCat_Core, ) +_huge_error: int = dpg.generate_uuid() +with dpg.theme(tag=_huge_error): + with dpg.theme_component(dpg.mvAll): + dpg.add_theme_color( + dpg.mvThemeCol_Text, + ( + 255.0, + 0.0, + 0.0, + 255.0, + ), + category=dpg.mvThemeCat_Core, + ) +_large_error: int = dpg.generate_uuid() +with dpg.theme(tag=_large_error): + with dpg.theme_component(dpg.mvAll): + dpg.add_theme_color( + dpg.mvThemeCol_Text, + ( + 255.0, + 115.0, + 0.0, + 255.0, + ), + category=dpg.mvThemeCat_Core, + ) +_default_statistic: int = dpg.generate_uuid() +with dpg.theme(tag=_default_statistic): + with dpg.theme_component(dpg.mvAll): + dpg.add_theme_color( + dpg.mvThemeCol_Text, + ( + 255.0, + 255.0, + 255.0, + 255.0, + ), + category=dpg.mvThemeCat_Core, + ) +_highlighted_statistic: int = dpg.generate_uuid() +with dpg.theme(tag=_highlighted_statistic): + with dpg.theme_component(dpg.mvAll): + dpg.add_theme_color( + dpg.mvThemeCol_Text, + ( + 255.0, + 115.0, + 0.0, + 255.0, + ), + category=dpg.mvThemeCat_Core, + ) fitting: SimpleNamespace = SimpleNamespace( **{ "limited_parameter": _limited_parameter, + "huge_error": _huge_error, + "large_error": _large_error, + "highlighted_statistc": _highlighted_statistic, + "default_statistic": _default_statistic, } ) -_command_palette_result_highlighted: int -with dpg.theme() as _command_palette_result_highlighted: +_palette_option_highlighted: int +with dpg.theme() as _palette_option_highlighted: with dpg.theme_component(dpg.mvAll): dpg.add_theme_color( dpg.mvThemeCol_Text, @@ -992,8 +1048,8 @@ def update_plot_series_theme_marker(parent: int, marker: int): category=dpg.mvThemeCat_Core, ) -_command_palette_result: int -with dpg.theme() as _command_palette_result: +_palette_option: int +with dpg.theme() as _palette_option: with dpg.theme_component(dpg.mvAll): dpg.add_theme_color( dpg.mvThemeCol_Text, @@ -1013,10 +1069,10 @@ def update_plot_series_theme_marker(parent: int, marker: int): ) -command_palette: SimpleNamespace = SimpleNamespace( +palette: SimpleNamespace = SimpleNamespace( **{ - "result": _command_palette_result, - "result_highlighted": _command_palette_result_highlighted, + "option": _palette_option, + "option_highlighted": _palette_option_highlighted, } ) diff --git a/src/deareis/tooltips/data_sets.py b/src/deareis/tooltips/data_sets.py index 38bcfbf..7cad4cb 100644 --- a/src/deareis/tooltips/data_sets.py +++ b/src/deareis/tooltips/data_sets.py @@ -44,9 +44,12 @@ """.strip(), "interpolation_toggle": """ If checked, then the corresponding data point will be interpolated linearly between the two neighboring points. + """.strip(), + "parallel": """ +Add an impedance (typically a resistance) in parallel with the current data set. """.strip(), "subtract": """ -Subtract some amount(s) from the impedances of the current data set. +Subtract impedances from the current data set. """.strip(), "copy_circuit": """ Copy the chosen fitted circuit to the option above and open the circuit editor. diff --git a/src/deareis/tooltips/drt.py b/src/deareis/tooltips/drt.py index 0f7c7e2..ffb2e13 100644 --- a/src/deareis/tooltips/drt.py +++ b/src/deareis/tooltips/drt.py @@ -26,6 +26,7 @@ liu2020: str = "DOI:10.1016/j.electacta.2020.136864" boukamp2015: str = "DOI:10.1016/j.electacta.2014.12.059" boukamp2017: str = "DOI:10.1016/j.ssi.2016.10.009" +cultrera2020: str = "DOI:10.1088/2633-1357/abad0d" drt = SimpleNamespace( **{ @@ -57,9 +58,11 @@ This is only used when the method setting is set to TR-NNLS or TR-RBF. """.strip(), - "lambda_value": """ + "lambda_value": f""" The regularization parameter to use as part of the Tikhonov regularization. If the checkbox next to the input field is ticked, then an attempt will be made to automatically find a suitable value. However, further tweaking of the value manually is recommended. +More than one approach for suggesting a suitable regularization parameter may be available (e.g., 'L-curve corner search' from {cultrera2020}). It may be necessary to use a lower value for the 'Maximum symmetry' setting (e.g., 0.1) when using the TR-RBF method and the 'L-curve corner search' algorithm to suggest a suitable regularization parameter. + This is only used when the method setting is set to TR-NNLS or TR-RBF. """.strip(), "shape_coeff": """ diff --git a/src/deareis/tooltips/fitting.py b/src/deareis/tooltips/fitting.py index 636ed22..068780e 100644 --- a/src/deareis/tooltips/fitting.py +++ b/src/deareis/tooltips/fitting.py @@ -80,9 +80,18 @@ """.strip(), "parameter_value": """ The fitted value of the parameter. Hovering over the value will show a tooltip that also contains the parameter's unit. + +Values are highlighted in red to indicate that either the lower or the upper limit is preventing further adjustment of the value. """.strip(), "error": """ The estimated relative standard error of the fitted value. Hovering over the value will show a tooltip with the estimated absolute standard error of the fitted value. + +Relative errors greater than or equal to 5 % are highlighted in orange to draw attention since further action may be required. + +Relative errors greater than 100 % are highlighted in red to indicate that the chosen equivalent circuit clearly should be modified. + """.strip(), + "statistics": """ +Some values may be highlighted to indicate a possible issue. For example, if the number of function evaluations is equal to the chosen limit, then this may result in a poor fit. """.strip(), "delete": """ Delete the current fit result. diff --git a/src/deareis/tooltips/general.py b/src/deareis/tooltips/general.py index 59cf8b3..074610c 100644 --- a/src/deareis/tooltips/general.py +++ b/src/deareis/tooltips/general.py @@ -58,16 +58,17 @@ "adjust_limits": """ Automatically adjust the limits of the plots to fit the data. """.strip(), - "command_palette": """ -Search for an action to perform based on its description. Results can be navigated using the following keys: -- Home - first result + "palette": """ +The options can be navigated using the following keys: + +- Home - first option - Page up - five steps up - Up arrow - one step up - Down arrow - one step down - Page down - five steps down -- End - last result +- End - last option -The highlighted result can be executed by pressing Enter. The window can be closed by pressing Esc. +The highlighted option can be selected by pressing Enter. The window can be closed by pressing Esc. """.strip(), "auto_backup_interval": """ The number of actions between automatically saving a backup of the current state of a project in case of, e.g., crashes or power outages. diff --git a/src/deareis/version.py b/src/deareis/version.py index 7af4635..67161aa 100644 --- a/src/deareis/version.py +++ b/src/deareis/version.py @@ -17,4 +17,4 @@ # The licenses of DearEIS' dependencies and/or sources of portions of code are included in # the LICENSES folder. -PACKAGE_VERSION: str = "4.1.0" \ No newline at end of file +PACKAGE_VERSION: str = "4.2.0" \ No newline at end of file diff --git a/tests/test_gui.py b/tests/test_gui.py index 2ead396..e01e043 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -93,6 +93,7 @@ PARENT_FOLDER: str = dirname(__file__) TMP_FOLDER: str = gettempdir() +TMP_PROJECT: str = join(TMP_FOLDER, "deareis_temporary_test_project.json") START_TIME: float = 0.0 @@ -152,10 +153,8 @@ def finish_tests(): project: Optional[Project] = STATE.get_active_project() if project is not None and project.get_label() == "Test project": path: str = project.get_path() + assert path == TMP_PROJECT, (path, TMP_PROJECT) signals.emit(Signal.CLOSE_PROJECT, force=True) - if exists(path): - remove(path) - assert not exists(path) else: signals.emit(Signal.CLOSE_PROJECT, force=True) print(f"\nFinished in {time() - START_TIME:.2f} s") @@ -263,6 +262,13 @@ def test_plotting_tab(): ) @next_step(test_undo_redo) + def result_palette(): + print(" - Result palette") + signals.emit(Signal.SHOW_RESULT_PALETTE) + sleep(0.5) + STATE.active_modal_window_object.hide() + + @next_step(result_palette) def validate_delete_plot(): assert STATE.is_project_dirty(project) is True assert len(project.get_plots()) == 2 @@ -317,11 +323,12 @@ def cycle_plot_types(): sleep(0.5) text = dpg.get_clipboard_text().strip() assert text != "" - perform_action(action=Action.NEXT_SECONDARY_RESULT) + perform_action(action=Action.NEXT_PLOT_TAB) sleep(0.5) seen_types.append(settings.get_type()) if len(set(seen_types)) < len(seen_types): break + assert len(set(seen_types)) == len(PlotType), (set(seen_types), PlotType) @next_step(cycle_plot_types) def cycle_plots(): @@ -391,6 +398,8 @@ def switch_tab(): print("\n- Plotting tab") switch_tab() + context: Context = project_tab.get_active_context() + assert context == Context.PLOTTING_TAB, context def test_simulation_tab(): @@ -407,6 +416,20 @@ def test_simulation_tab(): ) @next_step(test_plotting_tab) + def result_palette(): + print(" - Result palette") + signals.emit(Signal.SHOW_RESULT_PALETTE) + sleep(0.5) + STATE.active_modal_window_object.hide() + + @next_step(result_palette) + def data_set_palette(): + print(" - Data set palette") + signals.emit(Signal.SHOW_DATA_SET_PALETTE) + sleep(0.5) + STATE.active_modal_window_object.hide() + + @next_step(data_set_palette) def validate_copy_sympy_expression_with_values(): assert STATE.is_project_dirty(project) is False text = dpg.get_clipboard_text().strip() @@ -830,6 +853,8 @@ def switch_tab(): print("\n- Simulation tab") switch_tab() + context: Context = project_tab.get_active_context() + assert context == Context.SIMULATION_TAB, context def test_drt_tab(): @@ -844,6 +869,20 @@ def test_drt_tab(): outputs: List[str] = list(label_to_drt_output.keys()) @next_step(test_simulation_tab) + def result_palette(): + print(" - Result palette") + signals.emit(Signal.SHOW_RESULT_PALETTE) + sleep(0.5) + STATE.active_modal_window_object.hide() + + @next_step(result_palette) + def data_set_palette(): + print(" - Data set palette") + signals.emit(Signal.SHOW_DATA_SET_PALETTE) + sleep(0.5) + STATE.active_modal_window_object.hide() + + @next_step(data_set_palette) def validate_copy_markdown_scores(): assert STATE.is_project_dirty(project) is False text = dpg.get_clipboard_text().strip() @@ -1257,6 +1296,8 @@ def switch_tab(): print("\n- DRT tab") switch_tab() + context: Context = project_tab.get_active_context() + assert context == Context.DRT_TAB, context def test_fitting_tab(): @@ -1271,6 +1312,20 @@ def test_fitting_tab(): outputs: List[str] = list(label_to_fit_sim_output.keys()) @next_step(test_drt_tab) + def result_palette(): + print(" - Result palette") + signals.emit(Signal.SHOW_RESULT_PALETTE) + sleep(0.5) + STATE.active_modal_window_object.hide() + + @next_step(result_palette) + def data_set_palette(): + print(" - Data set palette") + signals.emit(Signal.SHOW_DATA_SET_PALETTE) + sleep(0.5) + STATE.active_modal_window_object.hide() + + @next_step(data_set_palette) def validate_copy_sympy_expression_with_values(): assert STATE.is_project_dirty(project) is False text = dpg.get_clipboard_text().strip() @@ -1780,6 +1835,8 @@ def switch_tab(): print("\n- Fitting tab") switch_tab() + context: Context = project_tab.get_active_context() + assert context == Context.FITTING_TAB, context def test_zhit_tab(): @@ -1793,6 +1850,20 @@ def test_zhit_tab(): ) @next_step(test_fitting_tab) + def result_palette(): + print(" - Result palette") + signals.emit(Signal.SHOW_RESULT_PALETTE) + sleep(0.5) + STATE.active_modal_window_object.hide() + + @next_step(result_palette) + def data_set_palette(): + print(" - Data set palette") + signals.emit(Signal.SHOW_DATA_SET_PALETTE) + sleep(0.5) + STATE.active_modal_window_object.hide() + + @next_step(data_set_palette) def validate_copy_bode(): assert STATE.is_project_dirty(project) is False text = dpg.get_clipboard_text().strip() @@ -2051,6 +2122,8 @@ def switch_tab(): print("\n- Z-HIT tab") switch_tab() + context: Context = project_tab.get_active_context() + assert context == Context.ZHIT_TAB, context def test_kramers_kronig_tab(): @@ -2064,6 +2137,20 @@ def test_kramers_kronig_tab(): ) @next_step(test_zhit_tab) + def result_palette(): + print(" - Result palette") + signals.emit(Signal.SHOW_RESULT_PALETTE) + sleep(0.5) + STATE.active_modal_window_object.hide() + + @next_step(result_palette) + def data_set_palette(): + print(" - Data set palette") + signals.emit(Signal.SHOW_DATA_SET_PALETTE) + sleep(0.5) + STATE.active_modal_window_object.hide() + + @next_step(data_set_palette) def validate_copy_bode(): assert STATE.is_project_dirty(project) is False text = dpg.get_clipboard_text().strip() @@ -2336,6 +2423,8 @@ def switch_tab(): print("\n- Kramers-Kronig tab") switch_tab() + context: Context = project_tab.get_active_context() + assert context == Context.KRAMERS_KRONIG_TAB, context def test_data_sets_tab(): @@ -2349,6 +2438,13 @@ def test_data_sets_tab(): ) @next_step(test_kramers_kronig_tab) + def data_set_palette(): + print(" - Data set palette") + signals.emit(Signal.SHOW_DATA_SET_PALETTE) + sleep(0.5) + STATE.active_modal_window_object.hide() + + @next_step(data_set_palette) def validate_mask_points(): assert STATE.is_project_dirty(project) is True data = project_tab.get_active_data_set() @@ -2454,9 +2550,9 @@ def toggle_points(): @next_step(toggle_points) def validate_subtract_impedances(): assert STATE.is_project_dirty(project) is True - assert len(project.get_data_sets()) == 5 + assert len(project.get_data_sets()) == 6 data = project_tab.get_active_data_set() - assert data.get_label() == "Average - interpolated - subtracted" + assert data.get_label() == "Average - interpolated - added parallel impedance - subtracted" signals.emit(Signal.SAVE_PROJECT) @next_step(validate_subtract_impedances) @@ -2466,10 +2562,30 @@ def subtract_impedances(): perform_action(action=Action.SUBTRACT_IMPEDANCE) sleep(0.5) dpg.set_value(STATE.active_modal_window_object.constant_real, 150) + STATE.active_modal_window_object.update_preview() sleep(0.5) STATE.active_modal_window_object.accept() @next_step(subtract_impedances) + def validate_parallel_impedances(): + assert STATE.is_project_dirty(project) is True + assert len(project.get_data_sets()) == 5 + data = project_tab.get_active_data_set() + assert data.get_label() == "Average - interpolated - added parallel impedance" + signals.emit(Signal.SAVE_PROJECT) + + @next_step(validate_parallel_impedances) + def parallel_impedances(): + print(" - Add parallel impedance") + assert STATE.is_project_dirty(project) is False + perform_action(action=Action.PARALLEL_IMPEDANCE) + sleep(0.5) + dpg.set_value(STATE.active_modal_window_object.constant_real, 500) + STATE.active_modal_window_object.update_preview() + sleep(0.5) + STATE.active_modal_window_object.accept() + + @next_step(parallel_impedances) def validate_interpolate_data_points(): assert STATE.is_project_dirty(project) is True assert len(project.get_data_sets()) == 4 @@ -2578,6 +2694,8 @@ def switch_tab(): print("\n- Data sets tab") switch_tab() + context: Context = project_tab.get_active_context() + assert context == Context.DATA_SETS_TAB, context def test_overview_tab(): @@ -2612,12 +2730,14 @@ def rename_project(): signals.emit(Signal.RENAME_PROJECT, label="Test project") print("\n- Overview tab") + context: Context = project_tab.get_active_context() + assert context == Context.OVERVIEW_TAB, context rename_project() def test_project(): project: Optional[Project] = None - path = join(TMP_FOLDER, "deareis_temporary_test_project.json") + path = TMP_PROJECT if exists(path): remove(path) diff --git a/version.txt b/version.txt index 99eba4d..ef8d756 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.1.0 \ No newline at end of file +4.2.0 \ No newline at end of file