diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a1f223a..97544a5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,10 +4,12 @@ repos: hooks: - id: isort args: ["--profile", "black", "--filter-files"] + - repo: https://github.com/ambv/black rev: 21.4b2 hooks: - id: black + - repo: https://gitlab.com/pycqa/flake8 rev: 3.9.1 hooks: @@ -15,9 +17,20 @@ repos: args: ["--ignore=ANN001,ANN101,ANN002,W503", --max-line-length=88] additional_dependencies: - flake8-annotations + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v3.4.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: debug-statements + + - repo: local + hooks: + - id: export-requirements + name: export-requirements + entry: poetry export + language: system + always_run: true + pass_filenames: false + args: ["-f", "requirements.txt", "--output", "requirements.txt"] diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 70e3a78..54e6a79 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,12 +1,57 @@ +========= Changelog ========= All notable changes to this project will be documented in this file. The format is based on `Keep a -Changelog `__, and this project +Changelog `_, and this project adheres to `Semantic -Versioning `__. +Versioning `_. + + +[v0.5.7] +-------- + +Changed +~~~~~~~ + +User experience +=============== + +- overhaul the :code:`TestHandlerView` to be better oragnized +- overhaul the :code:`EvaluationWindow` to be better oragnized +- setting labels for each :code:`Test` is now handled in the :code:`EvaluationWindow`s' "Plot" tab +- updated docs +- ensured exported plot dimensions are always uniform + +Performance +=========== + +- updated the :code:`TestHandler` to poll for readings asynchronously +- updated the :code:`TestHandler` to be more robust when generating log files +- minor performance buff to log processing +- minor performance buff to the :code:`LivePlot` component +- minor performance buff to :code:`Project` serialization +- minor performance buff to reading user configuration file + +Data handling +============= + +- the :code:`Project` data model now records calcium concentration +- updated the :code:`Test` object model to handle the :code:`Reading` class +- updated the :code:`Project` object model to be more backwards compatible +- refactored data analysis out of the :code:`EvaluationWindow` and into its own :code:`score` function +- updated :code:`score` function to handle the :code:`Reading` class + +Misc +==== + +- update all :code:`os.path` operations to fancy :code:`pathlib.Path` operations +- update all :code:`matplotlib` code to use the object oriented API +- fixed some lag that would accumulate when displaying log messages in the main menu +- lots of misc. code cleanup / reorganizing + [v0.5.6] -------- @@ -129,7 +174,7 @@ Added - report export as CSV (default) - report export as flattened JSON (not human readable) - more descriptive window titles, all windows get the app icon ### - + Changed ~~~~~~~ @@ -143,7 +188,7 @@ Changed - general linting and cleanup ### Fixed - bug in observed baseline pressure reporting - the Live Plot stops updating (clearing itself) at the end of a test - + Removed ~~~~~~~ @@ -159,6 +204,9 @@ Added - rinse dialog, accessible from the menu bar - help text, accessible from the menu bar -- get\_resource function for getting resource files. can be used for resources with bundled executables later ### Changed +- get_resource function for getting resource files. can be used for resources with bundled executables later + +Changed +~~~~~~~ - reset versioning to v0.1.0 - moved project loading functionality to menu bar diff --git a/COPYING b/COPYING index e72bfdd..f288702 100644 --- a/COPYING +++ b/COPYING @@ -671,4 +671,4 @@ into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read -. \ No newline at end of file +. diff --git a/README.rst b/README.rst index 912e72d..07737ac 100644 --- a/README.rst +++ b/README.rst @@ -9,9 +9,9 @@ performance testing. If you are working with Teledyne SSI Next Generation pumps generally, please check out `py-hplc`_! This project is stable and usable in a production environment, but listed as in beta due to the lack of a test suite (yet!). -If you notice something weird, fragile, or otherwise encounter a bug, please open an `issue`_. +If you notice something weird, fragile, or otherwise encounter a bug, please open an `issue`_. -.. image:: https://raw.githubusercontent.com/teauxfu/scalewiz/main/img/main_menu(details).PNG +.. image:: https://raw.githubusercontent.com/teauxfu/scalewiz/main/img/main_menu.PNG .. image:: https://raw.githubusercontent.com/teauxfu/scalewiz/main/img/evaluation(plot).PNG @@ -22,6 +22,10 @@ Installation python -m pip install --user scalewiz +Or, if you use :code:`pipx` (`try it!`_ 😉) :: + + pipx install scalewiz + Usage ===== @@ -29,6 +33,11 @@ Usage python -m scalewiz +If Python is on your PATH (or you used :code:`pipx` 😎), simply :: + + scalewiz + + Further instructions can be viewed in the `docs`_ section of this repo or with the Help button in the main menu. @@ -70,3 +79,4 @@ Acknowledgements .. _`py-hplc`: https://github.com/teauxfu/py-hplc .. _`docs`: https://github.com/teauxfu/scalewiz/blob/main/doc/index.rst#scalewiz-user-guide .. _`issue`: https://github.com/teauxfu/scalewiz/issues +.. _`try it!`: https://pypa.github.io/pipx/ diff --git a/doc/index.rst b/doc/index.rst index 1c7dc17..c93f950 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -30,7 +30,7 @@ If you need to clear a date field, just click its label. Experiment parameters ~~~~~~~~~~~~~~~~~~~~~ -This is the most important one. The first two fields only affect the +This is the most important one. The first few fields only affect the final report. The last five fields affect how the tests are conducted and scored. @@ -73,7 +73,7 @@ If you don't already have a project loaded, click 'Project' > 'Load existing' from the menu bar. If a project is currently loaded, it's name will be displayed as shown below. -.. image:: ../img/main_menu(loaded).PNG +.. image:: ../img/main_menu.PNG :alt: main menu with loaded project Use the 'Devices' dropdown boxes to select the serial ports the pumps @@ -87,7 +87,10 @@ Blanks ~~~~~~ If you are running a blank, enter a name for it. The notes field may be -used to store any other relevant information. |trial entry| +used to store any other relevant information. + + .. image:: ../img/main_menu(blank).PNG + :alt: blank entry Trials ~~~~~~ @@ -113,13 +116,6 @@ You can interrupt the uptake cycle (or the test itself) at any time by clicking the 'Stop' button. This will stop the pumps, then attempt to save the data to file. -While a test is running, you may click 'Toggle Details' to show/hide a -more detailed view of the experiment state, including a live plot of the -data as it is collected. - -.. image:: ../img/main_menu(details).PNG - :alt: live plot - A test will automatically stop itself and the pumps when either the time limit or pressure limit has been reached. The 'Start' button will become a 'New' button, which you can use to initialize a new test. @@ -130,9 +126,11 @@ Rinses Between each test, it is necessary to rinse the system. Clicking 'Rinse' from the menu bar will create a small dialog that can do this for you. -|rinse dialog| +.. image:: ../img/rinse_dialog.PNG + :alt: rinse dialog -|rinse dialog in progress| +.. image:: ../img/rinse_dialog(rinsing).PNG + :alt: rinse dialog in progress The button will temporarily disable while acting as a status label to show the progression of the rinse. Closing the dialog will terminate the @@ -149,23 +147,25 @@ Click 'Evaluation' from the menu bar to open the Evalutaion Window. The data for each test in the project will be displayed horizontally as a row. -- Report As: what to call the test on the plot -- Minutes: the duration of the test, (# of measurements) +- Minutes: the duration of the test - Pump: which series of pressure measurements to use for scoring -- Baseline: the observed baseline pressure for the selected Pump -- Max: the highest pressure observed for the selected Pump +- Baseline PSI: the observed baseline pressure for the selected Pump +- Max PSI: the highest pressure observed for the selected Pump - Clarity: the observed water clarity -- Notes: any misc. info associated with the test. may be edited at any - time -- Result: the test's score, considering the selected Pump -- Report: a checkbox for indicating whether or not a test should be - included on the report +- Notes: any misc. info associated with the test. +- Result: the test's score, considering the selected Pump and blanks on report +- Report: a checkbox for indicating whether or not a test should be included on the report + +.. note:: + + Blanks will only be factored into the scoring process if marked as 'On Report' + Plot ~~~~ -The 'Plot' tab displays the most recent plot of all tests with a ticked -'Include on Report' box. +The 'Plot' tab displays the most recent plot of all tests with a ticked 'Include on Report' box. +You can change the Label associated with each test using the entries on the right. .. image:: ../img/evaluation(plot).PNG :alt: plot frame with some data @@ -178,10 +178,25 @@ The 'Calculations' tab displays a text log of the evaluation of all tests with a ticked 'Include on Report' box. This log is automatically exported next to the report file when you click the 'Export' button. +.. image:: ../img/evaluation(calcs).PNG + :alt: calculations frame with some data + Generating a report ~~~~~~~~~~~~~~~~~~~ You can export a report at any time by clicking the 'Export' button. +This will output, next to the Project's .json file, + +- a .txt file copy of the most recent calculations log +- a .jpeg file of the Project's plot +- an either .csv or .json file with a summary of the results + +.. note:: + + The results are typically exported to CSV for easier parsing in Excel or similar. + Support for JSON reports are more or less accidental at time of writing. + If you are able and or willing to parse the JSON, it may be more useful to just work with the Project's JSON file directly. + Running tests concurrently -------------------------- @@ -193,9 +208,4 @@ tab will appear on the main menu, and can be used normally. :alt: two systems At the time of writing, a particular project may only be loaded to one -system at a time. Loading the same project to more than one system may -result in data loss. - -.. |trial entry| image:: ../img/main_menu(blank).PNG -.. |rinse dialog| image:: ../img/rinse_dialog.PNG -.. |rinse dialog in progress| image:: ../img/rinse_dialog(rinsing).PNG +'System' at a time. diff --git a/img/evaluation(calcs).PNG b/img/evaluation(calcs).PNG new file mode 100644 index 0000000..72a3d92 Binary files /dev/null and b/img/evaluation(calcs).PNG differ diff --git a/img/evaluation(data).PNG b/img/evaluation(data).PNG index 1999391..483edf6 100644 Binary files a/img/evaluation(data).PNG and b/img/evaluation(data).PNG differ diff --git a/img/evaluation(plot).PNG b/img/evaluation(plot).PNG index 2ad008d..a338a9b 100644 Binary files a/img/evaluation(plot).PNG and b/img/evaluation(plot).PNG differ diff --git a/img/main_menu(blank).PNG b/img/main_menu(blank).PNG index 0489402..0bd93f9 100644 Binary files a/img/main_menu(blank).PNG and b/img/main_menu(blank).PNG differ diff --git a/img/main_menu(concurrent).PNG b/img/main_menu(concurrent).PNG index 58cc452..4e7dbb3 100644 Binary files a/img/main_menu(concurrent).PNG and b/img/main_menu(concurrent).PNG differ diff --git a/img/main_menu(details).PNG b/img/main_menu(details).PNG deleted file mode 100644 index dcb837c..0000000 Binary files a/img/main_menu(details).PNG and /dev/null differ diff --git a/img/main_menu(loaded).PNG b/img/main_menu(loaded).PNG deleted file mode 100644 index 8204007..0000000 Binary files a/img/main_menu(loaded).PNG and /dev/null differ diff --git a/img/main_menu(project).PNG b/img/main_menu(project).PNG index abb0de8..c81542b 100644 Binary files a/img/main_menu(project).PNG and b/img/main_menu(project).PNG differ diff --git a/img/main_menu(running).PNG b/img/main_menu(running).PNG new file mode 100644 index 0000000..ed1363e Binary files /dev/null and b/img/main_menu(running).PNG differ diff --git a/img/main_menu(trial).PNG b/img/main_menu(trial).PNG index 8f2bc44..958283c 100644 Binary files a/img/main_menu(trial).PNG and b/img/main_menu(trial).PNG differ diff --git a/img/main_menu(uptake).PNG b/img/main_menu(uptake).PNG index e9da557..51d22ff 100644 Binary files a/img/main_menu(uptake).PNG and b/img/main_menu(uptake).PNG differ diff --git a/img/main_menu.PNG b/img/main_menu.PNG index 0bce40c..bb63983 100644 Binary files a/img/main_menu.PNG and b/img/main_menu.PNG differ diff --git a/img/project_editor(experiment).PNG b/img/project_editor(experiment).PNG index 25379c4..6a27a27 100644 Binary files a/img/project_editor(experiment).PNG and b/img/project_editor(experiment).PNG differ diff --git a/img/project_editor(report).PNG b/img/project_editor(report).PNG index b9ef04e..6ab8898 100644 Binary files a/img/project_editor(report).PNG and b/img/project_editor(report).PNG differ diff --git a/img/project_editor.PNG b/img/project_editor.PNG index 644b61c..236b36c 100644 Binary files a/img/project_editor.PNG and b/img/project_editor.PNG differ diff --git a/poetry.lock b/poetry.lock index 9dff7b3..3ebdd03 100644 --- a/poetry.lock +++ b/poetry.lock @@ -30,6 +30,14 @@ packaging = "*" six = ">=1.9.0" webencodings = "*" +[[package]] +name = "cfgv" +version = "3.2.0" +description = "Validate configuration and produce human readable error messages." +category = "dev" +optional = false +python-versions = ">=3.6.1" + [[package]] name = "cycler" version = "0.10.0" @@ -41,6 +49,14 @@ python-versions = "*" [package.dependencies] six = "*" +[[package]] +name = "distlib" +version = "0.3.1" +description = "Distribution utilities" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "docutils" version = "0.17.1" @@ -49,6 +65,25 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "filelock" +version = "3.0.12" +description = "A platform independent file lock." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "identify" +version = "2.2.4" +description = "File identification library for Python" +category = "dev" +optional = false +python-versions = ">=3.6.1" + +[package.extras] +license = ["editdistance-s"] + [[package]] name = "kiwisolver" version = "1.3.1" @@ -59,7 +94,7 @@ python-versions = ">=3.6" [[package]] name = "matplotlib" -version = "3.4.1" +version = "3.4.2" description = "Python plotting package" category = "main" optional = false @@ -73,9 +108,17 @@ pillow = ">=6.2.0" pyparsing = ">=2.2.1" python-dateutil = ">=2.7" +[[package]] +name = "nodeenv" +version = "1.6.0" +description = "Node.js virtual environment builder" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "numpy" -version = "1.20.2" +version = "1.20.3" description = "NumPy is the fundamental package for array computing with Python." category = "main" optional = false @@ -116,9 +159,25 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "pre-commit" +version = "2.12.1" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +category = "dev" +optional = false +python-versions = ">=3.6.1" + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +toml = "*" +virtualenv = ">=20.0.8" + [[package]] name = "py-hplc" -version = "0.1.7" +version = "1.0.2" description = "An unoffical Python wrapper for the SSI-Teledyne Next Generation class HPLC pumps." category = "main" optional = false @@ -173,6 +232,14 @@ category = "main" optional = false python-versions = "*" +[[package]] +name = "pyyaml" +version = "5.4.1" +description = "YAML parser and emitter for Python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" + [[package]] name = "readme-renderer" version = "29.0" @@ -203,7 +270,7 @@ dev = ["pytest"] [[package]] name = "six" -version = "1.15.0" +version = "1.16.0" description = "Python 2 and 3 compatibility utilities" category = "main" optional = false @@ -220,6 +287,14 @@ python-versions = "*" [package.dependencies] babel = "*" +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + [[package]] name = "tomlkit" version = "0.7.0" @@ -228,6 +303,24 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "virtualenv" +version = "20.4.6" +description = "Virtual Python Environment builder" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" + +[package.dependencies] +appdirs = ">=1.4.3,<2" +distlib = ">=0.3.1,<1" +filelock = ">=3.0.0,<4" +six = ">=1.9.0,<2" + +[package.extras] +docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] +testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] + [[package]] name = "webencodings" version = "0.5.1" @@ -239,7 +332,7 @@ python-versions = "*" [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "759021d4e6282b3bc5d0faa70d8721b6dd062e5b482e1796fd5c44b4e286931e" +content-hash = "c8aea8aaf25674e2103327faab3031b697a230a0b36c8015cd08cbbb87a79d4e" [metadata.files] appdirs = [ @@ -254,14 +347,30 @@ bleach = [ {file = "bleach-3.3.0-py2.py3-none-any.whl", hash = "sha256:6123ddc1052673e52bab52cdc955bcb57a015264a1c57d37bea2f6b817af0125"}, {file = "bleach-3.3.0.tar.gz", hash = "sha256:98b3170739e5e83dd9dc19633f074727ad848cbedb6026708c8ac2d3b697a433"}, ] +cfgv = [ + {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"}, + {file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"}, +] cycler = [ {file = "cycler-0.10.0-py2.py3-none-any.whl", hash = "sha256:1d8a5ae1ff6c5cf9b93e8811e581232ad8920aeec647c37316ceac982b08cb2d"}, {file = "cycler-0.10.0.tar.gz", hash = "sha256:cd7b2d1018258d7247a71425e9f26463dfb444d411c39569972f4ce586b0c9d8"}, ] +distlib = [ + {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, + {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"}, +] docutils = [ {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, ] +filelock = [ + {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, + {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, +] +identify = [ + {file = "identify-2.2.4-py2.py3-none-any.whl", hash = "sha256:ad9f3fa0c2316618dc4d840f627d474ab6de106392a4f00221820200f490f5a8"}, + {file = "identify-2.2.4.tar.gz", hash = "sha256:9bcc312d4e2fa96c7abebcdfb1119563b511b5e3985ac52f60d9116277865b2e"}, +] kiwisolver = [ {file = "kiwisolver-1.3.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:fd34fbbfbc40628200730bc1febe30631347103fc8d3d4fa012c21ab9c11eca9"}, {file = "kiwisolver-1.3.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:d3155d828dec1d43283bd24d3d3e0d9c7c350cdfcc0bd06c0ad1209c1bbc36d0"}, @@ -297,51 +406,55 @@ kiwisolver = [ {file = "kiwisolver-1.3.1.tar.gz", hash = "sha256:950a199911a8d94683a6b10321f9345d5a3a8433ec58b217ace979e18f16e248"}, ] matplotlib = [ - {file = "matplotlib-3.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7a54efd6fcad9cb3cd5ef2064b5a3eeb0b63c99f26c346bdcf66e7c98294d7cc"}, - {file = "matplotlib-3.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:86dc94e44403fa0f2b1dd76c9794d66a34e821361962fe7c4e078746362e3b14"}, - {file = "matplotlib-3.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:574306171b84cd6854c83dc87bc353cacc0f60184149fb00c9ea871eca8c1ecb"}, - {file = "matplotlib-3.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:84a10e462120aa7d9eb6186b50917ed5a6286ee61157bfc17c5b47987d1a9068"}, - {file = "matplotlib-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:81e6fe8b18ef5be67f40a1d4f07d5a4ed21d3878530193898449ddef7793952f"}, - {file = "matplotlib-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:c45e7bf89ea33a2adaef34774df4e692c7436a18a48bcb0e47a53e698a39fa39"}, - {file = "matplotlib-3.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1f83a32e4b6045191f9d34e4dc68c0a17c870b57ef9cca518e516da591246e79"}, - {file = "matplotlib-3.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:a18cc1ab4a35b845cf33b7880c979f5c609fd26c2d6e74ddfacb73dcc60dd956"}, - {file = "matplotlib-3.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:ac2a30a09984c2719f112a574b6543ccb82d020fd1b23b4d55bf4759ba8dd8f5"}, - {file = "matplotlib-3.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:a97781453ac79409ddf455fccf344860719d95142f9c334f2a8f3fff049ffec3"}, - {file = "matplotlib-3.4.1-cp38-cp38-win32.whl", hash = "sha256:2eee37340ca1b353e0a43a33da79d0cd4bcb087064a0c3c3d1329cdea8fbc6f3"}, - {file = "matplotlib-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:90dbc007f6389bcfd9ef4fe5d4c78c8d2efe4e0ebefd48b4f221cdfed5672be2"}, - {file = "matplotlib-3.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7f16660edf9a8bcc0f766f51c9e1b9d2dc6ceff6bf636d2dbd8eb925d5832dfd"}, - {file = "matplotlib-3.4.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:a989022f89cda417f82dbf65e0a830832afd8af743d05d1414fb49549287ff04"}, - {file = "matplotlib-3.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:be4430b33b25e127fc4ea239cc386389de420be4d63e71d5359c20b562951ce1"}, - {file = "matplotlib-3.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:7561fd541477d41f3aa09457c434dd1f7604f3bd26d7858d52018f5dfe1c06d1"}, - {file = "matplotlib-3.4.1-cp39-cp39-win32.whl", hash = "sha256:9f374961a3996c2d1b41ba3145462c3708a89759e604112073ed6c8bdf9f622f"}, - {file = "matplotlib-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:53ceb12ef44f8982b45adc7a0889a7e2df1d758e8b360f460e435abe8a8cd658"}, - {file = "matplotlib-3.4.1.tar.gz", hash = "sha256:84d4c4f650f356678a5d658a43ca21a41fca13f9b8b00169c0b76e6a6a948908"}, + {file = "matplotlib-3.4.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c541ee5a3287efe066bbe358320853cf4916bc14c00c38f8f3d8d75275a405a9"}, + {file = "matplotlib-3.4.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:3a5c18dbd2c7c366da26a4ad1462fe3e03a577b39e3b503bbcf482b9cdac093c"}, + {file = "matplotlib-3.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:a9d8cb5329df13e0cdaa14b3b43f47b5e593ec637f13f14db75bb16e46178b05"}, + {file = "matplotlib-3.4.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:7ad19f3fb6145b9eb41c08e7cbb9f8e10b91291396bee21e9ce761bb78df63ec"}, + {file = "matplotlib-3.4.2-cp37-cp37m-win32.whl", hash = "sha256:7a58f3d8fe8fac3be522c79d921c9b86e090a59637cb88e3bc51298d7a2c862a"}, + {file = "matplotlib-3.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6382bc6e2d7e481bcd977eb131c31dee96e0fb4f9177d15ec6fb976d3b9ace1a"}, + {file = "matplotlib-3.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6a6a44f27aabe720ec4fd485061e8a35784c2b9ffa6363ad546316dfc9cea04e"}, + {file = "matplotlib-3.4.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1c1779f7ab7d8bdb7d4c605e6ffaa0614b3e80f1e3c8ccf7b9269a22dbc5986b"}, + {file = "matplotlib-3.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:5826f56055b9b1c80fef82e326097e34dc4af8c7249226b7dd63095a686177d1"}, + {file = "matplotlib-3.4.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:0bea5ec5c28d49020e5d7923c2725b837e60bc8be99d3164af410eb4b4c827da"}, + {file = "matplotlib-3.4.2-cp38-cp38-win32.whl", hash = "sha256:6475d0209024a77f869163ec3657c47fed35d9b6ed8bccba8aa0f0099fbbdaa8"}, + {file = "matplotlib-3.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:21b31057bbc5e75b08e70a43cefc4c0b2c2f1b1a850f4a0f7af044eb4163086c"}, + {file = "matplotlib-3.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b26535b9de85326e6958cdef720ecd10bcf74a3f4371bf9a7e5b2e659c17e153"}, + {file = "matplotlib-3.4.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:32fa638cc10886885d1ca3d409d4473d6a22f7ceecd11322150961a70fab66dd"}, + {file = "matplotlib-3.4.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:956c8849b134b4a343598305a3ca1bdd3094f01f5efc8afccdebeffe6b315247"}, + {file = "matplotlib-3.4.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:85f191bb03cb1a7b04b5c2cca4792bef94df06ef473bc49e2818105671766fee"}, + {file = "matplotlib-3.4.2-cp39-cp39-win32.whl", hash = "sha256:b1d5a2cedf5de05567c441b3a8c2651fbde56df08b82640e7f06c8cd91e201f6"}, + {file = "matplotlib-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:df815378a754a7edd4559f8c51fc7064f779a74013644a7f5ac7a0c31f875866"}, + {file = "matplotlib-3.4.2.tar.gz", hash = "sha256:d8d994cefdff9aaba45166eb3de4f5211adb4accac85cbf97137e98f26ea0219"}, +] +nodeenv = [ + {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, + {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, ] numpy = [ - {file = "numpy-1.20.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e9459f40244bb02b2f14f6af0cd0732791d72232bbb0dc4bab57ef88e75f6935"}, - {file = "numpy-1.20.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:a8e6859913ec8eeef3dbe9aed3bf475347642d1cdd6217c30f28dee8903528e6"}, - {file = "numpy-1.20.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:9cab23439eb1ebfed1aaec9cd42b7dc50fc96d5cd3147da348d9161f0501ada5"}, - {file = "numpy-1.20.2-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:9c0fab855ae790ca74b27e55240fe4f2a36a364a3f1ebcfd1fb5ac4088f1cec3"}, - {file = "numpy-1.20.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:61d5b4cf73622e4d0c6b83408a16631b670fc045afd6540679aa35591a17fe6d"}, - {file = "numpy-1.20.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:d15007f857d6995db15195217afdbddfcd203dfaa0ba6878a2f580eaf810ecd6"}, - {file = "numpy-1.20.2-cp37-cp37m-win32.whl", hash = "sha256:d76061ae5cab49b83a8cf3feacefc2053fac672728802ac137dd8c4123397677"}, - {file = "numpy-1.20.2-cp37-cp37m-win_amd64.whl", hash = "sha256:bad70051de2c50b1a6259a6df1daaafe8c480ca98132da98976d8591c412e737"}, - {file = "numpy-1.20.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:719656636c48be22c23641859ff2419b27b6bdf844b36a2447cb39caceb00935"}, - {file = "numpy-1.20.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:aa046527c04688af680217fffac61eec2350ef3f3d7320c07fd33f5c6e7b4d5f"}, - {file = "numpy-1.20.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2428b109306075d89d21135bdd6b785f132a1f5a3260c371cee1fae427e12727"}, - {file = "numpy-1.20.2-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:e8e4fbbb7e7634f263c5b0150a629342cc19b47c5eba8d1cd4363ab3455ab576"}, - {file = "numpy-1.20.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:edb1f041a9146dcf02cd7df7187db46ab524b9af2515f392f337c7cbbf5b52cd"}, - {file = "numpy-1.20.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:c73a7975d77f15f7f68dacfb2bca3d3f479f158313642e8ea9058eea06637931"}, - {file = "numpy-1.20.2-cp38-cp38-win32.whl", hash = "sha256:6c915ee7dba1071554e70a3664a839fbc033e1d6528199d4621eeaaa5487ccd2"}, - {file = "numpy-1.20.2-cp38-cp38-win_amd64.whl", hash = "sha256:471c0571d0895c68da309dacee4e95a0811d0a9f9f532a48dc1bea5f3b7ad2b7"}, - {file = "numpy-1.20.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4703b9e937df83f5b6b7447ca5912b5f5f297aba45f91dbbbc63ff9278c7aa98"}, - {file = "numpy-1.20.2-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:abc81829c4039e7e4c30f7897938fa5d4916a09c2c7eb9b244b7a35ddc9656f4"}, - {file = "numpy-1.20.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:377751954da04d4a6950191b20539066b4e19e3b559d4695399c5e8e3e683bf6"}, - {file = "numpy-1.20.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:6e51e417d9ae2e7848314994e6fc3832c9d426abce9328cf7571eefceb43e6c9"}, - {file = "numpy-1.20.2-cp39-cp39-win32.whl", hash = "sha256:780ae5284cb770ade51d4b4a7dce4faa554eb1d88a56d0e8b9f35fca9b0270ff"}, - {file = "numpy-1.20.2-cp39-cp39-win_amd64.whl", hash = "sha256:924dc3f83de20437de95a73516f36e09918e9c9c18d5eac520062c49191025fb"}, - {file = "numpy-1.20.2-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:97ce8b8ace7d3b9288d88177e66ee75480fb79b9cf745e91ecfe65d91a856042"}, - {file = "numpy-1.20.2.zip", hash = "sha256:878922bf5ad7550aa044aa9301d417e2d3ae50f0f577de92051d739ac6096cee"}, + {file = "numpy-1.20.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:70eb5808127284c4e5c9e836208e09d685a7978b6a216db85960b1a112eeace8"}, + {file = "numpy-1.20.3-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6ca2b85a5997dabc38301a22ee43c82adcb53ff660b89ee88dded6b33687e1d8"}, + {file = "numpy-1.20.3-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c5bf0e132acf7557fc9bb8ded8b53bbbbea8892f3c9a1738205878ca9434206a"}, + {file = "numpy-1.20.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db250fd3e90117e0312b611574cd1b3f78bec046783195075cbd7ba9c3d73f16"}, + {file = "numpy-1.20.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:637d827248f447e63585ca3f4a7d2dfaa882e094df6cfa177cc9cf9cd6cdf6d2"}, + {file = "numpy-1.20.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:8b7bb4b9280da3b2856cb1fc425932f46fba609819ee1c62256f61799e6a51d2"}, + {file = "numpy-1.20.3-cp37-cp37m-win32.whl", hash = "sha256:67d44acb72c31a97a3d5d33d103ab06d8ac20770e1c5ad81bdb3f0c086a56cf6"}, + {file = "numpy-1.20.3-cp37-cp37m-win_amd64.whl", hash = "sha256:43909c8bb289c382170e0282158a38cf306a8ad2ff6dfadc447e90f9961bef43"}, + {file = "numpy-1.20.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f1452578d0516283c87608a5a5548b0cdde15b99650efdfd85182102ef7a7c17"}, + {file = "numpy-1.20.3-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6e51534e78d14b4a009a062641f465cfaba4fdcb046c3ac0b1f61dd97c861b1b"}, + {file = "numpy-1.20.3-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e515c9a93aebe27166ec9593411c58494fa98e5fcc219e47260d9ab8a1cc7f9f"}, + {file = "numpy-1.20.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1c09247ccea742525bdb5f4b5ceeacb34f95731647fe55774aa36557dbb5fa4"}, + {file = "numpy-1.20.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:66fbc6fed94a13b9801fb70b96ff30605ab0a123e775a5e7a26938b717c5d71a"}, + {file = "numpy-1.20.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:ea9cff01e75a956dbee133fa8e5b68f2f92175233de2f88de3a682dd94deda65"}, + {file = "numpy-1.20.3-cp38-cp38-win32.whl", hash = "sha256:f39a995e47cb8649673cfa0579fbdd1cdd33ea497d1728a6cb194d6252268e48"}, + {file = "numpy-1.20.3-cp38-cp38-win_amd64.whl", hash = "sha256:1676b0a292dd3c99e49305a16d7a9f42a4ab60ec522eac0d3dd20cdf362ac010"}, + {file = "numpy-1.20.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:830b044f4e64a76ba71448fce6e604c0fc47a0e54d8f6467be23749ac2cbd2fb"}, + {file = "numpy-1.20.3-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:55b745fca0a5ab738647d0e4db099bd0a23279c32b31a783ad2ccea729e632df"}, + {file = "numpy-1.20.3-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5d050e1e4bc9ddb8656d7b4f414557720ddcca23a5b88dd7cff65e847864c400"}, + {file = "numpy-1.20.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9c65473ebc342715cb2d7926ff1e202c26376c0dcaaee85a1fd4b8d8c1d3b2f"}, + {file = "numpy-1.20.3-cp39-cp39-win32.whl", hash = "sha256:16f221035e8bd19b9dc9a57159e38d2dd060b48e93e1d843c49cb370b0f415fd"}, + {file = "numpy-1.20.3-cp39-cp39-win_amd64.whl", hash = "sha256:6690080810f77485667bfbff4f69d717c3be25e5b11bb2073e76bb3f578d99b4"}, + {file = "numpy-1.20.3-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e465afc3b96dbc80cf4a5273e5e2b1e3451286361b4af70ce1adb2984d392f9"}, + {file = "numpy-1.20.3.zip", hash = "sha256:e55185e51b18d788e49fe8305fd73ef4470596b33fc2c1ceb304566b99c71a69"}, ] packaging = [ {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, @@ -400,9 +513,13 @@ pillow = [ {file = "Pillow-8.2.0-pp37-pypy37_pp73-win32.whl", hash = "sha256:e98eca29a05913e82177b3ba3d198b1728e164869c613d76d0de4bde6768a50e"}, {file = "Pillow-8.2.0.tar.gz", hash = "sha256:a787ab10d7bb5494e5f76536ac460741788f1fbce851068d73a87ca7c35fc3e1"}, ] +pre-commit = [ + {file = "pre_commit-2.12.1-py2.py3-none-any.whl", hash = "sha256:70c5ec1f30406250b706eda35e868b87e3e4ba099af8787e3e8b4b01e84f4712"}, + {file = "pre_commit-2.12.1.tar.gz", hash = "sha256:900d3c7e1bf4cf0374bb2893c24c23304952181405b4d88c9c40b72bda1bb8a9"}, +] py-hplc = [ - {file = "py-hplc-0.1.7.tar.gz", hash = "sha256:2985921307788a3ba4bbc1b3410818a7e994efb7eeb4e38ecddd170fcacf9b6b"}, - {file = "py_hplc-0.1.7-py3-none-any.whl", hash = "sha256:b71090d6a37217cf3f24554d5755dedb9932be4818796e82402443d6440acc83"}, + {file = "py-hplc-1.0.2.tar.gz", hash = "sha256:a51770b34d9578e399e157752348e8fd70f26f6bd9cd5b64c55d4b7ca7171ef5"}, + {file = "py_hplc-1.0.2-py3-none-any.whl", hash = "sha256:5bc2b615df12462255087cdb372a7a4c0f17363fba532d09747afb9195157f44"}, ] pygments = [ {file = "Pygments-2.9.0-py3-none-any.whl", hash = "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e"}, @@ -424,6 +541,37 @@ pytz = [ {file = "pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"}, {file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"}, ] +pyyaml = [ + {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, + {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"}, + {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"}, + {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, + {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, + {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, + {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"}, + {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"}, + {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, + {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, + {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, + {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, + {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"}, + {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"}, + {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, + {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, + {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, + {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, + {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"}, + {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"}, + {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, + {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, + {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, + {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, + {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"}, + {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"}, + {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, + {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, + {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, +] readme-renderer = [ {file = "readme_renderer-29.0-py2.py3-none-any.whl", hash = "sha256:63b4075c6698fcfa78e584930f07f39e05d46f3ec97f65006e430b595ca6348c"}, {file = "readme_renderer-29.0.tar.gz", hash = "sha256:92fd5ac2bf8677f310f3303aa4bce5b9d5f9f2094ab98c29f13791d7b805a3db"}, @@ -432,18 +580,26 @@ rope = [ {file = "rope-0.18.0.tar.gz", hash = "sha256:786b5c38c530d4846aa68a42604f61b4e69a493390e3ca11b88df0fbfdc3ed04"}, ] six = [ - {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, - {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] tkcalendar = [ {file = "tkcalendar-1.6.1-py3-none-any.whl", hash = "sha256:9d3a80816a7b32d64fab696fa3d2a007fb23c87953267d5e343a38ff4cd7c15c"}, {file = "tkcalendar-1.6.1-py3.8.egg", hash = "sha256:c3ac34ab268734377ce73407893e8a5765e288aecbbb55136fb3ccea98006a96"}, {file = "tkcalendar-1.6.1.tar.gz", hash = "sha256:5edf958c0a59429e90309e9b805b2e229192bbcab952460247204d7030eea5cf"}, ] +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] tomlkit = [ {file = "tomlkit-0.7.0-py2.py3-none-any.whl", hash = "sha256:6babbd33b17d5c9691896b0e68159215a9387ebfa938aa3ac42f4a4beeb2b831"}, {file = "tomlkit-0.7.0.tar.gz", hash = "sha256:ac57f29693fab3e309ea789252fcce3061e19110085aa31af5446ca749325618"}, ] +virtualenv = [ + {file = "virtualenv-20.4.6-py2.py3-none-any.whl", hash = "sha256:307a555cf21e1550885c82120eccaf5acedf42978fd362d32ba8410f9593f543"}, + {file = "virtualenv-20.4.6.tar.gz", hash = "sha256:72cf267afc04bf9c86ec932329b7e94db6a0331ae9847576daaa7ca3c86b29a4"}, +] webencodings = [ {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, diff --git a/pyproject.toml b/pyproject.toml index 4926421..99f6089 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "scalewiz" -version = "0.5.6" +version = "0.5.7" description = "A graphical user interface for chemical performance testing designed to work with Teledyne SSI MX-class HPLC pumps." readme = "README.rst" license = "GPL-3.0-or-later" @@ -23,10 +23,10 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.9" -matplotlib = "^3.3.4" +matplotlib = "^3.4.2." tkcalendar = "^1.6.1" pandas = "^1.2.2" -py-hplc = "^0.1.6" +py-hplc = "^1.0.1" tomlkit = "^0.7.0" appdirs = "^1.4.4" @@ -36,6 +36,7 @@ scalewiz = "scalewiz.__main__:main" [tool.poetry.dev-dependencies] rope = "^0.18.0" readme-renderer = "^29.0" +pre-commit = "^2.12.1" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/requirements.txt b/requirements.txt index 0ebaa09..13c0ae5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -40,51 +40,51 @@ kiwisolver==1.3.1; python_version >= "3.7" \ --hash=sha256:33449715e0101e4d34f64990352bce4095c8bf13bed1b390773fc0a7295967b3 \ --hash=sha256:401a2e9afa8588589775fe34fc22d918ae839aaaf0c0e96441c0fdbce6d8ebe6 \ --hash=sha256:950a199911a8d94683a6b10321f9345d5a3a8433ec58b217ace979e18f16e248 -matplotlib==3.4.1; python_version >= "3.7" \ - --hash=sha256:7a54efd6fcad9cb3cd5ef2064b5a3eeb0b63c99f26c346bdcf66e7c98294d7cc \ - --hash=sha256:86dc94e44403fa0f2b1dd76c9794d66a34e821361962fe7c4e078746362e3b14 \ - --hash=sha256:574306171b84cd6854c83dc87bc353cacc0f60184149fb00c9ea871eca8c1ecb \ - --hash=sha256:84a10e462120aa7d9eb6186b50917ed5a6286ee61157bfc17c5b47987d1a9068 \ - --hash=sha256:81e6fe8b18ef5be67f40a1d4f07d5a4ed21d3878530193898449ddef7793952f \ - --hash=sha256:c45e7bf89ea33a2adaef34774df4e692c7436a18a48bcb0e47a53e698a39fa39 \ - --hash=sha256:1f83a32e4b6045191f9d34e4dc68c0a17c870b57ef9cca518e516da591246e79 \ - --hash=sha256:a18cc1ab4a35b845cf33b7880c979f5c609fd26c2d6e74ddfacb73dcc60dd956 \ - --hash=sha256:ac2a30a09984c2719f112a574b6543ccb82d020fd1b23b4d55bf4759ba8dd8f5 \ - --hash=sha256:a97781453ac79409ddf455fccf344860719d95142f9c334f2a8f3fff049ffec3 \ - --hash=sha256:2eee37340ca1b353e0a43a33da79d0cd4bcb087064a0c3c3d1329cdea8fbc6f3 \ - --hash=sha256:90dbc007f6389bcfd9ef4fe5d4c78c8d2efe4e0ebefd48b4f221cdfed5672be2 \ - --hash=sha256:7f16660edf9a8bcc0f766f51c9e1b9d2dc6ceff6bf636d2dbd8eb925d5832dfd \ - --hash=sha256:a989022f89cda417f82dbf65e0a830832afd8af743d05d1414fb49549287ff04 \ - --hash=sha256:be4430b33b25e127fc4ea239cc386389de420be4d63e71d5359c20b562951ce1 \ - --hash=sha256:7561fd541477d41f3aa09457c434dd1f7604f3bd26d7858d52018f5dfe1c06d1 \ - --hash=sha256:9f374961a3996c2d1b41ba3145462c3708a89759e604112073ed6c8bdf9f622f \ - --hash=sha256:53ceb12ef44f8982b45adc7a0889a7e2df1d758e8b360f460e435abe8a8cd658 \ - --hash=sha256:84d4c4f650f356678a5d658a43ca21a41fca13f9b8b00169c0b76e6a6a948908 -numpy==1.20.2; python_version >= "3.7" and python_full_version >= "3.7.1" \ - --hash=sha256:e9459f40244bb02b2f14f6af0cd0732791d72232bbb0dc4bab57ef88e75f6935 \ - --hash=sha256:a8e6859913ec8eeef3dbe9aed3bf475347642d1cdd6217c30f28dee8903528e6 \ - --hash=sha256:9cab23439eb1ebfed1aaec9cd42b7dc50fc96d5cd3147da348d9161f0501ada5 \ - --hash=sha256:9c0fab855ae790ca74b27e55240fe4f2a36a364a3f1ebcfd1fb5ac4088f1cec3 \ - --hash=sha256:61d5b4cf73622e4d0c6b83408a16631b670fc045afd6540679aa35591a17fe6d \ - --hash=sha256:d15007f857d6995db15195217afdbddfcd203dfaa0ba6878a2f580eaf810ecd6 \ - --hash=sha256:d76061ae5cab49b83a8cf3feacefc2053fac672728802ac137dd8c4123397677 \ - --hash=sha256:bad70051de2c50b1a6259a6df1daaafe8c480ca98132da98976d8591c412e737 \ - --hash=sha256:719656636c48be22c23641859ff2419b27b6bdf844b36a2447cb39caceb00935 \ - --hash=sha256:aa046527c04688af680217fffac61eec2350ef3f3d7320c07fd33f5c6e7b4d5f \ - --hash=sha256:2428b109306075d89d21135bdd6b785f132a1f5a3260c371cee1fae427e12727 \ - --hash=sha256:e8e4fbbb7e7634f263c5b0150a629342cc19b47c5eba8d1cd4363ab3455ab576 \ - --hash=sha256:edb1f041a9146dcf02cd7df7187db46ab524b9af2515f392f337c7cbbf5b52cd \ - --hash=sha256:c73a7975d77f15f7f68dacfb2bca3d3f479f158313642e8ea9058eea06637931 \ - --hash=sha256:6c915ee7dba1071554e70a3664a839fbc033e1d6528199d4621eeaaa5487ccd2 \ - --hash=sha256:471c0571d0895c68da309dacee4e95a0811d0a9f9f532a48dc1bea5f3b7ad2b7 \ - --hash=sha256:4703b9e937df83f5b6b7447ca5912b5f5f297aba45f91dbbbc63ff9278c7aa98 \ - --hash=sha256:abc81829c4039e7e4c30f7897938fa5d4916a09c2c7eb9b244b7a35ddc9656f4 \ - --hash=sha256:377751954da04d4a6950191b20539066b4e19e3b559d4695399c5e8e3e683bf6 \ - --hash=sha256:6e51e417d9ae2e7848314994e6fc3832c9d426abce9328cf7571eefceb43e6c9 \ - --hash=sha256:780ae5284cb770ade51d4b4a7dce4faa554eb1d88a56d0e8b9f35fca9b0270ff \ - --hash=sha256:924dc3f83de20437de95a73516f36e09918e9c9c18d5eac520062c49191025fb \ - --hash=sha256:97ce8b8ace7d3b9288d88177e66ee75480fb79b9cf745e91ecfe65d91a856042 \ - --hash=sha256:878922bf5ad7550aa044aa9301d417e2d3ae50f0f577de92051d739ac6096cee +matplotlib==3.4.2; python_version >= "3.7" \ + --hash=sha256:c541ee5a3287efe066bbe358320853cf4916bc14c00c38f8f3d8d75275a405a9 \ + --hash=sha256:3a5c18dbd2c7c366da26a4ad1462fe3e03a577b39e3b503bbcf482b9cdac093c \ + --hash=sha256:a9d8cb5329df13e0cdaa14b3b43f47b5e593ec637f13f14db75bb16e46178b05 \ + --hash=sha256:7ad19f3fb6145b9eb41c08e7cbb9f8e10b91291396bee21e9ce761bb78df63ec \ + --hash=sha256:7a58f3d8fe8fac3be522c79d921c9b86e090a59637cb88e3bc51298d7a2c862a \ + --hash=sha256:6382bc6e2d7e481bcd977eb131c31dee96e0fb4f9177d15ec6fb976d3b9ace1a \ + --hash=sha256:6a6a44f27aabe720ec4fd485061e8a35784c2b9ffa6363ad546316dfc9cea04e \ + --hash=sha256:1c1779f7ab7d8bdb7d4c605e6ffaa0614b3e80f1e3c8ccf7b9269a22dbc5986b \ + --hash=sha256:5826f56055b9b1c80fef82e326097e34dc4af8c7249226b7dd63095a686177d1 \ + --hash=sha256:0bea5ec5c28d49020e5d7923c2725b837e60bc8be99d3164af410eb4b4c827da \ + --hash=sha256:6475d0209024a77f869163ec3657c47fed35d9b6ed8bccba8aa0f0099fbbdaa8 \ + --hash=sha256:21b31057bbc5e75b08e70a43cefc4c0b2c2f1b1a850f4a0f7af044eb4163086c \ + --hash=sha256:b26535b9de85326e6958cdef720ecd10bcf74a3f4371bf9a7e5b2e659c17e153 \ + --hash=sha256:32fa638cc10886885d1ca3d409d4473d6a22f7ceecd11322150961a70fab66dd \ + --hash=sha256:956c8849b134b4a343598305a3ca1bdd3094f01f5efc8afccdebeffe6b315247 \ + --hash=sha256:85f191bb03cb1a7b04b5c2cca4792bef94df06ef473bc49e2818105671766fee \ + --hash=sha256:b1d5a2cedf5de05567c441b3a8c2651fbde56df08b82640e7f06c8cd91e201f6 \ + --hash=sha256:df815378a754a7edd4559f8c51fc7064f779a74013644a7f5ac7a0c31f875866 \ + --hash=sha256:d8d994cefdff9aaba45166eb3de4f5211adb4accac85cbf97137e98f26ea0219 +numpy==1.20.3; python_version >= "3.7" and python_full_version >= "3.7.1" \ + --hash=sha256:70eb5808127284c4e5c9e836208e09d685a7978b6a216db85960b1a112eeace8 \ + --hash=sha256:6ca2b85a5997dabc38301a22ee43c82adcb53ff660b89ee88dded6b33687e1d8 \ + --hash=sha256:c5bf0e132acf7557fc9bb8ded8b53bbbbea8892f3c9a1738205878ca9434206a \ + --hash=sha256:db250fd3e90117e0312b611574cd1b3f78bec046783195075cbd7ba9c3d73f16 \ + --hash=sha256:637d827248f447e63585ca3f4a7d2dfaa882e094df6cfa177cc9cf9cd6cdf6d2 \ + --hash=sha256:8b7bb4b9280da3b2856cb1fc425932f46fba609819ee1c62256f61799e6a51d2 \ + --hash=sha256:67d44acb72c31a97a3d5d33d103ab06d8ac20770e1c5ad81bdb3f0c086a56cf6 \ + --hash=sha256:43909c8bb289c382170e0282158a38cf306a8ad2ff6dfadc447e90f9961bef43 \ + --hash=sha256:f1452578d0516283c87608a5a5548b0cdde15b99650efdfd85182102ef7a7c17 \ + --hash=sha256:6e51534e78d14b4a009a062641f465cfaba4fdcb046c3ac0b1f61dd97c861b1b \ + --hash=sha256:e515c9a93aebe27166ec9593411c58494fa98e5fcc219e47260d9ab8a1cc7f9f \ + --hash=sha256:c1c09247ccea742525bdb5f4b5ceeacb34f95731647fe55774aa36557dbb5fa4 \ + --hash=sha256:66fbc6fed94a13b9801fb70b96ff30605ab0a123e775a5e7a26938b717c5d71a \ + --hash=sha256:ea9cff01e75a956dbee133fa8e5b68f2f92175233de2f88de3a682dd94deda65 \ + --hash=sha256:f39a995e47cb8649673cfa0579fbdd1cdd33ea497d1728a6cb194d6252268e48 \ + --hash=sha256:1676b0a292dd3c99e49305a16d7a9f42a4ab60ec522eac0d3dd20cdf362ac010 \ + --hash=sha256:830b044f4e64a76ba71448fce6e604c0fc47a0e54d8f6467be23749ac2cbd2fb \ + --hash=sha256:55b745fca0a5ab738647d0e4db099bd0a23279c32b31a783ad2ccea729e632df \ + --hash=sha256:5d050e1e4bc9ddb8656d7b4f414557720ddcca23a5b88dd7cff65e847864c400 \ + --hash=sha256:a9c65473ebc342715cb2d7926ff1e202c26376c0dcaaee85a1fd4b8d8c1d3b2f \ + --hash=sha256:16f221035e8bd19b9dc9a57159e38d2dd060b48e93e1d843c49cb370b0f415fd \ + --hash=sha256:6690080810f77485667bfbff4f69d717c3be25e5b11bb2073e76bb3f578d99b4 \ + --hash=sha256:4e465afc3b96dbc80cf4a5273e5e2b1e3451286361b4af70ce1adb2984d392f9 \ + --hash=sha256:e55185e51b18d788e49fe8305fd73ef4470596b33fc2c1ceb304566b99c71a69 pandas==1.2.4; python_full_version >= "3.7.1" \ --hash=sha256:c601c6fdebc729df4438ec1f62275d6136a0dd14d332fc0e8ce3f7d2aadb4dd6 \ --hash=sha256:8d4c74177c26aadcfb4fd1de6c1c43c2bf822b3e0fc7a9b409eeaf84b3e92aaa \ @@ -136,9 +136,9 @@ pillow==8.2.0; python_version >= "3.7" \ --hash=sha256:22fd0f42ad15dfdde6c581347eaa4adb9a6fc4b865f90b23378aa7914895e120 \ --hash=sha256:e98eca29a05913e82177b3ba3d198b1728e164869c613d76d0de4bde6768a50e \ --hash=sha256:a787ab10d7bb5494e5f76536ac460741788f1fbce851068d73a87ca7c35fc3e1 -py-hplc==0.1.7; python_version >= "3.9" and python_version < "4.0" \ - --hash=sha256:2985921307788a3ba4bbc1b3410818a7e994efb7eeb4e38ecddd170fcacf9b6b \ - --hash=sha256:b71090d6a37217cf3f24554d5755dedb9932be4818796e82402443d6440acc83 +py-hplc==1.0.2; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:a51770b34d9578e399e157752348e8fd70f26f6bd9cd5b64c55d4b7ca7171ef5 \ + --hash=sha256:5bc2b615df12462255087cdb372a7a4c0f17363fba532d09747afb9195157f44 pyparsing==2.4.7; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.7" \ --hash=sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b \ --hash=sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1 @@ -151,9 +151,9 @@ python-dateutil==2.8.1; python_full_version >= "3.7.1" and python_version >= "3. pytz==2021.1; python_full_version >= "3.7.1" \ --hash=sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798 \ --hash=sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da -six==1.15.0; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.7" \ - --hash=sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced \ - --hash=sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259 +six==1.16.0; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.7" \ + --hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 \ + --hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 tkcalendar==1.6.1 \ --hash=sha256:9d3a80816a7b32d64fab696fa3d2a007fb23c87953267d5e343a38ff4cd7c15c \ --hash=sha256:c3ac34ab268734377ce73407893e8a5765e288aecbbb55136fb3ccea98006a96 \ diff --git a/scalewiz/__init__.py b/scalewiz/__init__.py index 07940fa..e8cdd3e 100644 --- a/scalewiz/__init__.py +++ b/scalewiz/__init__.py @@ -1 +1,13 @@ """The parent module for the scalewiz package.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from tkinter import Tk + +from scalewiz.helpers.configuration import get_config + +ROOT: Tk = None +CONFIG: dict = get_config() diff --git a/scalewiz/__main__.py b/scalewiz/__main__.py index fef41db..32d0849 100644 --- a/scalewiz/__main__.py +++ b/scalewiz/__main__.py @@ -2,13 +2,15 @@ import tkinter as tk +import scalewiz from scalewiz.components.scalewiz import ScaleWiz def main() -> None: """The Tkinter entry point of the program; enters mainloop.""" root = tk.Tk() - ScaleWiz(root).grid() + scalewiz.ROOT = root + ScaleWiz(root).grid(sticky="nsew") root.mainloop() diff --git a/scalewiz/components/evaluation_data_view.py b/scalewiz/components/evaluation_data_view.py new file mode 100644 index 0000000..3db7d34 --- /dev/null +++ b/scalewiz/components/evaluation_data_view.py @@ -0,0 +1,209 @@ +"""A table view to be displayed in the Evaluation Window.""" + +from __future__ import annotations + +import tkinter as tk +from logging import Logger, getLogger +from tkinter import messagebox, ttk +from tkinter.font import Font +from typing import TYPE_CHECKING + +from scalewiz.components.evaluation_plot_view import EvaluationPlotView +from scalewiz.helpers.score import score + +if TYPE_CHECKING: + from typing import List + + from scalewiz.components.evaluation_window import EvaluationWindow + from scalewiz.models.project import Project + from scalewiz.models.test import Test + +LOGGER: Logger = getLogger("scalewiz") + + +class EvaluationDataView(ttk.Frame): + """A widget for selecting devices.""" + + def __init__(self, parent: ttk.Frame, project: Project) -> None: + super().__init__(parent) + self.eval_window: EvaluationWindow = parent.master + self.project = project + self.trials: List[Test] = [] + self.blanks: List[Test] = [] + self.bold_font: Font = Font(family="Arial", weight="bold", size=10) + self.build() + + def build(self) -> None: + """Build the UI.""" + for child in self.winfo_children(): + self.after(0, child.destroy) + self.sort_tests() + + self.apply_col_headers() # row 0 + # add blanks block + blanks_lbl = ttk.Label(self, text="Blanks:", font=self.bold_font) + blanks_lbl.grid(row=1, column=0, sticky="w") + for i, blank in enumerate(self.blanks): + self.apply_test_row(blank, i + 2) # skips rows for headers + # add trials block + len_blanks = len(self.blanks) + trials_lbl = ttk.Label(self, text="Trials:", font=self.bold_font) + trials_lbl.grid(row=len_blanks + 3, sticky="w") # skips rows for headers + for i, trial in enumerate(self.trials): + self.apply_test_row(trial, i + len_blanks + 4) # skips rows for headers + self.update_score() + + def apply_col_headers(self, row: int = 0) -> None: + """Insert header labels on the passed row.""" + labels = [] + labels.append( + tk.Label( + self, + text="Name", + font=self.bold_font, + anchor="w", + ) + ) + + labels.append( + tk.Label( + self, + text="Minutes", + font=self.bold_font, + anchor="center", + ) + ) + labels.append(tk.Label(self, text="Pump", font=self.bold_font, anchor="center")) + labels.append( + tk.Label(self, text="Baseline PSI", font=self.bold_font, anchor="center") + ) + labels.append( + tk.Label(self, text="Max PSI", font=self.bold_font, anchor="center") + ) + labels.append( + tk.Label(self, text="Water Clarity", font=self.bold_font, anchor="center") + ) + labels.append(tk.Label(self, text="Notes", font=self.bold_font, anchor="w")) + labels.append(tk.Label(self, text="Score", font=self.bold_font, anchor="w")) + labels.append(tk.Label(self, text="On Report", font=self.bold_font, anchor="w")) + # extra for del button row + labels.append(tk.Label(self, text=" ", font=self.bold_font, anchor="w")) + + for i, lbl in enumerate(labels): + self.grid_columnconfigure(i, weight=1) + if i in (0, 1, 7): + lbl.grid(row=row, column=i, padx=0, sticky="w") + else: + lbl.grid(row=row, column=i, padx=3, sticky="ew") + + def apply_test_row(self, test: Test, row: int) -> None: + """Creates a row for the test and grids it.""" + cols: List[tk.Widget] = [] + vcmd = self.register(self.update_score) + # col 0 - name + cols.append(ttk.Label(self, textvariable=test.name)) + + # col 2 - duration + duration = round( + len(test.readings) * self.project.interval_seconds.get() / 60, 2 + ) + cols.append( + ttk.Label( + self, + text=f"{duration:.2f}", + anchor="center", + ) + ) + # col 3 - pump to score + to_score = ttk.Combobox( + self, + textvariable=test.pump_to_score, + values=["pump 1", "pump 2", "average"], + state="readonly", + width=7, + validate="all", + validatecommand=vcmd, + ) + to_score.bind("", self.update_score) + cols.append(to_score) + # col 4 - obs baseline + cols.append( + ttk.Label( + self, textvariable=test.observed_baseline, anchor="center", width=5 + ) + ) + # col 5 - max psi + cols.append( + ttk.Label(self, textvariable=test.max_psi, anchor="center", width=7) + ) + # col 6 - clarity + cols.append( + ttk.Label( + self, + textvariable=test.clarity, + anchor="center", + ) + ) + # col 7 - notes + cols.append(ttk.Entry(self, textvariable=test.notes, width=25)) + # col 8 - result + cols.append(ttk.Label(self, textvariable=test.result, width=5, anchor="center")) + # col 9 - include on report + cols.append( + ttk.Checkbutton( + self, + variable=test.include_on_report, + command=self.update_score, + ) + ) + # col 10 - delete + cols.append( + ttk.Button( + self, + command=lambda: self.remove_from_project(test), # noqa: E731 + text="Delete", + width=7, + ) + ) + + for i, col in enumerate(cols): + if i == 0: # left align the name col + col.grid(row=row, column=i, padx=1, pady=1, sticky="w") + elif i == 7: # make the notes col stretch + col.grid(row=row, column=i, padx=1, pady=1, sticky="ew") + elif i == 10: # right align the delete buttons + col.grid(row=row, column=i, padx=(5, 0), pady=1, sticky="e") + else: + col.grid(row=row, column=i, padx=1, pady=1) + + def sort_tests(self) -> None: + """Sort through the project, populating the lists of blanks and trials.""" + self.blanks.clear() + self.trials.clear() + + for test in self.project.tests: + if test.is_blank.get(): + self.blanks.append(test) + else: + self.trials.append(test) + + def remove_from_project(self, test: Test) -> None: + """Removes a Test from the parent Project, then rebuilds the UI.""" + msg = ( + "You are about to delete {} from {}.\n" + "This will become permanent once you save the project.\n" + "Do you wish to continue?" + ).format(test.name.get(), self.project.name.get()) + remove = messagebox.askyesno("Delete test", msg) + if remove and test in self.project.tests: + self.project.tests.remove(test) + self.update_score() + self.build() + + def update_score(self, *args) -> True: + """Calls score from a validation callback. Doesn't check anything.""" + # prevents a race condition when setting the score + self.after(0, score(self.project, self.eval_window.log_text)) + if isinstance(self.eval_window.plot_view, EvaluationPlotView): + self.after(0, self.eval_window.plot_view.update_plot) + return True diff --git a/scalewiz/components/evaluation_plot_view.py b/scalewiz/components/evaluation_plot_view.py new file mode 100644 index 0000000..3c86ece --- /dev/null +++ b/scalewiz/components/evaluation_plot_view.py @@ -0,0 +1,127 @@ +"""A plot view to be displayed in the Evaluation Window.""" + +from __future__ import annotations + +import tkinter as tk +from logging import Logger, getLogger +from tkinter import Canvas, ttk +from tkinter.font import Font +from typing import TYPE_CHECKING + +import matplotlib.pyplot as plt +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +from matplotlib.figure import Figure, SubplotParams +from matplotlib.ticker import MultipleLocator + +if TYPE_CHECKING: + from matplotlib.axis import Axis + + from scalewiz.models.project import Project + +LOGGER: Logger = getLogger("scalewiz") + + +class EvaluationPlotView(ttk.Frame): + """A widget for selecting devices.""" + + def __init__(self, parent: ttk.Notebook, project: Project) -> None: + super().__init__(parent) + self.parent: ttk.Notebook = parent + self.project: Project = project + self.fig: Figure = None + self.canvas: Canvas = None + self.axis: Axis = None + self.plot_frame: ttk.Frame = None + self.build() + + def build(self) -> None: + """Builds the UI.""" + if not self.winfo_exists(): + return + + if isinstance(self.fig, Figure): + plt.close(self.fig) + + for child in self.winfo_children(): + if child.winfo_exists(): + child.destroy() + + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + self.grid_columnconfigure(1, weight=1) + + label_frame = ttk.Frame(self) + bold_font = Font(family="Arial", weight="bold", size=10) + label_lbl = tk.Label( + label_frame, text="Label", font=bold_font, width=20, anchor="center" + ) + label_lbl.grid(row=0, column=0, sticky="ew") + + vcmd = self.register(self.update_plot) + + tests_on_report = [] + for test in self.project.tests: + if test.include_on_report.get(): + tests_on_report.append(test) + + for i, test in enumerate(tests_on_report): + label_ent = ttk.Entry( + label_frame, + textvariable=test.label, + validate="focusout", + validatecommand=vcmd, + width=25, + ) + label_ent.grid(row=i + 1, column=0, sticky="ew", pady=2) + + label_frame.grid(row=0, column=1, sticky="nsew") + + self.plot_frame = ttk.Frame(self) + self.fig, self.axis = plt.subplots( + figsize=(7.5, 4), + dpi=100, + subplotpars=SubplotParams( + wspace=0, hspace=0, left=0.1, right=0.9, top=0.95 + ), + ) + self.fig.patch.set_facecolor("#FAFAFA") + self.canvas = FigureCanvasTkAgg(self.fig, master=self.plot_frame) + self.canvas.get_tk_widget().pack(fill="both", expand=True) + with plt.style.context("bmh"): + self.axis.grid(color="darkgrey", alpha=0.65, linestyle="-") + self.axis.set_facecolor("w") # white + + # plot blanks + for test in tests_on_report: + if test.is_blank.get(): + elapsed = [] + for reading in test.readings: + elapsed.append(reading.elapsedMin) + self.axis.plot( + elapsed, + test.get_readings(), + label=test.label.get(), + linestyle=("-."), + ) + # then plot trials + for test in tests_on_report: + if not test.is_blank.get(): + elapsed = [] + for reading in test.readings: + elapsed.append(reading.elapsedMin) + self.axis.plot(elapsed, test.get_readings(), label=test.label.get()) + + self.axis.set_xlabel("Time (min)") + self.axis.set_ylabel("Pressure (psi)") + self.axis.set_ylim(top=self.project.limit_psi.get()) + self.axis.yaxis.set_major_locator(MultipleLocator(100)) + self.axis.set_xlim((0, self.project.limit_minutes.get())) + self.axis.legend(loc="best") + self.axis.margins(0) + + self.plot_frame.grid(row=0, column=0, sticky="n") + + def update_plot(self) -> True: + """Rebuilds the plot.""" + self.after_idle(self.build) + return True diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index 90319b5..02ad3a0 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -2,347 +2,150 @@ from __future__ import annotations -import os -import time import tkinter as tk -import typing -from tkinter import font, ttk +from logging import getLogger +from pathlib import Path +from tkinter import messagebox, ttk +from tkinter.scrolledtext import ScrolledText +from typing import TYPE_CHECKING -import matplotlib as mpl import matplotlib.pyplot as plt -from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg -from matplotlib.ticker import MultipleLocator -from scalewiz.components.test_evaluation_row import TestResultRow -from scalewiz.helpers.export_csv import export_csv +from scalewiz.components.evaluation_data_view import EvaluationDataView +from scalewiz.components.evaluation_plot_view import EvaluationPlotView +from scalewiz.helpers.export import export +from scalewiz.helpers.score import score from scalewiz.helpers.set_icon import set_icon from scalewiz.models.project import Project -if typing.TYPE_CHECKING: +if TYPE_CHECKING: + from scalewiz.models.test_handler import TestHandler -COLORS = [ - "orange", - "blue", - "red", - "mediumseagreen", - "darkgoldenrod", - "indigo", - "mediumvioletred", - "darkcyan", - "maroon", - "darkslategrey", -] + +LOGGER = getLogger("scalewiz") class EvaluationWindow(tk.Toplevel): """Frame for analyzing data.""" def __init__(self, handler: TestHandler) -> None: - tk.Toplevel.__init__(self) + super().__init__() self.handler = handler self.editor_project = Project() - if os.path.isfile(self.handler.project.path.get()): + if Path(self.handler.project.path.get()).is_file(): self.editor_project.load_json(self.handler.project.path.get()) # matplotlib uses these later - self.fig, self.axis, self.canvas = None, None, None - self.plot_frame = tk.Frame(self) # this gets destroyed in plot() + self.log_text: ScrolledText = None + self.plot_view: EvaluationPlotView = None # this gets destroyed in plot() + self.title(f"{self.handler.name} {self.handler.project.name.get()}") + self.resizable(0, 0) + set_icon(self) self.build() - def render(self, label: tk.Widget, entry: tk.Widget, row: int) -> None: - """Renders a given label and entry on the passed row.""" - # pylint: disable=no-self-use - label.grid(row=row, column=0, sticky="e") - entry.grid(row=row, column=1, sticky="new", padx=(5, 550), pady=2) - def build(self, reload: bool = False) -> None: """Destroys all child widgets, then builds the UI.""" - if reload and os.path.isfile(self.handler.project.path.get()): + if not self.winfo_exists(): + return + + if reload and Path(self.handler.project.path.get()).is_file(): # cleanup for the GC - for test in self.editor_project.tests: - test.remove_traces() self.editor_project.remove_traces() self.editor_project = Project() self.editor_project.load_json(self.handler.project.path.get()) - self.winfo_toplevel().title( - f"{self.handler.name} {self.handler.project.name.get()}" - ) - set_icon(self) - for child in self.winfo_children(): - child.destroy() + if child.winfo_exists(): + self.after(0, child.destroy) + self.grid_columnconfigure(0, weight=1) + # we will build a few tabs in this self.tab_control = ttk.Notebook(self) self.tab_control.grid(row=0, column=0) - tests_frame = ttk.Frame(self) - - bold_font = font.Font(family="Arial", weight="bold", size=10) - # header row - labels = [] - labels.append(tk.Label(tests_frame, text="Name", font=bold_font)) - labels.append(tk.Label(tests_frame, text="Label", font=bold_font)) - labels.append(tk.Label(tests_frame, text="Minutes", font=bold_font)) - labels.append(tk.Label(tests_frame, text="Pump", font=bold_font)) - labels.append(tk.Label(tests_frame, text="Baseline", font=bold_font)) - labels.append(tk.Label(tests_frame, text="Max", font=bold_font)) - labels.append(tk.Label(tests_frame, text="Clarity", font=bold_font)) - labels.append(tk.Label(tests_frame, text="Notes", font=bold_font)) - labels.append(tk.Label(tests_frame, text="Result", font=bold_font)) - labels.append(tk.Label(tests_frame, text="Report", font=bold_font)) - for i, label in enumerate(labels): - label.grid(row=0, column=i, padx=3, sticky="w") - - self.grid_columnconfigure(0, weight=1) - - self.blanks = [] - for test in self.editor_project.tests: - if test.is_blank.get(): - self.blanks.append(test) - - # select the trials - self.trials = [] - for test in self.editor_project.tests: - if not test.is_blank.get(): - self.trials.append(test) - - tk.Label(tests_frame, text="Blanks:", font=bold_font).grid( - row=1, column=0, sticky="w", padx=3, pady=1 - ) - tk.Label(tests_frame, text="Trials:", font=bold_font).grid( - row=2 + len(self.blanks), column=0, sticky="w", padx=3, pady=1 - ) - - for i, blank in enumerate(self.blanks): - TestResultRow(tests_frame, blank, self.editor_project, i + 2).grid( - row=i + 1, column=0, sticky="w", padx=3, pady=1 - ) - count = len(self.blanks) - for i, trial in enumerate(self.trials): - TestResultRow(tests_frame, trial, self.editor_project, i + count + 3).grid( - row=i + count + 3, column=0, sticky="w", padx=3, pady=1 - ) - - self.tab_control.add(tests_frame, text=" Data ") + data_view = EvaluationDataView(self.tab_control, self.editor_project) + self.tab_control.add(data_view, text=" Data ") # plot stuff ---------------------------------------------------------- - self.plot() # evaluation stuff ---------------------------------------------------- - log_frame = ttk.Frame(self) - log_frame.grid_columnconfigure(0, weight=1) - self.log_text = tk.scrolledtext.ScrolledText( - log_frame, background="white", state="disabled" + self.log_frame = ttk.Frame(self.tab_control) + self.log_frame.grid_columnconfigure(0, weight=1) + self.log_frame.grid_rowconfigure(0, weight=1) + self.log_text = ScrolledText( + self.log_frame, background="white", state="disabled" ) - self.log_text.grid(sticky="ew") - self.tab_control.add(log_frame, text=" Calculations ") + self.log_text.grid(sticky="nsew") + self.tab_control.add(self.log_frame, text=" Calculations ") + self.plot() + # finished adding to tab control button_frame = ttk.Frame(self) - ttk.Button(button_frame, text="Save", command=self.save, width=10).grid( - row=0, column=0, padx=5 + if self.handler.is_running: + state = "disabled" + else: + state = "normal" + save_btn = ttk.Button( + button_frame, text="Save", command=self.save, width=10, state=state ) - ttk.Button( + save_btn.grid(row=0, column=0, padx=5) + export_btn = ttk.Button( button_frame, text="Export", - command=lambda: export_csv(self.editor_project), + command=lambda: export(self.editor_project), width=10, - ).grid(row=0, column=1, padx=5) + ) + export_btn.grid(row=0, column=1, padx=5) button_frame.grid(row=1, column=0, pady=5) # update results - self.score() + score(self.editor_project, self.log_text) def plot(self) -> None: """Destroys the old plot frame if it exists, then makes a new one.""" - # close all pyplots to prevent memory leak - plt.close("all") - # get rid of our old plot tab - self.plot_frame.destroy() - self.plot_frame = ttk.Frame(self) - self.fig, self.axis = plt.subplots(figsize=(7.5, 4), dpi=100) - self.fig.patch.set_facecolor("#FAFAFA") - plt.subplots_adjust(wspace=0, hspace=0) - self.canvas = FigureCanvasTkAgg(self.fig, master=self.plot_frame) - self.canvas.get_tk_widget().pack(fill="both", expand=True) - with plt.style.context("bmh"): - mpl.rcParams["axes.prop_cycle"] = mpl.cycler(color=COLORS) - self.axis.grid(color="darkgrey", alpha=0.65, linestyle="-") - self.axis.set_facecolor("w") - self.axis.clear() - - # plot everything - for blank in self.blanks: - if blank.include_on_report.get(): - elapsed = [] - for reading in blank.readings: - elapsed.append(reading["elapsedMin"]) - self.axis.plot( - elapsed, - blank.get_readings(), - label=blank.label.get(), - linestyle=("-."), - ) + if isinstance(self.plot_view, EvaluationPlotView): + plt.close(self.plot_view.fig) + self.plot_view.destroy() - for trial in self.trials: - if trial.include_on_report.get(): - elapsed = [] - for i, reading in enumerate(trial.readings): - elapsed.append(trial.readings[i]["elapsedMin"]) - self.axis.plot( - elapsed, trial.get_readings(), label=trial.label.get() - ) - - self.axis.set_xlabel("Time (min)") - self.axis.set_ylabel("Pressure (psi)") - self.axis.set_ylim(top=self.editor_project.limit_psi.get()) - self.axis.yaxis.set_major_locator(MultipleLocator(100)) - self.axis.set_xlim((0, self.editor_project.limit_minutes.get())) - self.axis.legend(loc=0) - self.axis.margins(0) - plt.tight_layout() - - # finally, add to parent control - self.tab_control.add(self.plot_frame, text=" Plot ") - self.tab_control.insert(1, self.plot_frame) + self.plot_view = EvaluationPlotView(self.tab_control, self.editor_project) + self.tab_control.add(self.plot_view, text=" Plot ") def save(self) -> None: """Saves to file the project, most recent plot, and calculations log.""" # update image - output_path = ( + plot_output = ( f"{self.editor_project.numbers.get().replace(' ', '')} " f"{self.editor_project.name.get()} " "Scale Block Analysis (Graph).png" ) - output_path = os.path.join( - os.path.dirname(self.editor_project.path.get()), output_path.strip() - ) - self.fig.savefig(output_path) - self.editor_project.plot.set( - output_path - ) # store this path so we can find it later + parent_dir = Path(self.editor_project.path.get()).parent + plot_output = Path(parent_dir, plot_output).resolve() + self.plot_view.fig.savefig(plot_output) + self.editor_project.plot.set(str(plot_output)) # update log - output_path = ( + log_output = ( f"{self.editor_project.numbers.get().replace(' ', '')} " f"{self.editor_project.name.get()} " "Scale Block Analysis (Log).txt" - ) - output_path = os.path.join( - os.path.dirname(self.editor_project.path.get()), output_path.strip() - ) - with open(output_path, "w") as file: + ).strip() + log_output = Path(parent_dir, log_output).resolve() + + with log_output.open("w") as file: file.write(self.log_text.get("1.0", "end-1c")) + # refresh self.editor_project.dump_json() - - self.build(reload=True) - - def score(self, *args) -> None: - """Updates the result for every Test in the Project. - - Accepts event args passed from the tkVar trace. - """ - # extra unused args are passed in by tkinter - start_time = time.time() - log = [] - # scoring props - limit_minutes = self.editor_project.limit_minutes.get() - interval_seconds = self.editor_project.interval_seconds.get() - max_readings = round(limit_minutes * 60 / interval_seconds) - log.append("Max readings: limitMin * 60 / reading interval") - log.append(f"Max readings: {max_readings}") - baseline_area = round(self.editor_project.baseline.get() * max_readings) - log.append("Baseline area: baseline PSI * max readings") - log.append( - f"Baseline area: {self.editor_project.baseline.get()} * {max_readings}" - ) - log.append(f"Baseline area: {baseline_area}") - log.append("-" * 80) - log.append("") - - # select the blanks - blanks = [] - for test in self.editor_project.tests: - if test.is_blank.get() and test.include_on_report.get(): - blanks.append(test) - - areas_over_blanks = [] - for blank in blanks: - log.append(f"Evaluating {blank.name.get()}") - log.append(f"Considering data: {blank.pump_to_score.get()}") - readings = blank.get_readings() - log.append(f"Total readings: {len(readings)}") - log.append(f"Observed baseline: {blank.observed_baseline.get()} psi") - int_psi = sum(readings) - log.append("Integral PSI: sum of all pressure readings") - log.append(f"Integral PSI: {int_psi}") - area = self.editor_project.limit_psi.get() * len(readings) - int_psi - log.append("Area over blank: limit_psi * # of readings - integral PSI") - log.append( - f"Area over blank: {self.editor_project.limit_psi.get()} " - f"* {len(readings)} - {int_psi}" - ) - log.append(f"Area over blank: {area}") - log.append("") - areas_over_blanks.append(area) - - if len(areas_over_blanks) == 0: - return - # get protectable area - avg_blank_area = round(sum(areas_over_blanks) / len(areas_over_blanks)) - log.append(f"Avg. area over blanks: {avg_blank_area}") - avg_protectable_area = ( - self.editor_project.limit_psi.get() * max_readings - avg_blank_area - ) - log.append( - "Avg. protectable area: limit_psi * max_readings - avg. area over blanks" - ) - log.append( - f"Avg. protectable area: {self.editor_project.limit_psi.get()} " - f"* {max_readings} - {avg_blank_area}" - ) - log.append(f"Avg. protectable area: {avg_protectable_area}") - log.append("-" * 80) - log.append("") - - # select trials - trials = [] - for test in self.editor_project.tests: - if not test.is_blank.get(): - trials.append(test) - - # get readings - for trial in trials: - log.append(f"Evaluating {trial.name.get()}") - log.append(f"Considering data: {trial.pump_to_score.get()}") - readings = trial.get_readings() - log.append(f"Total readings: {len(readings)}") - log.append(f"Observed baseline: {trial.observed_baseline.get()} psi") - int_psi = sum(readings) + ( - (max_readings - len(readings)) * self.editor_project.limit_psi.get() + self.handler.load_project(self.editor_project.path.get()) + self.after(0, self.handler.rebuild_views) + + def export(self) -> None: + result, file = export(self.editor_project) + if result == 0: + messagebox.showinfo("Export complete", f"Exported a report to {file}") + else: + messagebox.showwarning( + "Export failed", + ( + f"Failed to export the report to {file}. \n" + "Check the log for a more detailed message." + ), ) - log.append("Integral PSI: sum of all pressure readings") - log.append(f"Integral PSI: {int_psi}") - result = round(1 - (int_psi - baseline_area) / avg_protectable_area, 3) - log.append( - "Result: 1 - (integral PSI - baseline area) / avg protectable area" - ) - log.append( - f"Result: 1 - ({int_psi} - {baseline_area}) / {avg_protectable_area}" - ) - log.append(f"Result: {result} \n") - trial.result.set(result) - - self.plot() - - log.insert(0, f"Evaluating results for {self.editor_project.name.get()}...") - log.insert(1, f"Finished in {round(time.time() - start_time, 3)} s \n") - self.to_log(log) - - def to_log(self, log: list[str]) -> None: - """Adds the passed log message to the Text widget in the Calculations frame.""" - self.log_text.configure(state="normal") - self.log_text.delete(1.0, "end") - for i in log: - self.log_text.insert("end", i) - self.log_text.insert("end", "\n") - self.log_text.configure(state="disabled") diff --git a/scalewiz/components/handler_view.py b/scalewiz/components/handler_view.py new file mode 100644 index 0000000..b398d8d --- /dev/null +++ b/scalewiz/components/handler_view.py @@ -0,0 +1,75 @@ +"""A Tkinter widget for handling tests.""" + +from __future__ import annotations + +from logging import getLogger +from tkinter import ttk +from typing import TYPE_CHECKING + +from matplotlib import pyplot as plt + +from scalewiz.components.handler_view_controls import TestControls +from scalewiz.components.handler_view_devices_entry import DeviceBoxes +from scalewiz.components.handler_view_info_entry import TestInfoEntry +from scalewiz.components.handler_view_plot import LivePlot + +if TYPE_CHECKING: + + from scalewiz.models.test_handler import TestHandler + +LOGGER = getLogger("scalewiz") + + +class TestHandlerView(ttk.Frame): + """A form for setting up / running Tests.""" + + def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: + super().__init__(parent) + self.parent: ttk.Frame = parent + self.handler: TestHandler = handler + self.handler.views.append(self) + self.plot: LivePlot = None + self.build() + + def build(self, reload: bool = False) -> None: + """Builds the UI, destroying any currently existing widgets.""" + if isinstance(self.plot, LivePlot): # explicitly close to prevent memory leak + self.after(0, plt.close, self.plot.fig) + for child in self.winfo_children(): + self.after(0, child.destroy) + self.grid_columnconfigure(0, weight=1) + # row 0 ------------------------------------------------------------------------ + dev_ent = DeviceBoxes(self, self.handler) + dev_ent.grid(row=0, column=0, sticky="new") + + # row 1 ------------------------------------------------------------------------ + frm = ttk.Frame(self) + frm.grid_columnconfigure(1, weight=1) + lbl = ttk.Label(frm, text=" Project:") + lbl.grid(row=0, column=0, sticky="nw") + proj = ttk.Label(frm, textvariable=self.handler.project.name, anchor="center") + proj.grid(row=0, column=1, sticky="ew") + frm.grid(row=1, column=0, sticky="new") + + # row 2 ------------------------------------------------------------------------ + test_info = TestInfoEntry(self, self.handler) + test_info.grid(row=2, column=0, sticky="new") + + # row 3------------------------------------------------------------------------- + test_controls = TestControls(self, self.handler) + test_controls.grid(row=3, column=0, sticky="nsew") + + # row 0 col 1 ------------------------------------------------------------------ + plt_frm = ttk.Frame(self) + self.plot = LivePlot(plt_frm, self.handler) + self.plot.grid(row=0, column=0, sticky="nsew") + plt_frm.grid(row=0, column=1, rowspan=4) + + def update_input_frame(self) -> None: + """Disables widgets in the input frame if a Test is running.""" + if self.handler.is_running: + for widget in self.inputs: + widget.configure(state="disabled") + else: + for widget in self.inputs: + widget.configure(state="normal") diff --git a/scalewiz/components/handler_view_controls.py b/scalewiz/components/handler_view_controls.py new file mode 100644 index 0000000..102060e --- /dev/null +++ b/scalewiz/components/handler_view_controls.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import tkinter as tk +from logging import Logger, getLogger +from queue import Empty +from tkinter import ttk +from tkinter.scrolledtext import ScrolledText +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + + from scalewiz.models.test_handler import TestHandler + + +LOGGER: Logger = getLogger("scalewiz") + + +class TestControls(ttk.Frame): + """A widget for selecting devices.""" + + def __init__(self, parent: tk.Widget, handler: TestHandler) -> None: + super().__init__(parent) + self.handler: TestHandler = handler + self.interval: int = round(handler.project.interval_seconds.get() * 1000) + self.build() + + def build(self) -> None: + self.grid_columnconfigure(0, weight=1) + self.grid_columnconfigure(1, weight=1) + # row 0 col 0 + start_btn = ttk.Button(self) + if self.handler.is_done and not self.handler.is_running: + start_btn.configure(text="New", command=self.handler.new_test) + elif self.handler.is_running and not self.handler.is_done: + start_btn.configure( + text="New", command=self.handler.new_test, state="disabled" + ) + else: + start_btn.configure(text="Start", command=self.handler.start_test) + start_btn.grid(row=0, column=0, sticky="ew") + # row 0 col 1 + if self.handler.is_running: + state = "normal" + else: + state = "disabled" + stop_btn = ttk.Button( + self, text="Stop", command=self.handler.request_stop, state=state + ) + stop_btn.grid(row=0, column=1, sticky="ew") + progressbar = ttk.Progressbar(self, variable=self.handler.progress, maximum=100) + progressbar.grid(row=1, column=0, columnspan=2, sticky="ew") + # row 1 col 0:1 + self.log_text = ScrolledText( + self, background="white", height=5, width=44, state="disabled" + ) + self.log_text.grid(row=2, column=0, columnspan=2, sticky="ew") + # enter polling loop + self.after(0, self.poll_log_queue) + + def poll_log_queue(self) -> None: + """Checks on an interval if there is a new message in the queue to display.""" + while True: + try: + record = self.handler.log_queue.get(block=False) + except Empty: + break + else: + self.display(record) + self.after(self.interval, self.poll_log_queue) + + def display(self, msg: str) -> None: + """Displays a message in the log.""" + self.log_text.configure(state="normal") + self.log_text.insert("end", "".join((msg, "\n"))) + self.log_text.configure(state="disabled") + self.log_text.yview("end") # scroll to bottom diff --git a/scalewiz/components/handler_view_devices_entry.py b/scalewiz/components/handler_view_devices_entry.py new file mode 100644 index 0000000..cc51ff9 --- /dev/null +++ b/scalewiz/components/handler_view_devices_entry.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import tkinter as tk +from logging import Logger, getLogger +from tkinter import ttk +from typing import TYPE_CHECKING + +from serial.tools import list_ports + +if TYPE_CHECKING: + from typing import List + + from scalewiz.models.test_handler import TestHandler + +LOGGER: Logger = getLogger("scalewiz") + + +class DeviceBoxes(ttk.Frame): + """A widget for selecting devices.""" + + def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: + super().__init__(parent) + self.parent: ttk.Frame = parent + self.handler: TestHandler = handler + self.devices_list: List[str] = [] + self.dev1: tk.StringVar = handler.dev1 + self.dev2: tk.StringVar = handler.dev2 + self.build() + + def build(self) -> None: + """Builds the widget.""" + # let the widgets grow to fill + self.grid_columnconfigure(0, weight=1) + self.grid_columnconfigure(1, weight=1) + # make the widgets + label = ttk.Label(self, text=" Devices:", anchor="e") + if self.handler.is_running and not self.handler.is_done: + state = "disabled" + else: + state = "normal" + self.device1_entry = ttk.Combobox( + self, + width=15, + textvariable=self.dev1, + values=self.devices_list, + validate="all", + validatecommand=self.update_devices_list, + state=state, + ) + self.device2_entry = ttk.Combobox( + self, + width=15, + textvariable=self.dev2, + values=self.devices_list, + validate="all", + validatecommand=self.update_devices_list, + state=state, + ) + # grid the widgets + label.grid(row=0, column=0, sticky="ne") + self.device1_entry.grid(row=0, column=1, sticky="w") + self.device2_entry.grid(row=0, column=2, sticky="e") + # refresh + self.update_devices_list() + + def update_devices_list(self, *args) -> None: + """Updates the devices list.""" + # extra unused args are passed in by tkinter + self.devices_list = sorted([i.device for i in list_ports.comports()]) + if len(self.devices_list) < 1: + self.devices_list = ["None found"] + + self.device1_entry.configure(values=self.devices_list) + self.device2_entry.configure(values=self.devices_list) + + if "None found" not in self.devices_list: + LOGGER.debug( + "%s found devices: %s", self.parent.handler.name, self.devices_list + ) diff --git a/scalewiz/components/handler_view_info_entry.py b/scalewiz/components/handler_view_info_entry.py new file mode 100644 index 0000000..d13a74d --- /dev/null +++ b/scalewiz/components/handler_view_info_entry.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import tkinter as tk +from logging import Logger, getLogger +from tkinter import ttk +from typing import TYPE_CHECKING + +from scalewiz.helpers.validation import can_be_pos_float + +if TYPE_CHECKING: + + from scalewiz.models.test_handler import TestHandler + + +LOGGER: Logger = getLogger("scalewiz") + + +class TestInfoEntry(ttk.Frame): + """A widget for inputting Test information.""" + + def __init__(self, parent: tk.Widget, handler: TestHandler) -> None: + super().__init__(parent) + self.handler: TestHandler = handler + self.build() + + def build(self) -> None: + """Builds the widget.""" + self.grid_columnconfigure(1, weight=1) + + if self.handler.is_done or self.handler.is_running: + state = "disabled" + else: + state = "normal" + + radio_lbl = ttk.Label(self, text=" Test Type:", anchor="e") + radio_lbl.grid(row=0, column=0, sticky="ew") + + radio_frm = ttk.Frame(self) + radio_frm.grid_columnconfigure(0, weight=1) + radio_frm.grid_columnconfigure(1, weight=1) + blank_btn = ttk.Radiobutton( + radio_frm, + text="Blank", + variable=self.handler.test.is_blank, + value=True, + command=self.build, + state=state, + ) + blank_btn.grid(row=0, column=0, sticky="e", padx=25) + trial_btn = ttk.Radiobutton( + radio_frm, + text="Trial", + variable=self.handler.test.is_blank, + value=False, + command=self.build, + state=state, + ) + trial_btn.grid(row=0, column=1, sticky="w", padx=25) + radio_frm.grid(row=0, column=1, sticky="ew") + + test_frm = ttk.Frame(self) + test_frm.grid_columnconfigure(1, weight=1) + + if self.handler.test.is_blank.get(): + # test_frm row 0 ----------------------------------------------------------- + name_lbl = ttk.Label(test_frm, text=" Name:", anchor="e") + name_lbl.grid(row=0, column=0, sticky="ew") + name_ent = ttk.Entry( + test_frm, textvariable=self.handler.test.name, state=state + ) + name_ent.grid(row=0, column=1, sticky="ew") + # test_frm row 1 ----------------------------------------------------------- + notes_lbl = ttk.Label(test_frm, text="Notes:", anchor="e") + notes_lbl.grid(row=1, column=0, sticky="ew") + if self.handler.is_running or not self.handler.is_done: + state = "normal" + elif self.handler.is_done: + state = "disabled" + notes_ent = ttk.Entry( + test_frm, textvariable=self.handler.test.notes, state=state + ) + notes_ent.grid(row=1, column=1, sticky="ew") + # spacers + ttk.Label(test_frm, text="").grid(row=2) + ttk.Label(test_frm, text="").grid(row=3, pady=1) + + else: + # test_frm row 0 ----------------------------------------------------------- + chem_lbl = ttk.Label(test_frm, text="Chemical:", anchor="e") + chem_lbl.grid(row=0, column=0, sticky="e") + chem_ent = ttk.Entry( + test_frm, textvariable=self.handler.test.chemical, state=state + ) + chem_ent.grid(row=0, column=1, sticky="ew") + # test_frm row 1 ----------------------------------------------------------- + rate_lbl = ttk.Label(test_frm, text="Rate (ppm):", anchor="e") + rate_lbl.grid(row=1, column=0, sticky="e") + # validation command to ensure numeric inputs + vcmd = self.register(lambda s: can_be_pos_float(s)) + rate_ent = ttk.Spinbox( + test_frm, + textvariable=self.handler.test.rate, + from_=1, + to=999999, + validate="key", + validatecommand=(vcmd, "%P"), + state=state, + ) + rate_ent.grid(row=1, column=1, sticky="ew") + # test_frm row 2 ----------------------------------------------------------- + clarity_lbl = ttk.Label(test_frm, text="Clarity:", anchor="e") + clarity_lbl.grid(row=2, column=0, sticky="e") + clarity_ent = ttk.Combobox( + test_frm, + values=["Clear", "Slightly hazy", "Hazy"], + textvariable=self.handler.test.clarity, + state=state, + ) + clarity_ent.current(0) # default to 'Clear' + clarity_ent.grid(row=2, column=1, sticky="ew") + # test_frm row 3 ----------------------------------------------------------- + notes_lbl = ttk.Label(test_frm, text="Notes:", anchor="e") + notes_lbl.grid(row=3, column=0, sticky="e") + if self.handler.is_done: + state = "disable" + else: + state = "normal" + notes_ent = ttk.Entry( + test_frm, textvariable=self.handler.test.notes, state=state + ) + notes_ent.grid(row=3, column=1, sticky="ew") + + test_frm.grid(row=1, column=0, columnspan=2, sticky="ew") diff --git a/scalewiz/components/handler_view_plot.py b/scalewiz/components/handler_view_plot.py new file mode 100644 index 0000000..e7d1a2d --- /dev/null +++ b/scalewiz/components/handler_view_plot.py @@ -0,0 +1,78 @@ +"""Renders data from a TestHandler as it is collected.""" + +from __future__ import annotations + +import logging +from tkinter import ttk +from typing import TYPE_CHECKING + +import matplotlib.pyplot as plt +from matplotlib.animation import FuncAnimation +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +from matplotlib.figure import SubplotParams + +if TYPE_CHECKING: + from scalewiz.models.test_handler import TestHandler + +LOGGER = logging.getLogger("scalewiz") + + +class LivePlot(ttk.Frame): + """Renders data from a TestHandler as it is collected.""" + + def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: + """Initialize a LivePlot.""" + super().__init__(parent) + self.handler = handler + self.build() + + def build(self) -> None: + if not self.winfo_exists(): + return + + self.fig, self.axis = plt.subplots( + figsize=(5, 3), + dpi=100, + constrained_layout=True, + subplotpars=SubplotParams(left=0.1, bottom=0.1, right=0.95, top=0.95), + ) + self.axis.set_xlabel("Time (min)") + self.axis.set_ylabel("Pressure (psi)") + self.axis.grid(color="darkgrey", alpha=0.65, linestyle="-") + self.axis.set_facecolor("w") # white + self.fig.patch.set_facecolor("#FAFAFA") + self.canvas = FigureCanvasTkAgg(self.fig, master=self) + self.canvas.get_tk_widget().pack(side="top", fill="both", expand=True) + interval = round(self.handler.project.interval_seconds.get() * 1000) # -> ms + self.ani = FuncAnimation(self.fig, self.animate, interval=interval) + + # could probably rewrite this with some tk.Widget.after calls + def animate(self, interval: float) -> None: + """Animates the live plot if a test isn't running. + + The interval argument is used by matplotlib internally. + """ + # # we can just skip this if the test isn't running + if len(self.handler.readings) > 0: + if self.handler.is_running and not self.handler.is_done: + pump1 = [] + pump2 = [] + elapsed = [] # we will share this series as an axis + # cast to tuple in case the list changes during iteration + readings = tuple(self.handler.readings) + for reading in readings: + pump1.append(reading.pump1) + pump2.append(reading.pump2) + elapsed.append(reading.elapsedMin) + max_psi = max((self.handler.max_psi_1, self.handler.max_psi_2)) + self.axis.clear() + with plt.style.context("bmh"): + self.axis.grid(color="darkgrey", alpha=0.65, linestyle="-") + self.axis.set_facecolor("w") # white + self.axis.set_xlabel("Time (min)") + self.axis.set_ylabel("Pressure (psi)") + self.axis.set_ylim((0, max_psi + 50)) + self.axis.margins(0, tight=True) + self.axis.plot(elapsed, pump1, label="Pump 1") + self.axis.plot(elapsed, pump2, label="Pump 2") + self.axis.legend(loc="best") diff --git a/scalewiz/components/live_plot.py b/scalewiz/components/live_plot.py deleted file mode 100644 index 81dcd31..0000000 --- a/scalewiz/components/live_plot.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Renders data from a TestHandler as it is collected.""" - -from __future__ import annotations - -import logging -import time -import typing -from tkinter import ttk - -import matplotlib.pyplot as plt -from matplotlib.animation import FuncAnimation -from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg - -# from matplotlib.ticker import MultipleLocator - -if typing.TYPE_CHECKING: - from scalewiz.models.test_handler import TestHandler - -LOGGER = logging.getLogger("scalewiz") - - -class LivePlot(ttk.Frame): - """Renders data from a TestHandler as it is collected.""" - - def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: - """Initialize a LivePlot.""" - ttk.Frame.__init__(self, parent) - self.handler = handler - - # matplotlib objects - fig, self.axis = plt.subplots(figsize=(5, 3), dpi=100) - fig.patch.set_facecolor("#FAFAFA") - self.axis.grid(color="darkgrey", alpha=0.65, linestyle="-") - self.axis.set_facecolor("w") - # self.axis.set_ylim(top=self.handler.project.limit_psi.get()) - # self.axis.yaxis.set_major_locator(MultipleLocator(100)) - # self.axis.set_xlim((0, None), auto=True) - self.axis.margins(0) - plt.tight_layout() - plt.subplots_adjust(left=0.15, bottom=0.15, right=0.97, top=0.95) - self.canvas = FigureCanvasTkAgg(fig, master=self) - self.canvas.get_tk_widget().pack(side="top", fill="both", expand=True) - interval = handler.project.interval_seconds.get() * 1000 # ms - self.ani = FuncAnimation(fig, self.animate, interval=interval) - - def animate(self, interval: float) -> None: - """Animates the live plot if a test isn't running.""" - # the interval argument is used by matplotlib internally - - # we can just skip this if the test isn't running - if self.handler.is_running.get() and not self.handler.is_done.get(): - # data access here 😳 - start = time.time() - LOGGER.debug("%s: Drawing a new plot ...", self.handler.name) - with plt.style.context("bmh"): - self.axis.clear() - self.axis.set_xlabel("Time (min)") - self.axis.set_ylabel("Pressure (psi)") - pump1 = [] - pump2 = [] - elapsed = [] # we will share this series as an axis - readings = list(self.handler.readings.queue) - for reading in readings: - pump1.append(reading["pump 1"]) - pump2.append(reading["pump 2"]) - elapsed.append(reading["elapsedMin"]) - self.axis.plot(elapsed, pump1, label="Pump 1") - self.axis.plot(elapsed, pump2, label="Pump 2") - self.axis.legend(loc=0) - LOGGER.debug( - "%s: Drew a new plot for %s data points in %s s", - self.handler.name, - len(readings), - round(time.time() - start, 3), - ) diff --git a/scalewiz/components/main_frame.py b/scalewiz/components/main_frame.py deleted file mode 100644 index d157c7d..0000000 --- a/scalewiz/components/main_frame.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Main frame widget for the application.""" - -# util -import logging -from tkinter import ttk - -from scalewiz.components.menu_bar import MenuBar -from scalewiz.components.test_handler_view import TestHandlerView -from scalewiz.helpers.configuration import get_config -from scalewiz.models.test_handler import TestHandler - -LOGGER = logging.getLogger("scalewiz") - - -class MainFrame(ttk.Frame): - """Main Frame for the application.""" - - def __init__(self, parent: ttk.Frame) -> None: - ttk.Frame.__init__(self, parent) - self.parent = parent - self.winfo_toplevel().protocol("WM_DELETE_WINDOW", self.close) - self.build() - - def build(self) -> None: - """Build the UI.""" - MenuBar(self) # this will apply itself to the current Toplevel - self.tab_control = ttk.Notebook(self) - self.tab_control.grid(sticky="nsew") - self.add_handler() - - def add_handler(self) -> None: - """Adds a new tab with an associated test handler.""" - system_name = f" System {len(self.tab_control.tabs()) + 1} " - handler = TestHandler(name=system_name.strip()) - # plug it in 🔌 - view = TestHandlerView(self.tab_control, handler) - handler.set_view(view) # we want to be able to rebuild it later - self.tab_control.add(view, sticky="nsew") - self.tab_control.tab(view, text=system_name) - LOGGER.info("Added %s to main window", handler.name) - # if this is the first handler, open the most recent project - if len(self.tab_control.tabs()) == 1: - config = get_config() - handler.load_project(config["recents"].get("project")) - - def close(self) -> None: - """Closes the program if no tests are running.""" - for tab in self.tab_control.tabs(): - widget = self.nametowidget(tab) - if widget.handler.is_running.get(): - if not widget.handler.is_done.get(): - LOGGER.warning( - "Attempted to close while a test was running on %s", - widget.handler.name, - ) - return - self.quit() diff --git a/scalewiz/components/menu_bar.py b/scalewiz/components/menu_bar.py deleted file mode 100644 index 5b4edb3..0000000 --- a/scalewiz/components/menu_bar.py +++ /dev/null @@ -1,108 +0,0 @@ -"""MenuBar for the MainWindow.""" - -from __future__ import annotations - -import logging -import tkinter as tk -from tkinter.messagebox import showinfo - -from scalewiz.components.evaluation_window import EvaluationWindow -from scalewiz.components.project_window import ProjectWindow -from scalewiz.components.rinse_window import RinseWindow -from scalewiz.helpers.show_help import show_help - -# todo #9 port over the old chlorides / ppm calculators - -LOGGER = logging.getLogger("scalewiz") - - -class MenuBar: - """Menu bar to be displayed on the Main Frame.""" - - def __init__(self, parent: tk.Frame) -> None: - # expecting parent to be the toplevel parent of the main frame - self.main_frame = parent - - menubar = tk.Menu() - menubar.add_command(label="Add System", command=self.main_frame.add_handler) - # make project cascade - project_menu = tk.Menu(tearoff=0) - project_menu.add_command(label="New/Edit", command=self.spawn_editor) - project_menu.add_command( - label="Load existing", command=self.request_project_load - ) - menubar.add_cascade(label="Project", menu=project_menu) - # resume making buttons - menubar.add_command(label="Evaluation", command=self.spawn_evaluator) - menubar.add_command( - label="Log", command=self.main_frame.parent.log_window.deiconify - ) - menubar.add_command(label="Rinse", command=self.spawn_rinse) - # add info cascade - info_menu = tk.Menu(tearoff=0) - info_menu.add_command(label="Help", command=show_help) - info_menu.add_command(label="About", command=self.about) - menubar.add_cascade(label="Info", menu=info_menu) - - # menubar.add_command(label="Debug", command=self._debug) - - self.main_frame.winfo_toplevel().configure(menu=menubar) - - def spawn_editor(self) -> None: - """Spawn a Toplevel for editing Projects.""" - current_tab = self.main_frame.tab_control.select() - widget = self.main_frame.nametowidget(current_tab) - window = ProjectWindow(widget.handler) - widget.handler.editors.append(window) - LOGGER.debug("Spawned a Project Editor window for %s", widget.handler.name) - - def spawn_evaluator(self) -> None: - """Requests to open an evalutaion window for the currently selected Project.""" - current_tab = self.main_frame.tab_control.select() - widget = self.main_frame.nametowidget(current_tab) - window = EvaluationWindow(widget.handler) - widget.handler.editors.append(window) - LOGGER.debug("Spawned an Evaluation window for %s", widget.handler.name) - - def request_project_load(self) -> None: - """Request that the currently selected TestHandler load a Project.""" - # build a list of currently loaded projects, and pass to the handler - currently_loaded = [] - for tab in self.main_frame.tab_control.tabs(): - widget = self.main_frame.nametowidget(tab) - currently_loaded.append(widget.handler.project.path.get()) - # the handler will check to make sure we don't load a project in duplicate - current_tab = self.main_frame.tab_control.select() - widget = self.main_frame.nametowidget(current_tab) - widget.handler.load_project(loaded=currently_loaded) # this will log about it - widget.build() - - def spawn_rinse(self) -> None: - """Shows a RinseFrame in a new Toplevel.""" - current_tab = self.main_frame.tab_control.select() - widget = self.main_frame.nametowidget(current_tab) - RinseWindow(widget.handler) - LOGGER.debug("Spawned a Rinse window for %s", widget.handler.name) - - def about(self) -> None: - showinfo( - "About", - ("Copyright (C) 2021 Alex Whittington\n\n" - - "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.\n\n" - - "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.\n\n" - - "You should have received a copy of the GNU General Public License along with this program. If not, see ." - )) - - def _debug(self) -> None: - """Used for debugging.""" - pass - # from scalewiz.helpers.configuration import init_config - - # init_config() - # current_tab = self.main_frame.tab_control.select() - # widget = self.main_frame.nametowidget(current_tab) - # widget.handler.rebuild_views() - # widget.bell() diff --git a/scalewiz/components/project_window.py b/scalewiz/components/project_editor.py similarity index 61% rename from scalewiz/components/project_window.py rename to scalewiz/components/project_editor.py index 6f70c4b..11edbf9 100644 --- a/scalewiz/components/project_window.py +++ b/scalewiz/components/project_editor.py @@ -2,19 +2,19 @@ from __future__ import annotations -import os.path import tkinter as tk -import typing -from tkinter import filedialog, ttk +from pathlib import Path +from tkinter import filedialog, messagebox, ttk +from typing import TYPE_CHECKING -from scalewiz.components.project_info import ProjectInfo -from scalewiz.components.project_params import ProjectParams -from scalewiz.components.project_report import ProjectReport +from scalewiz.components.project_editor_info import ProjectInfo +from scalewiz.components.project_editor_params import ProjectParams +from scalewiz.components.project_editor_report import ProjectReport from scalewiz.helpers.configuration import open_config from scalewiz.helpers.set_icon import set_icon from scalewiz.models.project import Project -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from scalewiz.models.test_handler import TestHandler @@ -25,27 +25,25 @@ class ProjectWindow(tk.Toplevel): """ def __init__(self, handler: TestHandler) -> None: - tk.Toplevel.__init__(self) - self.handler = handler - self.editor_project = Project() - if os.path.isfile(handler.project.path.get()): + super().__init__() + self.handler: TestHandler = handler + self.editor_project: Project = Project() + if Path(handler.project.path.get()).is_file(): self.editor_project.load_json(handler.project.path.get()) + + self.title(f"{self.handler.name}") + set_icon(self) self.build() def build(self, reload: bool = False) -> None: """Destroys all child widgets, then builds the UI.""" if reload: - # cleanup for the GC - for test in self.editor_project.tests: - test.remove_traces() self.editor_project.remove_traces() # clean up the old one for GC self.editor_project = Project() self.editor_project.load_json(self.handler.project.path.get()) - self.winfo_toplevel().title(f"{self.handler.name}") - set_icon(self) for child in self.winfo_children(): - child.destroy() + self.after(0, child.destroy) self.winfo_toplevel().resizable(0, 0) self.grid_columnconfigure(0, weight=1) @@ -60,15 +58,21 @@ def build(self, reload: bool = False) -> None: ) button_frame = ttk.Frame(self) - ttk.Button(button_frame, text="Save", width=7, command=self.save).grid( - row=0, column=0, padx=5 - ) - ttk.Button(button_frame, text="Save as", width=7, command=self.save_as).grid( - row=0, column=1, padx=10 - ) - ttk.Button(button_frame, text="New", width=7, command=self.new).grid( - row=0, column=2, padx=5 - ) + + if self.handler.is_running: + state = "disabled" + else: + state = "normal" + + ttk.Button( + button_frame, text="Save", width=7, command=self.save, state=state + ).grid(row=0, column=0, padx=5) + ttk.Button( + button_frame, text="Save as", width=7, command=self.save_as, state=state + ).grid(row=0, column=1, padx=10) + ttk.Button( + button_frame, text="New", width=7, command=self.new, state=state + ).grid(row=0, column=2, padx=5) ttk.Button( button_frame, text="Edit defaults", width=10, command=self.edit ).grid(row=0, column=3, padx=5) @@ -81,12 +85,16 @@ def new(self) -> None: def save(self) -> None: """Save the current Project to file as JSON.""" - if self.editor_project.path.get() == "": - self.save_as() + # todo don't allow saving if saving to current project - otherwise fine + if not self.handler.is_running: + if self.editor_project.path.get() == "": + self.save_as() + else: + self.editor_project.dump_json() + self.handler.load_project(self.editor_project.path.get()) + self.handler.rebuild_views() else: - self.editor_project.dump_json() - self.handler.load_project(self.editor_project.path.get()) - self.handler.view.build() + messagebox.showwarning("Can't save while a Test is running") def save_as(self) -> None: """Saves the Project to JSON using a Save As dialog.""" @@ -101,7 +109,7 @@ def save_as(self) -> None: ext = file_path[-5:] if ext not in (".json", ".JSON"): file_path = f"{file_path}.json" - self.editor_project.path.set(file_path) + self.editor_project.path.set(str(Path(file_path).resolve())) self.save() def edit(self) -> None: diff --git a/scalewiz/components/project_info.py b/scalewiz/components/project_editor_info.py similarity index 92% rename from scalewiz/components/project_info.py rename to scalewiz/components/project_editor_info.py index d431453..8106fb4 100644 --- a/scalewiz/components/project_info.py +++ b/scalewiz/components/project_editor_info.py @@ -2,23 +2,26 @@ from __future__ import annotations -import tkinter as tk -import typing from tkinter import ttk +from typing import TYPE_CHECKING import tkcalendar as tkcal -from scalewiz.helpers.render import render - -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from scalewiz.models.project import Project +def render(lbl: ttk.Label, ent: ttk.Entry, row: int) -> None: + """Grids a label and entry on the passed row.""" + lbl.grid(row=row, column=0, sticky="e") + ent.grid(row=row, column=1, sticky="ew", pady=1) + + class ProjectInfo(ttk.Frame): """Editor for Project metadata.""" - def __init__(self, parent: tk.Frame, project: Project) -> None: - ttk.Frame.__init__(self, parent) + def __init__(self, parent: ttk.Frame, project: Project) -> None: + super().__init__(parent) self.grid_columnconfigure(1, weight=1) # row 0 ----------------------------------------------------------------------- diff --git a/scalewiz/components/project_params.py b/scalewiz/components/project_editor_params.py similarity index 94% rename from scalewiz/components/project_params.py rename to scalewiz/components/project_editor_params.py index a2ed3a0..b7ea8c1 100644 --- a/scalewiz/components/project_params.py +++ b/scalewiz/components/project_editor_params.py @@ -2,21 +2,26 @@ from __future__ import annotations -import typing from tkinter import ttk +from typing import TYPE_CHECKING -from scalewiz.helpers.render import render from scalewiz.helpers.validation import can_be_float, can_be_pos_float, can_be_pos_int -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from scalewiz.models.project import Project +def render(lbl: ttk.Label, ent: ttk.Entry, row: int) -> None: + """Grids a label and entry on the passed row.""" + lbl.grid(row=row, column=0, sticky="e") + ent.grid(row=row, column=1, sticky="ew", pady=1) + + class ProjectParams(ttk.Frame): """A form for mutating experiment-relevant attributes of the Project.""" def __init__(self, parent: ttk.Frame, project: Project) -> None: - ttk.Frame.__init__(self, parent) + super().__init__(parent) # validation commands to ensure numeric inputs is_pos_int = self.register(lambda s: can_be_pos_int(s)) diff --git a/scalewiz/components/project_report.py b/scalewiz/components/project_editor_report.py similarity index 76% rename from scalewiz/components/project_report.py rename to scalewiz/components/project_editor_report.py index 5378d4c..f567e45 100644 --- a/scalewiz/components/project_report.py +++ b/scalewiz/components/project_editor_report.py @@ -2,20 +2,24 @@ from __future__ import annotations -import typing from tkinter import ttk +from typing import TYPE_CHECKING -from scalewiz.helpers.render import render - -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from scalewiz.models.project import Project +def render(lbl: ttk.Label, ent: ttk.Entry, row: int) -> None: + """Grids a label and entry on the passed row.""" + lbl.grid(row=row, column=0, sticky="e") + ent.grid(row=row, column=1, sticky="ew", pady=1) + + class ProjectReport(ttk.Frame): """Editor for Project reporting settings.""" def __init__(self, parent: ttk.Frame, project: Project) -> None: - ttk.Frame.__init__(self, parent) + super().__init__(parent) self.grid_columnconfigure(1, weight=1) lbl = ttk.Label(self, text="Export format:") @@ -28,7 +32,6 @@ def __init__(self, parent: ttk.Frame, project: Project) -> None: render(lbl, ent, 0) # matplotlib stuff - # todo implement color selection # colorsLbl = ttk.Label(self, text="Plot color cycle:") # colorsEnt = ttk.Entry(self) # parent.render(colorsLbl, colorsEnt, 1) diff --git a/scalewiz/components/scalewiz.py b/scalewiz/components/scalewiz.py index c37a9d1..834e1e8 100644 --- a/scalewiz/components/scalewiz.py +++ b/scalewiz/components/scalewiz.py @@ -3,45 +3,56 @@ import logging import os from importlib.metadata import version +from logging.handlers import QueueHandler +from queue import Queue from tkinter import font, ttk -from scalewiz.components.log_window import LogWindow -from scalewiz.components.main_frame import MainFrame +from scalewiz.components.scalewiz_log_window import LogWindow +from scalewiz.components.scalewiz_main_frame import MainFrame from scalewiz.helpers.set_icon import set_icon -from scalewiz.models.logger import Logger class ScaleWiz(ttk.Frame): - """Core object for the application.""" + """Core object for the application. - def __init__(self, parent) -> None: - ttk.Frame.__init__(self, parent) + Used to define widget styles and set up logging. + """ + def __init__(self, parent) -> None: + super().__init__(parent) # set UI # icon / version set_icon(parent) parent.title(f"ScaleWiz {version('scalewiz')}") parent.resizable(0, 0) # apparently this is a bad practice... - # but it needs to stay locked for the TestHandlerView's "Toggle details" to work # font 🔠 default_font = font.nametofont("TkDefaultFont") default_font.configure(family="Arial") parent.option_add("*Font", "TkDefaultFont") bold_font = font.Font(family="Helvetica", weight="bold") - + ttk.Style().configure("TNotebook.Tab", font=bold_font) # widget backgrounds / themes 🎨 parent.tk_setPalette(background="#FAFAFA") - ttk.Style().configure("TLabel", background="#FAFAFA") - ttk.Style().configure("TFrame", background="#FAFAFA") - ttk.Style().configure("TLabelframe", background="#FAFAFA") - ttk.Style().configure("TLabelframe.Label", background="#FAFAFA") - ttk.Style().configure("TRadiobutton", background="#FAFAFA") - ttk.Style().configure("TCheckbutton", background="#FAFAFA") - ttk.Style().configure("TNotebook", background="#FAFAFA") - ttk.Style().configure("TNotebook.Tab", font=bold_font) - + for aspect in ("TLabel", "TFrame", "TRadiobutton", "TCheckbutton", "TNotebook"): + ttk.Style().configure(aspect, background="#FAFAFA") + # configure logging functionality + self.log_queue = Queue() + queue_handler = QueueHandler(self.log_queue) + # this is for inspecting the multithreading + fmt = "%(asctime)s - %(thread)d - %(levelname)s - %(name)s - %(message)s" + # fmt = "%(asctime)s - %(levelname)s - %(name)s - %(message)s" + date_fmt = "%Y-%m-%d %H:%M:%S" + formatter = logging.Formatter( + fmt, + date_fmt, + ) + logging.basicConfig(level=logging.DEBUG) # applies to the root logger instance + queue_handler.setFormatter(formatter) + queue_handler.setLevel(logging.INFO) + logger = logging.getLogger("scalewiz") + logger.addHandler(queue_handler) # holding a ref to the toplevel for the menubar to find - self.log_window = LogWindow(Logger()) - logging.getLogger("scalewiz").info("Starting in %s", os.getcwd()) + self.log_window = LogWindow(self) + logger.info("Starting in %s", os.getcwd()) self.log_window.withdraw() # 🏌️‍♀️👋 MainFrame(self).grid() diff --git a/scalewiz/components/log_window.py b/scalewiz/components/scalewiz_log_window.py similarity index 77% rename from scalewiz/components/log_window.py rename to scalewiz/components/scalewiz_log_window.py index 1761d10..298c4ce 100644 --- a/scalewiz/components/log_window.py +++ b/scalewiz/components/scalewiz_log_window.py @@ -2,31 +2,31 @@ from __future__ import annotations -import queue import tkinter as tk -import typing +from queue import Empty from tkinter.scrolledtext import ScrolledText +from typing import TYPE_CHECKING from scalewiz.helpers.set_icon import set_icon -from scalewiz.models.logger import Logger -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from logging import LogRecord + from scalewiz.components.scalewiz import ScaleWiz + + # thanks https://github.com/beenje/tkinter-logging-text-widget class LogWindow(tk.Toplevel): """A Toplevel with a ScrolledText. Displays messages from a Logger.""" - def __init__(self, logger: Logger) -> None: - tk.Toplevel.__init__(self) - self.winfo_toplevel().title("Log Window") + def __init__(self, core: ScaleWiz) -> None: + super().__init__() + self.log_queue = core.log_queue + self.title("Log Window") # replace the window closing behavior with withdrawing instead 🐱‍👤 - self.winfo_toplevel().protocol( - "WM_DELETE_WINDOW", lambda: self.winfo_toplevel().withdraw() - ) - self.log_queue = logger.log_queue + self.protocol("WM_DELETE_WINDOW", lambda: self.winfo_toplevel().withdraw()) self.build() def build(self) -> None: @@ -34,14 +34,13 @@ def build(self) -> None: set_icon(self) self.grid_columnconfigure(0, weight=1) self.grid_rowconfigure(0, weight=1) - self.scrolled_text = ScrolledText(self, state="disabled", width=80) + self.scrolled_text = ScrolledText(self, state="disabled", width=88) self.scrolled_text.grid(row=0, column=0, sticky="nsew") self.scrolled_text.tag_config("INFO", foreground="black") self.scrolled_text.tag_config("DEBUG", foreground="gray") self.scrolled_text.tag_config("WARNING", foreground="orange") self.scrolled_text.tag_config("ERROR", foreground="red") self.scrolled_text.tag_config("CRITICAL", foreground="red", underline=1) - # start polling messages from the queue 📩 self.after(100, self.poll_log_queue) @@ -50,7 +49,7 @@ def poll_log_queue(self) -> None: while True: try: record = self.log_queue.get(block=False) - except queue.Empty: + except Empty: break else: self.display(record) @@ -61,7 +60,7 @@ def display(self, record: LogRecord) -> None: msg = record.getMessage() self.scrolled_text.configure(state="normal") self.scrolled_text.insert( - tk.END, msg + "\n", record.levelname + "end", "".join((msg, "\n")), record.levelname ) # last arg is for the tag self.scrolled_text.configure(state="disabled") - self.scrolled_text.yview(tk.END) # scroll to bottom + self.scrolled_text.yview("end") # scroll to bottom diff --git a/scalewiz/components/scalewiz_main_frame.py b/scalewiz/components/scalewiz_main_frame.py new file mode 100644 index 0000000..363b148 --- /dev/null +++ b/scalewiz/components/scalewiz_main_frame.py @@ -0,0 +1,57 @@ +"""Main frame widget for the application. Manages a Notebook of TestHandlerViews.""" + +import logging +from pathlib import Path +from tkinter import ttk + +from scalewiz.components.handler_view import TestHandlerView +from scalewiz.components.scalewiz_menu_bar import MenuBar +from scalewiz.models.test_handler import TestHandler + +LOGGER = logging.getLogger("scalewiz") + + +class MainFrame(ttk.Frame): + """Main Frame for the application.""" + + def __init__(self, parent: ttk.Frame) -> None: + super().__init__(parent) + self.winfo_toplevel().protocol("WM_DELETE_WINDOW", self.close) + self.build() + + def build(self) -> None: + """Build the UI.""" + self.winfo_toplevel().configure(menu=MenuBar(self).menubar) + self.tab_control: ttk.Notebook = ttk.Notebook(self) + self.tab_control.grid(sticky="nsew") + self.add_handler() + + def add_handler(self) -> None: + """Adds a new tab with an associated test handler.""" + system_name = f" System {len(self.tab_control.tabs())+1} " + handler = TestHandler(name=system_name.strip()) + self.tab_control.add( + TestHandlerView(self.tab_control, handler), sticky="nsew", text=system_name + ) + LOGGER.info("Added %s to main window", handler.name) + # if this is the first handler, open the most recent project + if len(self.tab_control.tabs()) == 1: + from scalewiz import CONFIG + + recent = CONFIG["recents"]["project"] + if recent != "": + recent = Path(recent) + if recent.is_file(): + handler.load_project(recent) + + def close(self) -> None: + """Closes the program if no tests are running.""" + for tab in self.tab_control.tabs(): + widget: TestHandlerView = self.nametowidget(tab) + if widget.handler.is_running: + LOGGER.warning( + "Attempted to close while a test was running on %s", + widget.handler.name, + ) + return + self.quit() diff --git a/scalewiz/components/scalewiz_menu_bar.py b/scalewiz/components/scalewiz_menu_bar.py new file mode 100644 index 0000000..0ff65a0 --- /dev/null +++ b/scalewiz/components/scalewiz_menu_bar.py @@ -0,0 +1,115 @@ +"""MenuBar for the MainWindow.""" + +from __future__ import annotations + +import logging +import tkinter as tk +from pathlib import Path + +# from time import time +from tkinter.messagebox import showinfo +from typing import TYPE_CHECKING + +from scalewiz.components.evaluation_window import EvaluationWindow +from scalewiz.components.project_editor import ProjectWindow +from scalewiz.components.scalewiz_rinse_window import RinseWindow +from scalewiz.helpers.show_help import show_help + +LOGGER = logging.getLogger("scalewiz") + +if TYPE_CHECKING: + from scalewiz.components.handler_view import TestHandlerView + from scalewiz.components.scalewiz_main_frame import MainFrame + + +class MenuBar: + """Menu bar to be displayed on the Main Frame.""" + + def __init__(self, parent: MainFrame) -> None: + # expecting parent to be the toplevel parent of the main frame + self.parent = parent + + menubar = tk.Menu() + menubar.add_command(label="Add System", command=self.parent.add_handler) + # make project cascade + project_menu = tk.Menu(tearoff=0) + project_menu.add_command(label="New/Edit", command=self.spawn_editor) + project_menu.add_command( + label="Load existing", command=self.request_project_load + ) + menubar.add_cascade(label="Project", menu=project_menu) + # resume making buttons + menubar.add_command(label="Evaluation", command=self.spawn_evaluator) + menubar.add_command(label="Rinse", command=self.spawn_rinse) + menubar.add_command( + label="Log", command=self.parent.master.log_window.deiconify + ) + menubar.add_command(label="Help", command=show_help) + menubar.add_command(label="About", command=self.about) + + # menubar.add_command(label="Debug", command=self._debug) + self.menubar = menubar + + def spawn_editor(self) -> None: + """Spawn a Toplevel for editing Projects.""" + current_tab = self.parent.tab_control.select() + widget = self.parent.nametowidget(current_tab) + window = ProjectWindow(widget.handler) + widget.handler.views.append(window) + LOGGER.debug("Spawned a Project Editor window for %s", widget.handler.name) + + def spawn_evaluator(self) -> None: + """Requests to open an evalutaion window for the currently selected Project.""" + current_tab = self.parent.tab_control.select() + widget: TestHandlerView = self.parent.nametowidget(current_tab) + window = EvaluationWindow(widget.handler) + widget.handler.views.append(window) + LOGGER.debug("Spawned an Evaluation window for %s", widget.handler.name) + + def request_project_load(self) -> None: + """Request that the currently selected TestHandler load a Project.""" + # build a list of currently loaded projects, and pass to the handler + currently_loaded = set() + for tab in self.parent.tab_control.tabs(): + widget = self.parent.nametowidget(tab) + currently_loaded.add(Path(widget.handler.project.path.get())) + # the handler will check to make sure we don't load a project in duplicate + current_tab = self.parent.tab_control.select() + widget = self.parent.nametowidget(current_tab) + widget.handler.load_project(loaded=currently_loaded) + widget.build() + + def spawn_rinse(self) -> None: + """Shows a RinseFrame in a new Toplevel.""" + current_tab = self.parent.tab_control.select() + widget = self.parent.nametowidget(current_tab) + RinseWindow(widget.handler) + LOGGER.debug("Spawned a Rinse window for %s", widget.handler.name) + + def about(self) -> None: + showinfo( + "About", + ( + "Copyright (C) 2021 Alex Whittington\n\n" + "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.\n\n" # noqa: E501 + "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.\n\n" # noqa: E501 + "You should have received a copy of the GNU General Public License along with this program. If not, see ." # noqa: E501 + ), + ) + + def _debug(self) -> None: + """Used for debugging.""" + pass + # LOGGER.warn("DEBUGGING") + + # current_tab = self.parent.tab_control.select() + # widget: TestHandlerView = self.parent.nametowidget(current_tab) + # widget.handler.setup_pumps() + # t0 = time() + # widget.handler.pump1.pressure + # widget.handler.pump2.pressure + # t1 = time() + # widget.handler.close_pumps() + # LOGGER.warn("collected 2 pressures in %s", t1 - t0) + # widget.handler.rebuild_views() + # widget.bell() diff --git a/scalewiz/components/rinse_window.py b/scalewiz/components/scalewiz_rinse_window.py similarity index 85% rename from scalewiz/components/rinse_window.py rename to scalewiz/components/scalewiz_rinse_window.py index 8c0e36a..21f8dd5 100644 --- a/scalewiz/components/rinse_window.py +++ b/scalewiz/components/scalewiz_rinse_window.py @@ -1,9 +1,9 @@ """Simple frame that starts and stops the pumps on a timer.""" import logging -import time import tkinter as tk from concurrent.futures import ThreadPoolExecutor +from time import sleep from tkinter import ttk from scalewiz.helpers.set_icon import set_icon @@ -18,7 +18,7 @@ class RinseWindow(tk.Toplevel): def __init__(self, handler: TestHandler) -> None: tk.Toplevel.__init__(self) self.winfo_toplevel().protocol("WM_DELETE_WINDOW", self.close) - self.handler = handler + self.handler: TestHandler = handler self.pool = ThreadPoolExecutor(max_workers=1) self.stop = False @@ -36,14 +36,12 @@ def __init__(self, handler: TestHandler) -> None: ent = ttk.Spinbox(self, textvariable=self.rinse_minutes, from_=3, to=60) ent.grid(row=0, column=1) - self.button = ttk.Button( - self, textvariable=self.txt, command=self.request_rinse - ) + self.button = ttk.Button(self, text="Rinse", command=self.request_rinse) self.button.grid(row=2, column=0, columnspan=2) def request_rinse(self) -> None: """Try to start a rinse cycle if a test isn't running.""" - if not self.handler.is_running.get() or self.handler.is_done.get(): + if self.handler.is_done or not self.handler.is_running: self.pool.submit(self.rinse) def rinse(self) -> None: @@ -53,16 +51,16 @@ def rinse(self) -> None: self.handler.pump2.run() self.button.configure(state="disabled") - duration = self.rinse_minutes.get() * 60 + duration = round(self.rinse_minutes.get() * 60) for i in range(duration): if not self.stop: - self.txt.set(f"{i+1}/{duration} s") - time.sleep(1) + self.button.configure(text=f"{i+1}/{duration} s") + sleep(1) else: break self.bell() self.end_rinse() - self.button.configure(state="normal") + self.button.configure(state="normal", text="Rinse") def end_rinse(self) -> None: """Stop the pumps if they are running, then close their ports.""" diff --git a/scalewiz/components/test_evaluation_row.py b/scalewiz/components/test_evaluation_row.py deleted file mode 100644 index 54b5d84..0000000 --- a/scalewiz/components/test_evaluation_row.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Component for displaying a Test in a gridlike fashion.""" -from __future__ import annotations - -import tkinter as tk -import typing -from tkinter import messagebox, ttk - -if typing.TYPE_CHECKING: - from scalewiz.models.project import Project - from scalewiz.models.test import Test - - -class TestResultRow(ttk.Frame): - """Component for displaying a Test in a gridlike fashion.""" - - def __init__( - self, parent: tk.Frame, test: Test, project: Project, row: int - ) -> None: - ttk.Frame.__init__(self, parent) - self.test = test - # the immediate parent will be a frame "tests_frame" in the EvaluationFrame - # self.parent.master refers to the EvaluationFrame itself - self.parent = parent - self.project = project - self.row = row - self.build() - - def build(self) -> None: - """Make the UI.""" - cols: list[tk.Widget] = [] - # col 0 - name - cols.append(ttk.Label(self.parent, textvariable=self.test.name)) - # col 1 - label - cols.append( - ttk.Entry( - self.parent, - textvariable=self.test.label, - width=25, - validate="focusout", - validatecommand=self.update_score, - ) - ) - # col 2 - duration - duration = round( - len(self.test.readings) * self.project.interval_seconds.get() / 60, 2 - ) - cols.append( - ttk.Label( - self.parent, - text=f"{duration:.2f}, ({len(self.test.readings)})", - anchor="center", - ) - ) - # col 3 - pump to score - to_score = ttk.Combobox( - self.parent, - textvariable=self.test.pump_to_score, - values=["pump 1", "pump 2", "average"], - state="readonly", - width=7, - validate="all", - validatecommand=self.update_score, - ) - to_score.bind("", self.update_score) - cols.append(to_score) - # col 4 - obs baseline - cols.append( - ttk.Label( - self.parent, textvariable=self.test.observed_baseline, anchor="center" - ) - ) - # col 5 - max psi - cols.append( - ttk.Label(self.parent, textvariable=self.test.max_psi, anchor="center") - ) - # col 6 - clarity - cols.append( - ttk.Label(self.parent, textvariable=self.test.clarity, anchor="center") - ) - # col 7 - notes - cols.append(ttk.Entry(self.parent, textvariable=self.test.notes)) - # col 8 - result - cols.append( - ttk.Label(self.parent, textvariable=self.test.result, anchor="center") - ) - # col 9 - include on report - cols.append( - ttk.Checkbutton( - self.parent, - variable=self.test.include_on_report, - command=self.update_score, - ) - ) - # col 10 - delete - cols.append( - ttk.Button( - self.parent, - command=self.remove_from_project, - text="Delete", - width=7, - ) - ) - - for i, col in enumerate(cols): - if i == 0: # left align the name col - col.grid(row=self.row, column=i, padx=1, pady=1, sticky="w") - if i == 7: # make the notes col stretch - self.parent.grid_columnconfigure(7, weight=1) - col.grid(row=self.row, column=i, padx=1, pady=1, sticky="ew") - else: # defaults for the rest - col.grid( - row=self.row, - column=i, - padx=1, - pady=1, - ) - - def remove_from_project(self) -> None: - """Removes a Test from the parent Project, then tries to rebuild the UI.""" - msg = ( - "You are about to delete {} from {}.\n" - "This will become permanent once you save the project.\n" - "Do you wish to continue?" - ).format(self.test.name.get(), self.project.name.get()) - remove = messagebox.askyesno("Delete test", msg) - if remove and self.test in self.project.tests: - self.project.tests.remove(self.test) - self.parent.master.build() - - def update_score(self, *args) -> True: - """Method to call score from a validation callback. Doesn't check anything.""" - # prevents a race condition when setting the score - self.after(1, self.parent.master.score) - return True diff --git a/scalewiz/components/test_handler_view.py b/scalewiz/components/test_handler_view.py deleted file mode 100644 index b255408..0000000 --- a/scalewiz/components/test_handler_view.py +++ /dev/null @@ -1,325 +0,0 @@ -"""A Tkinter widget for handling tests.""" - -from __future__ import annotations - -import queue -import tkinter as tk -import typing -from logging import getLogger -from tkinter import ttk -from tkinter.scrolledtext import ScrolledText - -import matplotlib.pyplot as plt -import serial.tools.list_ports as list_ports - -from scalewiz.components.live_plot import LivePlot -from scalewiz.helpers.validation import can_be_pos_float - -if typing.TYPE_CHECKING: - from typing import List - - from scalewiz.models.test_handler import TestHandler - -LOGGER = getLogger("scalewiz") - - -class TestHandlerView(ttk.Frame): - """A form for setting up / running Tests.""" - - def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: - ttk.Frame.__init__(self, parent) - self.parent = parent - self.handler = handler - self.handler.parent = self - self.devices_list: List[str] = [] - self.inputs: List[tk.Widget] = [] - self.inputs_frame: ttk.Frame = None - self.device1_entry: ttk.Combobox = None - self.device2_entry: ttk.Combobox = None - self.trial_entry_frame: ttk.Frame = None - self.blank_entry: ttk.Label = None - self.blank_entry: ttk.Label = None - self.start_button: ttk.Button = None - self.new_button: ttk.Button = None - self.elapsed_label: ttk.Label = None - self.plot_frame: ttk.Frame = None - self.log_frame: ttk.Frame = None - self.log_text: ScrolledText = None - # we don't have to worry about cleaning up these traces - # the same handler instance will persist across projects - self.handler.is_running.trace_add("write", self.update_input_frame) - self.handler.is_done.trace_add("write", self.update_start_button) - self.build() - self.poll_log_queue() - - def build(self) -> None: - """Builds the UI, destroying any currently existing widgets.""" - for child in self.winfo_children(): - child.destroy() - - # use this list to hold refs so we can easily disable later - self.inputs.clear() - self.inputs_frame = ttk.Frame(self) - self.inputs_frame.grid(row=0, column=0, sticky="new") - - # row 0 ------------------------------------------------------------------------ - lbl = ttk.Label(self.inputs_frame, text=" Devices:") - lbl.bind("", self.update_devices_list) - - # put the boxes in a frame to make life easier - ent = ttk.Frame(self.inputs_frame) # this frame will set the width for the col - self.device1_entry = ttk.Combobox( - ent, - width=15, - textvariable=self.handler.dev1, - values=self.devices_list, - validate="all", - validatecommand=self.update_devices_list, - ) - self.device2_entry = ttk.Combobox( - ent, - width=15, - textvariable=self.handler.dev2, - values=self.devices_list, - validate="all", - validatecommand=self.update_devices_list, - ) - self.device1_entry.grid(row=0, column=0, sticky=tk.W) - self.device2_entry.grid(row=0, column=1, sticky=tk.E, padx=(4, 0)) - self.inputs.append(self.device1_entry) - self.inputs.append(self.device2_entry) - self.render(lbl, ent, 0) - - # row 1 ------------------------------------------------------------------------ - lbl = ttk.Label(self.inputs_frame, text="Project:") - btn = ttk.Label( - self.inputs_frame, textvariable=self.handler.project.name, anchor="center" - ) - self.inputs.append(btn) - self.render(lbl, btn, 1) - - # row 2 ------------------------------------------------------------------------ - lbl = ttk.Label(self.inputs_frame, text="Test Type:") - ent = ttk.Frame(self.inputs_frame) - ent.grid_columnconfigure(0, weight=1) - ent.grid_columnconfigure(1, weight=1) - blank_radio = ttk.Radiobutton( - ent, - text="Blank", - variable=self.handler.test.is_blank, - value=True, - command=self.update_test_type, - ) - trial_radio = ttk.Radiobutton( - ent, - text="Trial", - variable=self.handler.test.is_blank, - value=False, - command=self.update_test_type, - ) - blank_radio.grid(row=0, column=0) - trial_radio.grid(row=0, column=1) - self.inputs.append(blank_radio) - self.inputs.append(trial_radio) - self.render(lbl, ent, 2) - - # row 3 ------------------------------------------------------------------------ - self.grid_rowconfigure(3, weight=1) - # row 3a is used when the TestHandlerView is in "Blank" mode - # row 3a ----------------------------------------------------------------------- - self.trial_label_frame = ttk.Frame(self.inputs_frame) - - ttk.Label(self.trial_label_frame, text="Chemical:").grid( - row=0, column=0, sticky=tk.E, pady=1 - ) - ttk.Label(self.trial_label_frame, text="Rate (ppm):").grid( - row=1, - column=0, - sticky=tk.E, - pady=1, - ) - ttk.Label(self.trial_label_frame, text="Clarity:").grid( - row=2, column=0, sticky=tk.E, pady=1 - ) - - self.trial_entry_frame = ttk.Frame(self.inputs_frame) - self.trial_entry_frame.grid_columnconfigure(0, weight=1) - chemical_entry = ttk.Entry( - self.trial_entry_frame, textvariable=self.handler.test.chemical - ) - chemical_entry.grid(row=0, column=0, sticky="ew", pady=1) - - # validation command to ensure numeric inputs - vcmd = self.register(lambda s: can_be_pos_float(s)) - rate_entry = ttk.Spinbox( - self.trial_entry_frame, - textvariable=self.handler.test.rate, - from_=1, - to=999999, - validate="key", - validatecommand=(vcmd, "%P"), - ) - rate_entry.grid(row=1, column=0, sticky="ew", pady=1) - clarity_entry = ttk.Combobox( - self.trial_entry_frame, - values=["Clear", "Slightly hazy", "Hazy"], - textvariable=self.handler.test.clarity, - ) - clarity_entry.grid(row=2, column=0, sticky="ew", pady=1) - clarity_entry.current(0) - - self.inputs.append(chemical_entry) - self.inputs.append(rate_entry) - self.inputs.append(clarity_entry) - - # row 3b is used when the TestHandlerView is in "Trial" mode - # row 3b ----------------------------------------------------------------------- - self.blank_label = ttk.Label(self.inputs_frame, text="Name:") - self.blank_entry = ttk.Entry( - self.inputs_frame, textvariable=self.handler.test.name - ) - self.inputs.append(self.blank_entry) - - # row 4 ------------------------------------------------------------------------ - lbl = ttk.Label(self.inputs_frame, text="Notes:") - ent = ttk.Entry(self.inputs_frame, textvariable=self.handler.test.notes) - self.inputs.append(ent) - self.render(lbl, ent, 4) - - # inputs_frame end ------------------------------------------------------------- - - # row 1 ------------------------------------------------------------------------ - ent = ttk.Frame(self) - self.start_button = ttk.Button( - ent, text="Start", command=self.handler.start_test - ) - stop_button = ttk.Button(ent, text="Stop", command=self.handler.request_stop) - details_button = ttk.Button( - ent, text="Toggle Details", command=self.update_plot_visible - ) - - self.start_button.grid(row=0, column=0) - stop_button.grid(row=0, column=1) - details_button.grid(row=0, column=2) - - ttk.Progressbar(ent, variable=self.handler.progress).grid( - row=1, columnspan=3, sticky="nwe" - ) - self.elapsed_label = ttk.Label(ent, textvariable=self.handler.elapsed_str) - self.elapsed_label.grid(row=1, column=1) - ent.grid(row=1, column=0, padx=1, pady=1, sticky="n") - self.new_button = ttk.Button(ent, text="New", command=self.handler.new_test) - - # rows 0-1 --------------------------------------------------------------------- - # close all pyplots to prevent memory leak - plt.close("all") - self.plot_frame = LivePlot(self, self.handler) - self.grid_columnconfigure(1, weight=1) # let it grow - self.grid_rowconfigure(1, weight=1) - - # row 2 ------------------------------------------------------------------------ - self.log_frame = ttk.Frame(self) - self.log_text = ScrolledText( - self.log_frame, background="white", height=5, width=44, state="disabled" - ) - self.log_text.grid(sticky="ew") - - self.update_test_type() - self.update_start_button() - self.update_devices_list() - - # methods to update local state ---------------------------------------------------- - - def render(self, label: tk.Widget, entry: tk.Widget, row: int) -> None: - """Renders a row on the UI. As method for convenience.""" - # pylint: disable=no-self-use - label.grid(row=row, column=0, sticky=tk.N + tk.E) - entry.grid(row=row, column=1, sticky=tk.N + tk.E + tk.W, pady=1, padx=1) - - def update_devices_list(self, *args) -> None: - """Updates the devices list held by the TestHandler.""" - # extra unused args are passed in by tkinter - def update() -> None: - self.devices_list = sorted([i.device for i in list_ports.comports()]) - if len(self.devices_list) < 1: - self.devices_list = ["None found"] - - self.device1_entry.configure(values=self.devices_list) - self.device2_entry.configure(values=self.devices_list) - - if len(self.devices_list) > 1: - self.device1_entry.current(0) - self.device2_entry.current(1) - - if "None found" not in self.devices_list: - LOGGER.debug( - "%s found devices: %s", self.handler.name, self.devices_list - ) - - # after here to prevent race conditions - self.after(1, update) - - def update_input_frame(self, *args) -> None: - """Disables widgets in the input frame if a Test is running.""" - if self.handler.is_running.get(): - for widget in self.inputs: - widget.configure(state="disabled") - else: - for widget in self.inputs: - widget.configure(state="normal") - - def update_start_button(self, *args) -> None: - """Changes the "Start" button to a "New" button when the Test finishes.""" - if self.handler.is_done.get(): - self.start_button.configure(text="New", command=self.handler.new_test) - else: - self.start_button.configure(text="Start", command=self.handler.start_test) - - def update_test_type(self, *args) -> None: - """Rebuilds part of the UI to change the entries wrt Test type (blank/trial).""" - if self.handler.test.is_blank.get(): - self.trial_label_frame.grid_remove() - self.trial_entry_frame.grid_remove() - self.render(self.blank_label, self.blank_entry, 3) - LOGGER.debug("%s: changed to Blank mode", self.handler.name) - else: - self.blank_label.grid_remove() - self.blank_entry.grid_remove() - self.render(self.trial_label_frame, self.trial_entry_frame, 3) - LOGGER.debug("%s: changed to Trial mode", self.handler.name) - - def update_plot_visible(self) -> None: - """Updates the details view across all TestHandlerViews.""" - is_visible = bool() - # check if the plot is gridded - if self.plot_frame.grid_info() != {}: - is_visible = True - - for tab in self.parent.tabs(): - this = self.parent.nametowidget(tab) - if not is_visible: # show the details view - LOGGER.debug("%s: Showing details view", this.handler.name) - this.plot_frame.grid(row=0, column=1, rowspan=3) - this.log_frame.grid(row=2, column=0, sticky="ew") - else: # hide the details view - LOGGER.debug("%s: Hiding details view", this.handler.name) - this.plot_frame.grid_remove() - this.log_frame.grid_remove() - - def poll_log_queue(self) -> None: - """Checks every 100ms if there is a new message in the queue to display.""" - while True: - try: - record = self.handler.log_queue.get(block=False) - except queue.Empty: - break - else: - self.display(record) - self.after(100, self.poll_log_queue) - - def display(self, msg: str) -> None: - """Displays a message in the log.""" - self.log_text.configure(state="normal") - self.log_text.insert(tk.END, msg + "\n") # last arg is for the tag - self.log_text.configure(state="disabled") - self.log_text.yview(tk.END) # scroll to bottom diff --git a/scalewiz/helpers/configuration.py b/scalewiz/helpers/configuration.py index c750c59..1e2d2cf 100644 --- a/scalewiz/helpers/configuration.py +++ b/scalewiz/helpers/configuration.py @@ -1,7 +1,5 @@ """This module defines functions that deal with program configuration.""" -# todo color cycle for reports - from __future__ import annotations import os @@ -12,10 +10,12 @@ from appdirs import user_config_dir from tomlkit import comment, document, dumps, loads, table +import scalewiz + LOGGER = getLogger("scalewiz.config") CONFIG_DIR = Path(user_config_dir("ScaleWiz", "teauxfu")) -CONFIG_FILE = Path(os.path.join(CONFIG_DIR, "config.toml")) +CONFIG_FILE = Path(CONFIG_DIR, "config.toml") def ensure_config() -> None: @@ -34,7 +34,6 @@ def ensure_config() -> None: "No config file found in %s. Making one now at %s", CONFIG_DIR, CONFIG_FILE ) init_config() - # todo #19 make sure the config isn't missing keys def init_config() -> None: @@ -134,8 +133,8 @@ def get_config() -> dict[str, Union[float, int, str]]: """Returns the current configuration as a dict.""" ensure_config() with CONFIG_FILE.open("r") as file: - defaults = loads(file.read()) - return defaults + config = loads(file.read()) + return config def update_config(table: str, key: str, value: Union[float, int, str]) -> None: @@ -152,5 +151,6 @@ def update_config(table: str, key: str, value: Union[float, int, str]) -> None: doc[table][key] = value CONFIG_FILE.write_text(dumps(doc)) LOGGER.info("Updated %s.%s to %s", table, key, value) + scalewiz.CONFIG = get_config() else: LOGGER.info("Failed to update %s.%s to %s", table, key, value) diff --git a/scalewiz/helpers/export_csv.py b/scalewiz/helpers/export.py similarity index 77% rename from scalewiz/helpers/export_csv.py rename to scalewiz/helpers/export.py index 69b7c9c..2ed16d5 100644 --- a/scalewiz/helpers/export_csv.py +++ b/scalewiz/helpers/export.py @@ -1,9 +1,11 @@ """A function for exporting a representation of a Project as CSV.""" +from __future__ import annotations + import json import logging -import os -import time +from pathlib import Path +from typing import Tuple from pandas import DataFrame @@ -12,9 +14,8 @@ LOGGER = logging.getLogger("scalewiz") -def export_csv(project: Project) -> None: +def export(project: Project) -> Tuple[int, Path]: """Generates a report for a Project in a flattened CSV format (or ugly JSON).""" - start_time = time.time() LOGGER.info("Beginning export of %s", project.name.get()) output_dict = { @@ -31,9 +32,11 @@ def export_csv(project: Project) -> None: "baselinePsi": project.baseline.get(), "bicarbs": project.bicarbs.get(), "bicarbsIncreased": project.bicarbs_increased.get(), + "calcium": project.calcium.get(), "chlorides": project.chlorides.get(), "timeLimitMin": project.limit_minutes.get(), "limitPsi": project.limit_psi.get(), + "readingIntervalSecs": project.interval_seconds.get(), "name": [], "isBlank": [], "chemical": [], @@ -56,7 +59,7 @@ def export_csv(project: Project) -> None: if test.include_on_report.get() and not test.is_blank.get() ] tests = blanks + trials - + # we use lists here instead of sets since sets aren't JSON serializable output_dict["name"] = [test.name.get() for test in tests] output_dict["isBlank"] = [test.is_blank.get() for test in tests] output_dict["chemical"] = [test.chemical.get() for test in tests] @@ -69,20 +72,25 @@ def export_csv(project: Project) -> None: output_dict["result"] = [test.result.get() for test in tests] output_dict["clarity"] = [test.clarity.get() for test in tests] - pre = f"{project.numbers.get().replace(' ', '')} {project.name.get()}" - out = f"{pre} - CaCO3 Scale Block Analysis.{project.output_format.get()}" - out = os.path.join(os.path.dirname(project.path.get()), out.strip()) + fmt = project.output_format.get() + out = f"{project.numbers.get().replace(' ', '')} {project.name.get()}" + out = f"{out} - CaCO3 Scale Block Analysis.{fmt}".strip() + out = Path(Path(project.path.get()).parent).joinpath(out).resolve() - with open(out, "w") as output: - if project.output_format.get() == "CSV": + with out.open("w") as output: + if fmt == "CSV": data = DataFrame.from_dict(output_dict) data.to_csv(out, encoding="utf-8") - elif project.output_format.get() == "JSON": + elif fmt == "JSON": json.dump(output_dict, output, indent=4) LOGGER.info( - "Finished export of %s as %s in %s s", + "Finished export of %s as %s", project.name.get(), project.output_format.get(), - round(time.time() - start_time, 3), ) + + if out.is_file(): + return 0, out + else: + return 1, out diff --git a/scalewiz/helpers/render.py b/scalewiz/helpers/render.py deleted file mode 100644 index f5c3438..0000000 --- a/scalewiz/helpers/render.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Grids a label and entry on the passed row.""" - -from tkinter import ttk - - -def render(lbl: ttk.Label, ent: ttk.Entry, row: int) -> None: - """Grids a label and entry on the passed row.""" - lbl.grid(row=row, column=0, sticky="e") - ent.grid(row=row, column=1, sticky="ew", pady=1) diff --git a/scalewiz/helpers/score.py b/scalewiz/helpers/score.py new file mode 100644 index 0000000..0f958f0 --- /dev/null +++ b/scalewiz/helpers/score.py @@ -0,0 +1,116 @@ +"""Functions for scoring Tests within a Project. +""" +from __future__ import annotations + +import tkinter as tk +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from tkinter.scrolledtext import ScrolledText + from typing import List, Set + + from scalewiz.models.project import Project + + +def score(project: Project, log_widget: ScrolledText = None, *args) -> None: + """Updates the result for every Test in the Project. + + Accepts event args passed from the tkVar trace. + """ + # extra unused args may be passed in by tkinter + log: List[str] = [] + log.append(f"Evaluating results for {project.name.get()}...") + # scoring props + limit_minutes = project.limit_minutes.get() + interval_seconds = project.interval_seconds.get() + max_readings = round(limit_minutes * 60 / interval_seconds) + log.append("Max readings: limitMin * 60 / reading interval") + log.append(f"Max readings: {max_readings}") + baseline_area = round(project.baseline.get() * max_readings) + log.append("Baseline area: baseline PSI * max readings") + log.append(f"Baseline area: {project.baseline.get()} * {max_readings}") + log.append(f"Baseline area: {baseline_area}") + log.append("-" * 80) + log.append("") + + # select the blanks + blanks = [] + for test in project.tests: + if test.is_blank.get() and test.include_on_report.get(): + blanks.append(test) + if len(blanks) < 1: # this is bad enough to stop us, could check earlier ..? + return + + areas_over_blanks: Set[int] = set() + for blank in blanks: + log.append(f"Evaluating {blank.name.get()}") + log.append(f"Considering data: {blank.pump_to_score.get()}") + readings = blank.get_readings() + log.append(f"Total readings: {len(readings)}") + log.append(f"Observed baseline: {blank.observed_baseline.get()} psi") + int_psi = sum(readings) + log.append("Integral PSI: sum of all pressure readings") + log.append(f"Integral PSI: {int_psi}") + area = project.limit_psi.get() * len(readings) - int_psi + log.append("Area over blank: limit_psi * # of readings - integral PSI") + log.append( + f"Area over blank: {project.limit_psi.get()} " + f"* {len(readings)} - {int_psi}" + ) + log.append(f"Area over blank: {area}") + log.append("") + areas_over_blanks.add(area) + + # get protectable area + avg_blank_area = round(sum(areas_over_blanks) / len(areas_over_blanks)) + log.append(f"Avg. area over blanks: {avg_blank_area}") + avg_protectable_area = project.limit_psi.get() * max_readings - avg_blank_area + log.append( + "Avg. protectable area: limit_psi * max_readings - avg. area over blanks" + ) + log.append( + f"Avg. protectable area: {project.limit_psi.get()} " + f"* {max_readings} - {avg_blank_area}" + ) + log.append(f"Avg. protectable area: {avg_protectable_area}") + log.append("-" * 80) + log.append("") + + # select trials + trials = [] + for test in project.tests: + if not test.is_blank.get(): + trials.append(test) + + # get readings + for trial in trials: + log.append(f"Evaluating {trial.name.get()}") + log.append(f"Considering data: {trial.pump_to_score.get()}") + readings = trial.get_readings() + log.append(f"Total readings: {len(readings)}") + log.append(f"Observed baseline: {trial.observed_baseline.get()} psi") + int_psi = sum(readings) + ( + (max_readings - len(readings)) * project.limit_psi.get() + ) + log.append("Integral PSI: sum of all pressure readings") + log.append(f"Integral PSI: {int_psi}") + result = round(1 - (int_psi - baseline_area) / avg_protectable_area, 3) + log.append("Result: 1 - (integral PSI - baseline area) / avg protectable area") + log.append( + f"Result: 1 - ({int_psi} - {baseline_area}) / {avg_protectable_area}" + ) + log.append(f"Result: {result} \n") + trial.result.set(f"{result:.2f}") + + if isinstance(log_widget, tk.Text): + to_log(log, log_widget) + + +def to_log(log: list[str], log_widget: ScrolledText) -> None: + """Adds the passed log messages to the passed Text widget.""" + if log_widget.winfo_exists(): + log_widget.configure(state="normal") + log_widget.delete(1.0, "end") + for msg in log: + log_widget.insert("end", "".join((msg, "\n"))) + log_widget.configure(state="disabled") diff --git a/scalewiz/helpers/set_icon.py b/scalewiz/helpers/set_icon.py index 94ed5f2..3816197 100644 --- a/scalewiz/helpers/set_icon.py +++ b/scalewiz/helpers/set_icon.py @@ -4,6 +4,7 @@ import logging import os import tkinter as tk +from pathlib import Path from scalewiz.helpers.get_resource import get_resource @@ -14,14 +15,13 @@ def set_icon(widget: tk.Widget) -> None: """Sets an icon on the current Toplevel.""" # set the Toplevel's icon try: # this makes me nervous, but whatever - icon_path = get_resource(r"../components/icon.ico") + icon_path = Path(get_resource(r"../components/icon.ico")).resolve() + if icon_path.is_file(): + widget.winfo_toplevel().wm_iconbitmap(icon_path) + # for windows, set the taskbar icon + if "nt" in os.name: + import ctypes + + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("scalewiz") except FileNotFoundError: LOGGER.error("Failed to set the icon") - - if os.path.isfile(icon_path): - widget.winfo_toplevel().wm_iconbitmap(icon_path) - # for windows, set the taskbar icon - if os.name == "nt": - import ctypes - - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("scalewiz") diff --git a/scalewiz/models/logger.py b/scalewiz/models/logger.py deleted file mode 100644 index 0f37515..0000000 --- a/scalewiz/models/logger.py +++ /dev/null @@ -1,31 +0,0 @@ -"""A logger class for the program.""" - -import logging -from logging.handlers import QueueHandler -from queue import Queue - - -class Logger: - """Sets default logging behavior for the program. - - Holds a ref to a queue. The LogFrame depends on access to this. - - Use from anywhere by calling logging.getLogger('scalewiz') - """ - - def __init__(self) -> None: - """The LogWindow depends on access to the .loq_queue attribute.""" - self.log_queue = Queue() - logging.basicConfig(level=logging.DEBUG) - queue_handler = QueueHandler(self.log_queue) - # this one is for inspecting the multithreading - # fmt = "%(asctime)s - %(func)s - %(thread)d - %(levelname)s - %(name)s - %(message)s" # noqa: E501 - fmt = "%(asctime)s - %(levelname)s - %(name)s - %(message)s" - date_fmt = "%Y-%m-%d %H:%M:%S" - formatter = logging.Formatter( - fmt, - date_fmt, - ) - queue_handler.setFormatter(formatter) - queue_handler.setLevel(logging.INFO) - logging.getLogger("scalewiz").addHandler(queue_handler) diff --git a/scalewiz/models/project.py b/scalewiz/models/project.py index beb9d64..a9fdafa 100644 --- a/scalewiz/models/project.py +++ b/scalewiz/models/project.py @@ -4,13 +4,18 @@ import json import logging -import os import tkinter as tk +from pathlib import Path +from typing import TYPE_CHECKING -from scalewiz.helpers.configuration import get_config, update_config +from scalewiz import CONFIG +from scalewiz.helpers.configuration import update_config from scalewiz.helpers.sort_nicely import sort_nicely from scalewiz.models.test import Test +if TYPE_CHECKING: + from typing import List + LOGGER = logging.getLogger("scalewiz") @@ -20,7 +25,7 @@ class Project: # pylint: disable=too-many-instance-attributes def __init__(self) -> None: - self.tests: list[Test] = [] + self.tests: List[Test] = [] # experiment parameters that affect score self.baseline = tk.IntVar() self.limit_minutes = tk.DoubleVar() @@ -55,25 +60,24 @@ def __init__(self) -> None: def set_defaults(self) -> None: """Sets project parameters to the defaults read from the config file.""" - config = get_config() # load from cofig toml - defaults = config["defaults"] + defaults = CONFIG["defaults"] # make sure we are seeing reasonable values for key, value in defaults.items(): if not isinstance(value, str) and value < 0: defaults[key] = value * (-1) # apply values - self.baseline.set(defaults.get("baseline")) - self.interval_seconds.set(defaults.get("reading_interval")) - self.limit_minutes.set(defaults.get("time_limit")) - self.limit_psi.set(defaults.get("pressure_limit")) - self.output_format.set(defaults.get("output_format")) - self.temperature.set(defaults.get("test_temperature")) - self.flowrate.set(defaults.get("flowrate")) - self.uptake_seconds.set(defaults.get("uptake_time")) + self.baseline.set(defaults["baseline"]) + self.interval_seconds.set(defaults["reading_interval"]) + self.limit_minutes.set(defaults["time_limit"]) + self.limit_psi.set(defaults["pressure_limit"]) + self.output_format.set(defaults["output_format"]) + self.temperature.set(defaults["test_temperature"]) + self.flowrate.set(defaults["flowrate"]) + self.uptake_seconds.set(defaults["uptake_time"]) # this must never be <= 0 if self.interval_seconds.get() <= 0: self.interval_seconds.set(1) - self.analyst.set(config["recents"].get("analyst")) + self.analyst.set(CONFIG["recents"]["analyst"]) def add_traces(self) -> None: """Adds tkVar traces where needed. Must be cleaned up with remove_traces.""" @@ -85,26 +89,30 @@ def add_traces(self) -> None: def dump_json(self, path: str = None) -> None: """Dump a JSON representation of the Project at the passed path.""" if path is None: - path = self.path.get() + path = Path(self.path.get()) + + blanks = {} + trials = {} + for test in self.tests: + label = test.label.get().lower() + while label in blanks or label in trials: # make sure we don't overwrite + label = "".join((label, " - copy")) + if test.is_blank.get(): + blanks[label] = test + else: + trials[label] = test + + blank_labels = sort_nicely(list(blanks.keys())) + trial_labels = sort_nicely(list(trials.keys())) - blanks = [test for test in self.tests if test.is_blank.get()] - trials = [test for test in self.tests if not test.is_blank.get()] - blank_labels = sort_nicely([test.label.get().lower() for test in blanks]) - trial_labels = sort_nicely([test.label.get().lower() for test in trials]) tests = [] for label in blank_labels: - for test in self.tests: - if test.label.get().lower() == label: - tests.append(test) - + tests.append(blanks.pop(label)) for label in trial_labels: - for test in self.tests: - if test.label.get().lower() == label: - tests.append(test) + tests.append(trials.pop(label)) self.tests.clear() - for test in tests: - self.tests.append(test) + self.tests = [test for test in tests] this = { "info": { @@ -119,7 +127,7 @@ def dump_json(self, path: str = None) -> None: "name": self.name.get(), "analyst": self.analyst.get(), "numbers": self.numbers.get(), - "path": os.path.abspath(self.path.get()), + "path": str(Path(self.path.get()).resolve()), "notes": self.notes.get(), }, "params": { @@ -137,64 +145,67 @@ def dump_json(self, path: str = None) -> None: }, "tests": [test.to_dict() for test in self.tests], "outputFormat": self.output_format.get(), - "plot": os.path.abspath(self.plot.get()), + "plot": str(Path(self.plot.get()).resolve()), } - - with open(path, "w") as file: - json.dump(this, file, indent=4) - LOGGER.info("Saved %s to %s", self.name.get(), path) - update_config("recents", "analyst", self.analyst.get()) - update_config("recents", "project", self.path.get()) + try: + with Path(path).open("w") as file: + json.dump(this, file, indent=4) + LOGGER.info("Saved %s to %s", self.name.get(), path) + update_config("recents", "analyst", self.analyst.get()) + update_config("recents", "project", str(Path(self.path.get()).resolve())) + except Exception as err: + LOGGER.exception(err) def load_json(self, path: str) -> None: """Return a Project from a passed path to a JSON dump.""" - path = os.path.abspath(path) - if os.path.isfile(path): + path = Path(path).resolve() + if path.is_file(): LOGGER.info("Loading from %s", path) - with open(path, "r") as file: + with path.open("r") as file: obj = json.load(file) # we expect the data files to be shared over Dropbox, etc. - if path != obj.get("info").get("path"): + if str(path) != obj["info"]["path"]: LOGGER.warning( "Opened a Project whose actual path didn't match its path property" ) - obj["info"]["path"] = path - - info = obj.get("info") - self.customer.set(info.get("customer")) - self.submitted_by.set(info.get("submittedBy")) - self.client.set(info.get("productionCo")) - self.field.set(info.get("field")) - self.sample.set(info.get("sample")) - self.sample_date.set(info.get("sampleDate")) - self.received_date.set(info.get("recDate")) - self.completed_date.set(info.get("compDate")) - self.name.set(info.get("name")) - self.numbers.set(info.get("numbers")) - self.analyst.set(info.get("analyst")) - self.path.set(info.get("path")) - self.notes.set(info.get("notes")) - - params = obj.get("params") - self.bicarbs.set(params.get("bicarbonates")) - self.bicarbs_increased.set(params.get("bicarbsIncreased")) - self.calcium.set(params.get("calcium")) - self.chlorides.set(params.get("chlorides")) - self.baseline.set(params.get("baseline")) - self.temperature.set(params.get("temperature")) - self.limit_psi.set(params.get("limitPSI")) - self.limit_minutes.set(params.get("limitMin")) - self.interval_seconds.set(params.get("interval")) - self.flowrate.set(params.get("flowrate")) - self.uptake_seconds.set(params.get("uptake")) - - self.plot.set(obj.get("plot")) - self.output_format.set(obj.get("outputFormat")) - - for entry in obj.get("tests"): - test = Test() - test.load_json(entry) + obj["info"]["path"] = str(path) + + info: dict = obj["info"] + self.customer.set(info["customer"]) + self.submitted_by.set(info["submittedBy"]) + self.client.set(info["productionCo"]) + self.field.set(info["field"]) + self.sample.set(info["sample"]) + self.sample_date.set(info["sampleDate"]) + self.received_date.set(info["recDate"]) + self.completed_date.set(info["compDate"]) + self.name.set(info["name"]) + self.numbers.set(info["numbers"]) + self.analyst.set(info["analyst"]) + self.path.set(str(Path(info["path"]).resolve())) + self.notes.set(info["notes"]) + + defaults = CONFIG["defaults"] + params: dict = obj["params"] + self.bicarbs.set(params.get("bicarbonates", 0)) + self.bicarbs_increased.set(params.get("bicarbsIncreased", False)) + self.calcium.set(params.get("calcium", 0)) + self.chlorides.set(params.get("chlorides", 0)) + self.baseline.set(params.get("baseline", defaults["baseline"])) + self.temperature.set(params.get("temperature", defaults["test_temperature"])) + self.limit_psi.set(params.get("limitPSI", defaults["pressure_limit"])) + self.limit_minutes.set(params.get("limitMin", defaults["time_limit"])) + self.interval_seconds.set(params.get("interval", defaults["reading_interval"])) + self.flowrate.set(params.get("flowrate", defaults["flowrate"])) + self.uptake_seconds.set(params.get("uptake", defaults["uptake_time"])) + self.output_format.set(obj.get("outputFormat", defaults["output_format"])) + + self.plot.set(obj["plot"]) + + self.tests.clear() + for entry in obj["tests"]: + test = Test(data=entry) self.tests.append(test) def remove_traces(self) -> None: @@ -205,17 +216,18 @@ def remove_traces(self) -> None: var.trace_remove("write", var.trace_info()[0][1]) except IndexError: # sometimes this spaghets when loading empty projects... pass + for test in self.tests: + test.remove_traces() def update_proj_name(self, *args) -> None: """Constructs a default name for the Project.""" # extra unused args are passed in by tkinter - name = "" if self.client.get() != "": name = self.client.get().strip() else: name = self.customer.get().strip() if self.field.get() != "": - name = f"{name} - {self.field.get()}".strip() + name = f"{name} - {self.field.get().strip()}" if self.sample.get() != "": - name = f"{name} ({self.sample.get()})".strip() + name = f"{name} ({self.sample.get().strip()})" self.name.set(name) diff --git a/scalewiz/models/test.py b/scalewiz/models/test.py index 2c82566..9fd45f3 100644 --- a/scalewiz/models/test.py +++ b/scalewiz/models/test.py @@ -5,17 +5,29 @@ # util import logging import tkinter as tk -from typing import Any, Union +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import List, Tuple, Union LOGGER = logging.getLogger("scalewiz") +@dataclass +class Reading: + elapsedMin: float + pump1: int + pump2: int + average: int + + class Test: """Object for holding all the data associated with a Test.""" # pylint: disable=too-many-instance-attributes - def __init__(self) -> None: + def __init__(self, data: dict = None) -> None: self.is_blank = tk.BooleanVar() # boolean for blank vs chemical trial self.name = tk.StringVar() # identifier for the test self.chemical = tk.StringVar() # chemical, if any, to be tested @@ -26,7 +38,7 @@ def __init__(self) -> None: self.pump_to_score = tk.StringVar() # which series of PSIs to use self.result = tk.DoubleVar() # represents the test's performance vs the blank self.include_on_report = tk.BooleanVar() # condition for scoring - self.readings: list[dict] = [] # list of flat reading dicts + self.readings: List[Reading] = [] # list of flat reading dicts self.max_psi = tk.IntVar() # the highest psi of the test self.observed_baseline = tk.IntVar() # a guess at the baseline for the test # set defaults @@ -34,6 +46,9 @@ def __init__(self) -> None: self.is_blank.set(True) self.add_traces() # will need to clean these up later for the GC + if isinstance(data, dict): + self.load_json(data) + def add_traces(self) -> None: """Adds tkVar traces. Need to be removed with remove_traces.""" self.chemical.trace_add("write", self.update_test_name) @@ -43,6 +58,19 @@ def add_traces(self) -> None: def to_dict(self) -> dict[str, Union[bool, float, int, str]]: """Returns a dict representation of a Test.""" + self.clean_test() # strip whitespaces from relevant fields + # cast all readings from dataclasses to dicts + readings = [] + for reading in self.readings: + readings.append( + { + "pump 1": reading.pump1, + "pump 2": reading.pump2, + "average": reading.average, + "elapsedMin": reading.elapsedMin, + } + ) + return { "name": self.name.get(), "isBlank": self.is_blank.get(), @@ -55,28 +83,38 @@ def to_dict(self) -> dict[str, Union[bool, float, int, str]]: "includeOnRep": self.include_on_report.get(), "result": self.result.get(), "obsBaseline": self.observed_baseline.get(), - "readings": self.readings, + "readings": readings, } def load_json(self, obj: dict[str, Union[bool, float, int, str]]) -> None: """Load a Test with values from a JSON object.""" - self.name.set(obj.get("name")) - self.is_blank.set(obj.get("isBlank")) - self.chemical.set(obj.get("chemical")) - self.rate.set(obj.get("rate")) - self.label.set(obj.get("reportAs")) - self.clarity.set(obj.get("clarity")) - self.notes.set(obj.get("notes")) - self.pump_to_score.set(obj.get("toConsider")) - self.include_on_report.set(obj.get("includeOnRep")) - self.result.set(obj.get("result")) - self.readings = obj.get("readings") + self.name.set(obj["name"]) + self.is_blank.set(obj["isBlank"]) + self.chemical.set(obj["chemical"]) + self.rate.set(obj["rate"]) + self.label.set(obj["reportAs"]) + self.clarity.set(obj["clarity"]) + self.notes.set(obj["notes"]) + self.pump_to_score.set(obj["toConsider"]) + self.include_on_report.set(obj["includeOnRep"]) + self.result.set(obj["result"]) + readings = obj["readings"] + for reading in readings: + self.readings.append( + Reading( + pump1=reading["pump 1"], + pump2=reading["pump 2"], + average=reading["average"], + elapsedMin=reading["elapsedMin"], + ) + ) self.update_obs_baseline() - def get_readings(self) -> list[int]: + def get_readings(self) -> Tuple[int]: """Returns a list of the pump_to_score's pressure readings.""" pump = self.pump_to_score.get() - return [reading[pump] for reading in self.readings] + pump = pump.replace(" ", "") # legacy accomodation for spaces in keys + return [getattr(reading, pump) for reading in self.readings] def update_test_name(self, *args) -> None: """Makes a name by concatenating the chemical name and rate.""" @@ -86,8 +124,12 @@ def update_test_name(self, *args) -> None: else: self.name.set(f"{self.chemical.get()} {self.rate.get():.2f} ppm") - if self.chemical.get().strip() != self.chemical.get(): - self.chemical.set(self.chemical.get().strip()) + def clean_test(self) -> None: + """Do some formatting on the test to clean it up for storing.""" + strippables = (self.chemical, self.name, self.label, self.clarity, self.notes) + for attr in strippables: + if attr.get().strip() != attr.get(): + attr.set(attr.get().strip()) def update_label(self, *args) -> None: """Sets the label to the current name as a default value.""" @@ -107,5 +149,5 @@ def remove_traces(self) -> None: for var in variables: try: var.trace_remove("write", var.trace_info()[0][1]) - except IndexError: # sometimes this spaghets when loading empty projects... + except IndexError: # sometimes this spaghets on empty projects... pass diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index 86496e0..8a60f0b 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -2,28 +2,25 @@ from __future__ import annotations -import logging -import os import tkinter as tk -import typing from concurrent.futures import ThreadPoolExecutor from datetime import date +from logging import DEBUG, FileHandler, Formatter, getLogger +from pathlib import Path from queue import Queue -from threading import Event -from time import monotonic, sleep, time +from time import sleep, time from tkinter import filedialog, messagebox +from typing import TYPE_CHECKING from py_hplc import NextGenPump +import scalewiz from scalewiz.models.project import Project -from scalewiz.models.test import Test +from scalewiz.models.test import Reading, Test -if typing.TYPE_CHECKING: - from tkinter import ttk - from tkinter.scrolledtext import ScrolledText - from typing import List - - from scalewiz.components.test_handler_view import TestHandlerView +if TYPE_CHECKING: + from logging import Logger + from typing import List, Set, Union class TestHandler: @@ -33,86 +30,63 @@ class TestHandler: def __init__(self, name: str = "Nemo") -> None: self.name = name - self.logger = logging.getLogger(f"scalewiz.{name}") - self.view: TestHandlerView = None - self.project = Project() + self.root: tk.Tk = scalewiz.ROOT + self.logger: Logger = getLogger(f"scalewiz.{name}") + self.project: Project = Project() self.test: Test = None - self.pool = ThreadPoolExecutor(max_workers=1) - self.readings: Queue[dict] = Queue() - self.editors: list[tk.Widget] = [] # list of views displaying the project + self.readings: List[Reading] = [] self.max_readings: int = None # max # of readings to collect + self.limit_psi: int = None self.max_psi_1: int = None self.max_psi_2: int = None - self.log_handler: logging.FileHandler = None # handles logging to log window - # test handler view overwrites this attribute in the view's build() - self.log_text: ScrolledText = None + self.limit_minutes: float = None + self.log_handler: FileHandler = None # handles logging to log window self.log_queue: Queue[str] = Queue() # view pulls from this queue - self.dev1 = tk.StringVar() self.dev2 = tk.StringVar() - self.stop_requested: Event = Event() + self.stop_requested: bool = bool() self.progress = tk.IntVar() - self.elapsed_min = tk.DoubleVar() # used for evaluations - self.elapsed_str = tk.StringVar() # used in widgets where formatting is awkward - + self.elapsed_min: float = float() # current duration self.pump1: NextGenPump = None self.pump2: NextGenPump = None + self.pool = ThreadPoolExecutor(max_workers=3) # UI concerns - self.is_running = tk.BooleanVar() - self.is_done = tk.BooleanVar() + self.views: List[tk.Widget] = [] # list of views displaying the project + self.is_running: bool = bool() + self.is_done: bool = bool() self.new_test() + @property def can_run(self) -> bool: """Returns a bool indicating whether or not the test can run.""" return ( - ( - self.max_psi_1 <= self.project.limit_psi.get() - or self.max_psi_2 <= self.project.limit_psi.get() - ) - and self.elapsed_min.get() <= self.project.limit_minutes.get() - and len(self.readings.queue) < self.max_readings - and not self.stop_requested.is_set() + (self.max_psi_1 < self.limit_psi or self.max_psi_2 < self.limit_psi) + and self.elapsed_min < self.limit_minutes + and len(self.readings) < self.max_readings + and not self.stop_requested ) - def load_project(self, path: str = None, loaded: list[str] = []) -> None: - """Opens a file dialog then loads the selected Project file.""" - # traces are set in Project and Test __init__ methods - # we need to explicitly clean them up here - if self.project is not None: - for test in self.project.tests: - test.remove_traces() - self.project.remove_traces() - - if path is None: - path = os.path.abspath( - filedialog.askopenfilename( - initialdir='C:"', - title="Select project file:", - filetypes=[("JSON files", "*.json")], - ) - ) - - # check that the dialog succeeded, the file exists, and isn't already loaded - if path != "" and os.path.isfile(path): - if path in loaded: - msg = "Attempted to load an already-loaded project" - self.logger.warning(msg) - messagebox.showwarning("Project already loaded", msg) - else: - self.project = Project() - self.project.load_json(path) - self.rebuild_views() - self.logger.info("Loaded %s", self.project.name.get()) + def new_test(self) -> None: + """Initialize a new test.""" + self.logger.info("Initializing a new test") + if isinstance(self.test, Test): + self.test.remove_traces() + self.test = Test() + self.limit_psi = self.project.limit_psi.get() + self.limit_minutes = self.project.limit_minutes.get() + self.max_psi_1, self.max_psi_2 = 0, 0 + self.is_running, self.is_done = False, False + self.progress.set(0) + self.max_readings = round( + self.project.limit_minutes.get() * 60 / self.project.interval_seconds.get() + ) + self.rebuild_views() def start_test(self) -> None: """Perform a series of checks to make sure the test can run, then start it.""" - # todo disable the start button instead of this - if self.is_running.get(): - return - issues = [] - if not os.path.isfile(self.project.path.get()): + if not Path(self.project.path.get()).is_file(): msg = "Select an existing project file first" issues.append(msg) @@ -120,72 +94,84 @@ def start_test(self) -> None: msg = "Name the experiment before starting" issues.append(msg) + if self.test.name.get() in {test.name.get() for test in self.project.tests}: + msg = "A test with this name already exists in the project" + issues.append(msg) + if self.test.clarity.get() == "" and not self.test.is_blank.get(): msg = "Water clarity cannot be blank" issues.append(msg) - # this method will append issue msgs if any occur - self.setup_pumps(issues) # hooray for pointers + # these methods will append issue messages if any occur + self.update_log_handler(issues) + self.setup_pumps(issues) if len(issues) > 0: messagebox.showwarning("Couldn't start the test", "\n".join(issues)) for pump in (self.pump1, self.pump2): pump.close() else: - self.stop_requested.clear() - self.is_done.set(False) - self.is_running.set(True) - self.update_log_handler() - self.logger.info("submitting") - self.pool.submit(self.take_readings) + self.readings.clear() + self.stop_requested = False + self.is_done = False + self.is_running = True + self.rebuild_views() + self.pool.submit(self.uptake_cycle) - def take_readings(self) -> None: - """Get ready to take readings, then start doing it on a second thread.""" - # run the uptake cycle --------------------------------------------------------- + def uptake_cycle(self) -> None: + """Get ready to take readings. + + Meant to be run from a worker thread. + """ + self.logger.info("Starting an uptake cycle") uptake = self.project.uptake_seconds.get() step = uptake / 100 # we will sleep for 100 steps self.pump1.run() self.pump2.run() - rinse_start = monotonic() - sleep(step) + rinse_start = time() for i in range(100): - elapsed = monotonic() - rinse_start - if self.can_run(): - self.elapsed_str.set(f"{uptake - elapsed:.1f} s") + if self.can_run: self.progress.set(i) - sleep(step - ((monotonic() - rinse_start) % step)) + sleep(step - ((time() - rinse_start) % step)) else: - self.stop_test() + self.stop_test(save=False) break - self.log_queue.put("") # add newline for clarity - # we use these in the loop + self.take_readings() # still in the Future's thread + + def take_readings(self) -> None: + """Collects Readings by messaging the pumps. + + Meant to be run from a worker thread. + """ + self.logger.info("Starting readings collection") + + def get_pressure(pump: NextGenPump) -> Union[float, int]: + self.logger.info("collecting a reading from %s", pump.serial.name) + return pump.pressure + interval = self.project.interval_seconds.get() - test_start_time = monotonic() - sleep(interval) + start_time = time() # readings loop ---------------------------------------------------------------- - while self.can_run(): - minutes_elapsed = round((monotonic() - test_start_time) / 60, 2) - - psi1 = self.pump1.pressure - psi2 = self.pump2.pressure + while self.can_run: + self.elapsed_min = (time() - start_time) / 60 + t0 = time() + psi1 = self.pool.submit(get_pressure, self.pump1) + psi2 = self.pool.submit(get_pressure, self.pump2) + psi1, psi2 = psi1.result(), psi2.result() + t1 = time() + self.logger.warn("got both in %s s", t1 - t0) average = round(((psi1 + psi2) / 2)) - reading = { - "elapsedMin": minutes_elapsed, - "pump 1": psi1, - "pump 2": psi2, - "average": average, - } - + reading = Reading( + elapsedMin=self.elapsed_min, pump1=psi1, pump2=psi2, average=average + ) # make a message for the log in the test handler view msg = "@ {:.2f} min; pump1: {}, pump2: {}, avg: {}".format( - minutes_elapsed, psi1, psi2, average + self.elapsed_min, psi1, psi2, average ) + self.readings.append(reading) self.log_queue.put(msg) - self.logger.info(msg) - - self.readings.put(reading) - self.elapsed_min.set(minutes_elapsed) - self.elapsed_str.set(f"{minutes_elapsed:.2f} min.") - self.progress.set(round(len(self.readings.queue) / self.max_readings * 100)) + self.logger.debug(msg) + prog = round((len(self.readings) / self.max_readings) * 100) + self.progress.set(prog) if psi1 > self.max_psi_1: self.max_psi_1 = psi1 @@ -193,44 +179,38 @@ def take_readings(self) -> None: self.max_psi_2 = psi2 # TYSM https://stackoverflow.com/a/25251804 - sleep(interval - ((monotonic() - test_start_time) % interval)) - # end of readings loop --------------------------------------------------------- - self.stop_test() - self.save_test() - - # because the readings loop is blocking, it is handled on a separate thread - # beacuse of this, we have to interact with it in a somewhat backhanded way - # this method is intended to be called from the test handler view + sleep(interval - ((time() - start_time) % interval)) + else: + self.root.after(0, self.stop_test, {"save": True}) + def request_stop(self) -> None: """Requests that the Test stop.""" - if self.is_running.get(): - # the readings loop thread checks this flag on each iteration - self.stop_requested.set() - self.logger.info("Received a stop request") + if self.is_running: + self.stop_requested = True - def stop_test(self) -> None: + def stop_test(self, save: bool = False, rinsing: bool = False) -> None: """Stops the pumps, closes their ports.""" - for pump in (self.pump1, self.pump2): - if pump.is_open: - pump.stop() - pump.close() - self.logger.info( - "Stopped and closed the device @ %s", - pump.serial.name, - ) - - self.is_done.set(True) - self.logger.info("Test for %s has been stopped", self.test.name.get()) + self.close_pumps() + if not rinsing: + self.is_done = True + self.is_running = False + for _ in range(3): + self.views[0].bell() + if save: + self.save_test() + self.progress.set(100) + self.rebuild_views() def save_test(self) -> None: """Saves the test to the Project file in JSON format.""" - for reading in list(self.readings.queue): - self.test.readings.append(reading) + self.logger.info( + "Saving %s to %s", self.test.name.get(), self.project.name.get() + ) + self.test.readings.extend(self.readings) self.project.tests.append(self.test) self.project.dump_json() # refresh data / UI - self.load_project(path=self.project.path.get()) - self.rebuild_views() + self.load_project(path=self.project.path.get(), new_test=False) def setup_pumps(self, issues: List[str] = None) -> None: """Set up the pumps with some default values. @@ -251,68 +231,90 @@ def setup_pumps(self, issues: List[str] = None) -> None: self.pump1 = NextGenPump(self.dev1.get(), self.logger) self.pump2 = NextGenPump(self.dev2.get(), self.logger) + flowrate = self.project.flowrate.get() for pump in (self.pump1, self.pump2): if pump is None or not pump.is_open: issues.append(f"Couldn't connect to {pump.serial.name}") continue - pump.flowrate = self.project.flowrate.get() - self.logger.info("set flowrate to %s", pump.flowrate) + pump.flowrate = flowrate + self.logger.info("Set flowrates to %s", pump.flowrate) - # logging stuff / methods that affect UI - def new_test(self) -> None: - """Initialize a new test.""" - self.logger.info("Initialized a new test") - self.test = Test() - with self.readings.mutex: - self.readings.queue.clear() - self.max_psi_1 = self.max_psi_2 = 0 - self.is_running.set(False) - self.is_done.set(False) - self.progress.set(0) - self.elapsed_str.set("") - self.max_readings = round( - self.project.limit_minutes.get() * 60 / self.project.interval_seconds.get() - ) + def close_pumps(self) -> None: + """Tears down the pumps.""" + for pump in (self.pump1, self.pump2): + if pump.is_open: + pump.stop() + pump.close() + self.logger.info( + "Stopped and closed the device @ %s", + pump.serial.name, + ) + + def load_project( + self, + path: Union[str, Path] = None, + loaded: Set[Path] = [], + new_test: bool = True, + ) -> None: + """Opens a file dialog then loads the selected Project file. - # rebuild the TestHandlerView - if self.view is not None: - self.view.build() + `loaded` gets built from scratch every time it is passed in -- no need to update + """ + if path is None: + path = filedialog.askopenfilename( + initialdir='C:"', + title="Select project file:", + filetypes=[("JSON files", "*.json")], + ) + if isinstance(path, str): + path = Path(path).resolve() + + # check that the dialog succeeded, the file exists, and isn't already loaded + if path.is_file(): + if path in loaded: + msg = "Attempted to load an already-loaded project" + self.logger.warning(msg) + messagebox.showwarning("Project already loaded", msg) + else: + self.project.remove_traces() + self.project = Project() + self.project.load_json(path) + if new_test: + self.new_test() + self.logger.info("Loaded %s", self.project.name.get()) + self.rebuild_views() def rebuild_views(self) -> None: - """Rebuild all open Widgets that could modify the Project file.""" - for widget in self.editors: + """Rebuild all open Widgets that display or modify the Project file.""" + for widget in self.views: if widget.winfo_exists(): self.logger.debug("Rebuilding %s", widget) - widget.build(reload=True) - else: # clean up as we go - self.editors.remove(widget) - self.view.build() - self.logger.info("Rebuilt all view widgets") + self.root.after_idle(widget.build, {"reload": True}) + else: + self.logger.debug("Removing dead widget %s", widget) + self.views.remove(widget) - def update_log_handler(self) -> None: - """Sets up the logging FileHandler to the passed path.""" - log_file = f"{round(time())}_{self.test.name.get()}_{date.today()}.txt" - parent_dir = os.path.dirname(self.project.path.get()) - logs_dir = os.path.join(parent_dir, "logs") - if not os.path.isdir(logs_dir): - os.mkdir(logs_dir) - log_path = os.path.join(logs_dir, log_file) - - if self.log_handler in self.logger.handlers: - self.logger.removeHandler(self.log_handler) - self.log_handler = logging.FileHandler(log_path) + self.logger.debug("Rebuilt all view widgets") - formatter = logging.Formatter( + def update_log_handler(self, issues: List[str]) -> None: + """Sets up the logging FileHandler to the passed path.""" + id = "".join(char for char in self.test.name.get() if char.isalnum()) + log_file = f"{time():.0f}_{id}_{date.today()}.txt" + parent_dir = Path(self.project.path.get()).parent.resolve() + logs_dir = parent_dir.joinpath("logs").resolve() + if not logs_dir.is_dir(): + logs_dir.mkdir() + log_path = Path(logs_dir).joinpath(log_file).resolve() + self.log_handler = FileHandler(log_path) + + formatter = Formatter( "%(asctime)s - %(thread)d - %(levelname)s - %(message)s", "%Y-%m-%d %H:%M:%S", ) + if self.log_handler in self.logger.handlers: # remove the old one + self.logger.removeHandler(self.log_handler) self.log_handler.setFormatter(formatter) - self.log_handler.setLevel(logging.DEBUG) + self.log_handler.setLevel(DEBUG) self.logger.addHandler(self.log_handler) self.logger.info("Set up a log file at %s", log_file) self.logger.info("Starting a test for %s", self.project.name.get()) - - def set_view(self, view: ttk.Frame) -> None: - """Stores a ref to the view displaying the handler.""" - self.view = view - self.log_text = view.log_text diff --git a/todo b/todo index 6990505..8468c8b 100644 --- a/todo +++ b/todo @@ -1,7 +1,30 @@ -#7 refactor state management in the test handler -#9 port over the old chlorides / ppm calculators -#1 refactor TestHandlerView +todo +---- -??? remove last three tabs of evaluation window? +- try to clean up export code / add export confirmation dialog +- handle a queue of changes to a project more gracefully -'add system' -> 'system' > 'add new', 'remove current' +bugs +---- + +- none! I'm pretty sure ... + +refactoring +----------- + +- we have a dep. on Pandas for one little call in export helper - could be worked around + +updates / new features +---------------------- + +- none! + +low prio +-------- + +- port over the old chlorides / ppm calculators +- check for config missing keys ? +- menubar: + - 'add system' -> 'system' > 'add new', 'remove current' + - this will be a little awkward since we'd have to update/rebuild the menubar + each time a system is added / removed