From e114be7a25a1f4fbc2f734ccc29fc409addbb819 Mon Sep 17 00:00:00 2001 From: teauxfu Date: Tue, 11 May 2021 16:06:28 -0500 Subject: [PATCH 01/49] prep for refactors --- poetry.lock | 98 ++++++++++++------------ pyproject.toml | 2 +- scalewiz/components/evaluation_window.py | 18 +++-- scalewiz/components/live_plot.py | 2 +- scalewiz/components/test_handler_view.py | 10 ++- scalewiz/models/test.py | 12 ++- scalewiz/models/test_handler.py | 12 ++- todo | 4 +- 8 files changed, 85 insertions(+), 73 deletions(-) diff --git a/poetry.lock b/poetry.lock index 9dff7b3..03a742c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -59,7 +59,7 @@ python-versions = ">=3.6" [[package]] name = "matplotlib" -version = "3.4.1" +version = "3.4.2" description = "Python plotting package" category = "main" optional = false @@ -75,7 +75,7 @@ python-dateutil = ">=2.7" [[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 @@ -203,7 +203,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 @@ -239,7 +239,7 @@ python-versions = "*" [metadata] lock-version = "1.1" python-versions = "^3.9" -content-hash = "759021d4e6282b3bc5d0faa70d8721b6dd062e5b482e1796fd5c44b4e286931e" +content-hash = "6640ed2329fb162297a39342580ff0313ae97d7c820a75aa0ef285b324a8c88f" [metadata.files] appdirs = [ @@ -297,51 +297,51 @@ 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"}, ] 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"}, @@ -432,8 +432,8 @@ 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"}, diff --git a/pyproject.toml b/pyproject.toml index 4926421..467d92f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ 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" diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index 90319b5..0462e69 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -46,7 +46,7 @@ def __init__(self, handler: TestHandler) -> None: 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.plot_frame: ttk.Frame = None # this gets destroyed in plot() self.build() def render(self, label: tk.Widget, entry: tk.Widget, row: int) -> None: @@ -157,7 +157,8 @@ def plot(self) -> None: # close all pyplots to prevent memory leak plt.close("all") # get rid of our old plot tab - self.plot_frame.destroy() + if isinstance(self.plot_frame, ttk.Frame): + 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") @@ -340,9 +341,10 @@ def score(self, *args) -> None: 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") + if self.log_text.grid_info() != {}: + self.log_text.configure(state="normal") + self.log_text.delete(1.0, "end") + for msg in log: + self.log_text.insert("end", msg) + self.log_text.insert("end", "\n") + self.log_text.configure(state="disabled") diff --git a/scalewiz/components/live_plot.py b/scalewiz/components/live_plot.py index 81dcd31..4bad690 100644 --- a/scalewiz/components/live_plot.py +++ b/scalewiz/components/live_plot.py @@ -24,7 +24,7 @@ class LivePlot(ttk.Frame): def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: """Initialize a LivePlot.""" - ttk.Frame.__init__(self, parent) + super().__init__(parent) self.handler = handler # matplotlib objects diff --git a/scalewiz/components/test_handler_view.py b/scalewiz/components/test_handler_view.py index b255408..d3c7c02 100644 --- a/scalewiz/components/test_handler_view.py +++ b/scalewiz/components/test_handler_view.py @@ -212,11 +212,10 @@ def build(self) -> None: # 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) - + plt.close("all") + self.plot_frame = LivePlot(self, self.handler) # row 2 ------------------------------------------------------------------------ self.log_frame = ttk.Frame(self) self.log_text = ScrolledText( @@ -292,11 +291,14 @@ def update_plot_visible(self) -> None: """Updates the details view across all TestHandlerViews.""" is_visible = bool() # check if the plot is gridded + print(type(self.plot_frame)) + if self.plot_frame.grid_info() != {}: is_visible = True for tab in self.parent.tabs(): - this = self.parent.nametowidget(tab) + this = self.nametowidget(tab) + print(type(this)) 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) diff --git a/scalewiz/models/test.py b/scalewiz/models/test.py index 2c82566..1b6b23a 100644 --- a/scalewiz/models/test.py +++ b/scalewiz/models/test.py @@ -5,11 +5,20 @@ # util import logging import tkinter as tk -from typing import Any, Union +from dataclasses import dataclass +from typing import 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.""" @@ -77,6 +86,7 @@ def get_readings(self) -> list[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] + # 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.""" diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index 86496e0..067b3f8 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -16,7 +16,7 @@ from py_hplc import NextGenPump 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 @@ -168,12 +168,10 @@ def take_readings(self) -> None: psi1 = self.pump1.pressure psi2 = self.pump2.pressure average = round(((psi1 + psi2) / 2)) - reading = { - "elapsedMin": minutes_elapsed, - "pump 1": psi1, - "pump 2": psi2, - "average": average, - } + + reading = Reading( + elapsedMin=minutes_elapsed, pump1=psi1, pump2=psi2, average=average + ) # make a message for the log in the test handler view msg = "@ {:.2f} min; pump1: {}, pump2: {}, avg: {}".format( diff --git a/todo b/todo index 6990505..429695d 100644 --- a/todo +++ b/todo @@ -2,6 +2,6 @@ #9 port over the old chlorides / ppm calculators #1 refactor TestHandlerView -??? remove last three tabs of evaluation window? - 'add system' -> 'system' > 'add new', 'remove current' + +refactor scoring out of eval window From db9e39ece6a92acba8da77b5cd3113ef52ff5bb9 Mon Sep 17 00:00:00 2001 From: teauxfu Date: Wed, 12 May 2021 20:20:49 -0500 Subject: [PATCH 02/49] prep for refactors --- .pre-commit-config.yaml | 13 ++ CHANGELOG.rst | 19 ++- poetry.lock | 164 ++++++++++++++++++++++- pyproject.toml | 3 +- requirements.txt | 102 +++++++------- scalewiz/components/evaluation_window.py | 41 +++--- scalewiz/components/live_plot.py | 46 +++---- scalewiz/components/test_handler_view.py | 28 ++-- scalewiz/helpers/configuration.py | 4 +- scalewiz/models/test.py | 44 ++++-- todo | 32 ++++- 11 files changed, 364 insertions(+), 132 deletions(-) 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..d3dd12d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,21 @@ Changelog `__, and this project adheres to `Semantic Versioning `__. + +[v0.5.7] +-------- + +Changed +~~~~~~~ + +- updated some path operations in :code:`EvaluationFrame` to use the :code:`pathlib.Path` API +- updated :code:`EvaluationFrame` to handle the :code:`Reading` class +- updated the :code:`Test` object model to handle the :code:`Reading` class +- minor performance buff to the :code:`LivePlot` component +- refactoring the :code:`TestHandlerView` to be less tempermental +- misc. code cleanup + + [v0.5.6] -------- @@ -129,7 +144,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 +158,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 ~~~~~~~ diff --git a/poetry.lock b/poetry.lock index 03a742c..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" @@ -73,6 +108,14 @@ 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.3" @@ -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" @@ -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 = "6640ed2329fb162297a39342580ff0313ae97d7c820a75aa0ef285b324a8c88f" +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"}, @@ -317,6 +426,10 @@ matplotlib = [ {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.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"}, @@ -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"}, @@ -440,10 +588,18 @@ tkcalendar = [ {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 467d92f..4216228 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ python = "^3.9" 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/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index 0462e69..5a5ce6b 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -6,6 +6,7 @@ import time import tkinter as tk import typing +from pathlib import Path from tkinter import font, ttk import matplotlib as mpl @@ -19,8 +20,10 @@ from scalewiz.models.project import Project if typing.TYPE_CHECKING: + from scalewiz.models.test import Test from scalewiz.models.test_handler import TestHandler + COLORS = [ "orange", "blue", @@ -42,11 +45,13 @@ def __init__(self, handler: TestHandler) -> None: tk.Toplevel.__init__(self) 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: ttk.Frame = None # this gets destroyed in plot() + self.trials: list[Test] = [] + self.blanks: list[Test] = [] self.build() def render(self, label: tk.Widget, entry: tk.Widget, row: int) -> None: @@ -57,7 +62,7 @@ def render(self, label: tk.Widget, entry: tk.Widget, row: int) -> None: 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 reload and Path(self.handler.project.path.get()).is_file: # cleanup for the GC for test in self.editor_project.tests: test.remove_traces() @@ -96,15 +101,13 @@ def build(self, reload: bool = False) -> None: self.grid_columnconfigure(0, weight=1) - self.blanks = [] + self.blanks.clear() + self.trials.clear() + # filter through blanks and trials 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(): + else: self.trials.append(test) tk.Label(tests_frame, text="Blanks:", font=bold_font).grid( @@ -176,7 +179,7 @@ def plot(self) -> None: if blank.include_on_report.get(): elapsed = [] for reading in blank.readings: - elapsed.append(reading["elapsedMin"]) + elapsed.append(reading.elapsedMin) self.axis.plot( elapsed, blank.get_readings(), @@ -187,8 +190,8 @@ def plot(self) -> None: 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"]) + for reading in trial.readings: + elapsed.append(reading.elapsedMin) self.axis.plot( elapsed, trial.get_readings(), label=trial.label.get() ) @@ -213,24 +216,24 @@ def save(self) -> None: f"{self.editor_project.numbers.get().replace(' ', '')} " f"{self.editor_project.name.get()} " "Scale Block Analysis (Graph).png" - ) + ).strip() output_path = os.path.join( - os.path.dirname(self.editor_project.path.get()), output_path.strip() + os.path.dirname(self.editor_project.path.get()), output_path ) self.fig.savefig(output_path) - self.editor_project.plot.set( - output_path - ) # store this path so we can find it later + # store this path so we can find it later + self.editor_project.plot.set(output_path) # update log output_path = ( f"{self.editor_project.numbers.get().replace(' ', '')} " f"{self.editor_project.name.get()} " "Scale Block Analysis (Log).txt" - ) + ).strip() output_path = os.path.join( - os.path.dirname(self.editor_project.path.get()), output_path.strip() + os.path.dirname(self.editor_project.path.get()), output_path ) - with open(output_path, "w") as file: + + with Path(output_path).open("w") as file: file.write(self.log_text.get("1.0", "end-1c")) self.editor_project.dump_json() diff --git a/scalewiz/components/live_plot.py b/scalewiz/components/live_plot.py index 4bad690..b38d4bd 100644 --- a/scalewiz/components/live_plot.py +++ b/scalewiz/components/live_plot.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import time import typing from tkinter import ttk @@ -31,7 +30,7 @@ def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: 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_facecolor("w") # white # 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) @@ -40,9 +39,10 @@ def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: 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 + interval = round(handler.project.interval_seconds.get() * 1000) # ms self.ani = FuncAnimation(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 @@ -50,26 +50,20 @@ def animate(self, interval: float) -> None: # 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), - ) + readings = list(self.handler.readings.queue) + if len(readings) > 0: + 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 + for reading in readings: + pump1.append(reading.pump1) + pump2.append(reading.pump2) + 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) diff --git a/scalewiz/components/test_handler_view.py b/scalewiz/components/test_handler_view.py index d3c7c02..cb0389f 100644 --- a/scalewiz/components/test_handler_view.py +++ b/scalewiz/components/test_handler_view.py @@ -47,13 +47,14 @@ def __init__(self, parent: ttk.Frame, handler: TestHandler) -> 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.handler.is_running.trace_add("write", self.build) + self.handler.is_done.trace_add("write", self.build) self.build() self.poll_log_queue() - def build(self) -> None: + def build(self, *args) -> None: """Builds the UI, destroying any currently existing widgets.""" + LOGGER.info("%s: rebuilding", self.handler.name) for child in self.winfo_children(): child.destroy() @@ -195,7 +196,7 @@ def build(self) -> None: ) 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 + ent, text="Toggle Details", command=self.update_details_view ) self.start_button.grid(row=0, column=0) @@ -226,6 +227,7 @@ def build(self) -> None: self.update_test_type() self.update_start_button() self.update_devices_list() + self.update_input_frame() # methods to update local state ---------------------------------------------------- @@ -258,7 +260,7 @@ def update() -> None: # after here to prevent race conditions self.after(1, update) - def update_input_frame(self, *args) -> None: + def update_input_frame(self) -> None: """Disables widgets in the input frame if a Test is running.""" if self.handler.is_running.get(): for widget in self.inputs: @@ -267,14 +269,14 @@ def update_input_frame(self, *args) -> None: for widget in self.inputs: widget.configure(state="normal") - def update_start_button(self, *args) -> None: + def update_start_button(self) -> 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: + def update_test_type(self) -> 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() @@ -287,7 +289,7 @@ def update_test_type(self, *args) -> None: 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: + def update_details_view(self) -> None: """Updates the details view across all TestHandlerViews.""" is_visible = bool() # check if the plot is gridded @@ -295,10 +297,9 @@ def update_plot_visible(self) -> None: if self.plot_frame.grid_info() != {}: is_visible = True - + # we do this operation on ever tab so the window size makes sense for tab in self.parent.tabs(): this = self.nametowidget(tab) - print(type(this)) 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) @@ -309,7 +310,7 @@ def update_plot_visible(self) -> None: 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.""" + """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) @@ -317,11 +318,12 @@ def poll_log_queue(self) -> None: break else: self.display(record) - self.after(100, self.poll_log_queue) + interval = round(self.handler.project.interval_seconds.get() * 1000) + self.after(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(tk.END, msg + "\n") # last arg is for the tag + self.log_text.insert(tk.END, msg + "\n") 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..94f26cb 100644 --- a/scalewiz/helpers/configuration.py +++ b/scalewiz/helpers/configuration.py @@ -134,8 +134,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: diff --git a/scalewiz/models/test.py b/scalewiz/models/test.py index 1b6b23a..5270bfb 100644 --- a/scalewiz/models/test.py +++ b/scalewiz/models/test.py @@ -35,7 +35,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 @@ -52,6 +52,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(), @@ -64,7 +77,7 @@ 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: @@ -79,14 +92,23 @@ def load_json(self, obj: dict[str, Union[bool, float, int, str]]) -> None: 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") + readings = obj.get("readings") + for reading in readings: + self.readings.append( + Reading( + pump1=reading.get("pump 1"), + pump2=reading.get("pump 2"), + average=reading.get("average"), + elapsedMin=reading.get("elapsedMin"), + ) + ) self.update_obs_baseline() def get_readings(self) -> list[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] - # return [getattr(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.""" @@ -96,8 +118,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.""" @@ -117,5 +143,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... - pass + except IndexError as err: # sometimes this spaghets on empty projects... + LOGGER.exception(err) # just pass and move on diff --git a/todo b/todo index 429695d..5e106db 100644 --- a/todo +++ b/todo @@ -1,7 +1,29 @@ -#7 refactor state management in the test handler -#9 port over the old chlorides / ppm calculators -#1 refactor TestHandlerView +bugs +---- -'add system' -> 'system' > 'add new', 'remove current' +- the LivePlot currently seems rather unreliable + - this may be a recently introduced bug from matplotlib itself + - may need to open an issue upstream if it isn't my fault + related: + - current calls to matplotlib api (LivePlot, EvaluationFrame.plot) are messy -refactor scoring out of eval window +refactoring +----------- + +- refactor state management in the test handler (is_running, is_done....) +- refactor TestHandlerView + - see above -- probably better to just brute force with calls to build than to do all + the current tkVar tracing sillyness +- we have a dep. on Pandas for one little call in export_csv -- could be worked around + +updates / new features +---------------------- + +- menubar: + - 'add system' -> 'system' > 'add new', 'remove current' +- refactor scoring out of eval window -- deserves its own helper func + +low prio +-------- + +- port over the old chlorides / ppm calculators From 3335d6d21b984bdb7d75becf860ec7a0ae47691a Mon Sep 17 00:00:00 2001 From: teauxfu Date: Thu, 13 May 2021 09:33:36 -0500 Subject: [PATCH 03/49] fix typo --- scalewiz/models/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scalewiz/models/test.py b/scalewiz/models/test.py index 5270bfb..731310d 100644 --- a/scalewiz/models/test.py +++ b/scalewiz/models/test.py @@ -61,7 +61,7 @@ def to_dict(self) -> dict[str, Union[bool, float, int, str]]: "pump 1": reading.pump1, "pump 2": reading.pump2, "average": reading.average, - "elapsedMin:": reading.elapsedMin, + "elapsedMin": reading.elapsedMin, } ) From 43ca4aedc922e6c4a219897326f08dfe369e71c7 Mon Sep 17 00:00:00 2001 From: teauxfu Date: Thu, 13 May 2021 14:17:34 -0500 Subject: [PATCH 04/49] live plot still acting weird --- scalewiz/components/evaluation_window.py | 6 +- scalewiz/components/main_frame.py | 2 +- scalewiz/components/menu_bar.py | 38 ++++------ scalewiz/components/test_evaluation_row.py | 8 ++- scalewiz/components/test_handler_view.py | 82 +++++++--------------- scalewiz/models/project.py | 8 ++- scalewiz/models/test.py | 9 ++- scalewiz/models/test_handler.py | 20 ++---- todo | 2 + 9 files changed, 73 insertions(+), 102 deletions(-) diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index 5a5ce6b..eb170cb 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -20,6 +20,8 @@ from scalewiz.models.project import Project if typing.TYPE_CHECKING: + from typing import List + from scalewiz.models.test import Test from scalewiz.models.test_handler import TestHandler @@ -50,8 +52,8 @@ def __init__(self, handler: TestHandler) -> None: # matplotlib uses these later self.fig, self.axis, self.canvas = None, None, None self.plot_frame: ttk.Frame = None # this gets destroyed in plot() - self.trials: list[Test] = [] - self.blanks: list[Test] = [] + self.trials: List[Test] = [] + self.blanks: List[Test] = [] self.build() def render(self, label: tk.Widget, entry: tk.Widget, row: int) -> None: diff --git a/scalewiz/components/main_frame.py b/scalewiz/components/main_frame.py index d157c7d..a4e103b 100644 --- a/scalewiz/components/main_frame.py +++ b/scalewiz/components/main_frame.py @@ -23,7 +23,7 @@ def __init__(self, parent: ttk.Frame) -> None: def build(self) -> None: """Build the UI.""" - MenuBar(self) # this will apply itself to the current Toplevel + MenuBar(self) self.tab_control = ttk.Notebook(self) self.tab_control.grid(sticky="nsew") self.add_handler() diff --git a/scalewiz/components/menu_bar.py b/scalewiz/components/menu_bar.py index 5b4edb3..2fa7fca 100644 --- a/scalewiz/components/menu_bar.py +++ b/scalewiz/components/menu_bar.py @@ -34,17 +34,14 @@ def __init__(self, parent: tk.Frame) -> None: 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.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="Help", command=show_help) + menubar.add_command(label="About", command=self.about) - # menubar.add_command(label="Debug", command=self._debug) + menubar.add_command(label="Debug", command=self._debug) self.main_frame.winfo_toplevel().configure(menu=menubar) @@ -87,22 +84,17 @@ def spawn_rinse(self) -> None: 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" + ( + "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 + ), + ) - "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() + 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/test_evaluation_row.py b/scalewiz/components/test_evaluation_row.py index 54b5d84..875b46e 100644 --- a/scalewiz/components/test_evaluation_row.py +++ b/scalewiz/components/test_evaluation_row.py @@ -2,10 +2,12 @@ from __future__ import annotations import tkinter as tk -import typing from tkinter import messagebox, ttk +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import List -if typing.TYPE_CHECKING: from scalewiz.models.project import Project from scalewiz.models.test import Test @@ -27,7 +29,7 @@ def __init__( def build(self) -> None: """Make the UI.""" - cols: list[tk.Widget] = [] + cols: List[tk.Widget] = [] # col 0 - name cols.append(ttk.Label(self.parent, textvariable=self.test.name)) # col 1 - label diff --git a/scalewiz/components/test_handler_view.py b/scalewiz/components/test_handler_view.py index cb0389f..3d5792e 100644 --- a/scalewiz/components/test_handler_view.py +++ b/scalewiz/components/test_handler_view.py @@ -42,13 +42,13 @@ def __init__(self, parent: ttk.Frame, handler: TestHandler) -> 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.plot_frame: LivePlot = 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.build) - self.handler.is_done.trace_add("write", self.build) + # self.handler.is_running.trace_add("write", self.build) + # self.handler.is_done.trace_add("write", self.build) self.build() self.poll_log_queue() @@ -191,38 +191,36 @@ def build(self, *args) -> None: # row 1 ------------------------------------------------------------------------ ent = ttk.Frame(self) + ent.grid_columnconfigure(0, weight=1) + ent.grid_columnconfigure(1, weight=1) + ent.grid_columnconfigure(2, weight=1) 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_details_view - ) - self.start_button.grid(row=0, column=0) - stop_button.grid(row=0, column=1) - details_button.grid(row=0, column=2) + self.start_button.grid(row=0, column=0, sticky="ew") + stop_button.grid(row=0, column=2, sticky="ew") - 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) + progressbar = ttk.Progressbar(ent, variable=self.handler.progress) + progressbar.grid(row=1, columnspan=3, sticky="nwe") + + ent.grid(row=1, column=0, padx=5, pady=1, sticky="nwe") # rows 0-1 --------------------------------------------------------------------- # close all pyplots to prevent memory leak + plt.close("all") self.grid_columnconfigure(1, weight=1) # let it grow self.grid_rowconfigure(1, weight=1) - plt.close("all") self.plot_frame = LivePlot(self, self.handler) + self.plot_frame.grid(row=0, column=1, rowspan=3) # 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.log_frame.grid(row=2, column=0, sticky="ew") self.update_test_type() self.update_start_button() @@ -234,31 +232,25 @@ def build(self, *args) -> None: 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) + label.grid(row=row, column=0, sticky="ne") + entry.grid(row=row, column=1, sticky="new", 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.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) + 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 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) + if "None found" not in self.devices_list: + LOGGER.debug("%s found devices: %s", self.handler.name, self.devices_list) def update_input_frame(self) -> None: """Disables widgets in the input frame if a Test is running.""" @@ -289,26 +281,6 @@ def update_test_type(self) -> None: self.render(self.trial_label_frame, self.trial_entry_frame, 3) LOGGER.debug("%s: changed to Trial mode", self.handler.name) - def update_details_view(self) -> None: - """Updates the details view across all TestHandlerViews.""" - is_visible = bool() - # check if the plot is gridded - print(type(self.plot_frame)) - - if self.plot_frame.grid_info() != {}: - is_visible = True - # we do this operation on ever tab so the window size makes sense - for tab in self.parent.tabs(): - this = self.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 on an interval if there is a new message in the queue to display.""" while True: diff --git a/scalewiz/models/project.py b/scalewiz/models/project.py index beb9d64..9c7e75a 100644 --- a/scalewiz/models/project.py +++ b/scalewiz/models/project.py @@ -6,11 +6,15 @@ import logging import os import tkinter as tk +from typing import TYPE_CHECKING from scalewiz.helpers.configuration import get_config, 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 +24,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() @@ -73,7 +77,7 @@ def set_defaults(self) -> None: # 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.get("recents").get("analyst")) def add_traces(self) -> None: """Adds tkVar traces where needed. Must be cleaned up with remove_traces.""" diff --git a/scalewiz/models/test.py b/scalewiz/models/test.py index 731310d..fca7c94 100644 --- a/scalewiz/models/test.py +++ b/scalewiz/models/test.py @@ -6,7 +6,10 @@ import logging import tkinter as tk from dataclasses import dataclass -from typing import Union +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import List, Union LOGGER = logging.getLogger("scalewiz") @@ -35,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[Reading] = [] # 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 @@ -104,7 +107,7 @@ def load_json(self, obj: dict[str, Union[bool, float, int, str]]) -> None: ) self.update_obs_baseline() - def get_readings(self) -> list[int]: + def get_readings(self) -> List[int]: """Returns a list of the pump_to_score's pressure readings.""" pump = self.pump_to_score.get() pump = pump.replace(" ", "") # legacy accomodation for spaces in keys diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index 067b3f8..7f3ed31 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -15,6 +15,7 @@ from py_hplc import NextGenPump +from scalewiz.components.test_handler_view import TestHandlerView from scalewiz.models.project import Project from scalewiz.models.test import Reading, Test @@ -23,8 +24,6 @@ from tkinter.scrolledtext import ScrolledText from typing import List - from scalewiz.components.test_handler_view import TestHandlerView - class TestHandler: """Handles a Test.""" @@ -39,7 +38,7 @@ def __init__(self, name: str = "Nemo") -> None: 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.editors: List[tk.Widget] = [] # list of views displaying the project self.max_readings: int = None # max # of readings to collect self.max_psi_1: int = None self.max_psi_2: int = None @@ -53,7 +52,6 @@ def __init__(self, name: str = "Nemo") -> None: self.stop_requested: Event = Event() 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.pump1: NextGenPump = None self.pump2: NextGenPump = None @@ -75,7 +73,7 @@ def can_run(self) -> bool: and not self.stop_requested.is_set() ) - def load_project(self, path: str = None, loaded: list[str] = []) -> None: + 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 @@ -134,6 +132,7 @@ def start_test(self) -> None: self.stop_requested.clear() self.is_done.set(False) self.is_running.set(True) + self.rebuild_views() self.update_log_handler() self.logger.info("submitting") self.pool.submit(self.take_readings) @@ -148,9 +147,7 @@ def take_readings(self) -> None: rinse_start = monotonic() sleep(step) for i in range(100): - elapsed = monotonic() - rinse_start if self.can_run(): - self.elapsed_str.set(f"{uptake - elapsed:.1f} s") self.progress.set(i) sleep(step - ((monotonic() - rinse_start) % step)) else: @@ -182,7 +179,6 @@ def take_readings(self) -> None: 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)) if psi1 > self.max_psi_1: @@ -267,14 +263,11 @@ def new_test(self) -> None: 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() ) - # rebuild the TestHandlerView - if self.view is not None: - self.view.build() + self.rebuild_views() def rebuild_views(self) -> None: """Rebuild all open Widgets that could modify the Project file.""" @@ -284,7 +277,8 @@ def rebuild_views(self) -> None: widget.build(reload=True) else: # clean up as we go self.editors.remove(widget) - self.view.build() + if isinstance(self.view, TestHandlerView): + self.view.build() self.logger.info("Rebuilt all view widgets") def update_log_handler(self) -> None: diff --git a/todo b/todo index 5e106db..6923e96 100644 --- a/todo +++ b/todo @@ -21,6 +21,8 @@ updates / new features - 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 - refactor scoring out of eval window -- deserves its own helper func low prio From d916f6f772e32dc8bf3333fd059a9a4ef231937e Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Fri, 14 May 2021 09:18:58 -0500 Subject: [PATCH 05/49] ok --- scalewiz/components/live_plot.py | 1 + scalewiz/components/test_handler_view.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scalewiz/components/live_plot.py b/scalewiz/components/live_plot.py index b38d4bd..57c14e6 100644 --- a/scalewiz/components/live_plot.py +++ b/scalewiz/components/live_plot.py @@ -27,6 +27,7 @@ def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: self.handler = handler # matplotlib objects + plt.close("all") fig, self.axis = plt.subplots(figsize=(5, 3), dpi=100) fig.patch.set_facecolor("#FAFAFA") self.axis.grid(color="darkgrey", alpha=0.65, linestyle="-") diff --git a/scalewiz/components/test_handler_view.py b/scalewiz/components/test_handler_view.py index 3d5792e..b580ade 100644 --- a/scalewiz/components/test_handler_view.py +++ b/scalewiz/components/test_handler_view.py @@ -209,10 +209,11 @@ def build(self, *args) -> None: # rows 0-1 --------------------------------------------------------------------- # close all pyplots to prevent memory leak - plt.close("all") + self.grid_columnconfigure(1, weight=1) # let it grow self.grid_rowconfigure(1, weight=1) self.plot_frame = LivePlot(self, self.handler) + print(self.winfo_children()) self.plot_frame.grid(row=0, column=1, rowspan=3) # row 2 ------------------------------------------------------------------------ self.log_frame = ttk.Frame(self) From 5999538f280a45b8a0f9959342a0396246dd6375 Mon Sep 17 00:00:00 2001 From: teauxfu Date: Fri, 14 May 2021 14:21:21 -0500 Subject: [PATCH 06/49] most of handlerview refactor done do liveplot next :( --- CHANGELOG.rst | 2 +- COPYING | 2 +- README.rst | 2 +- pyproject.toml | 2 +- scalewiz/components/devices_comboboxes.py | 76 ++++++ scalewiz/components/evaluation_window.py | 4 +- scalewiz/components/live_plot.py | 14 +- scalewiz/components/test_controls.py | 68 ++++++ scalewiz/components/test_handler_view.py | 279 ++-------------------- scalewiz/components/test_info_widget.py | 111 +++++++++ scalewiz/models/project.py | 1 + scalewiz/models/test_handler.py | 33 ++- 12 files changed, 322 insertions(+), 272 deletions(-) create mode 100644 scalewiz/components/devices_comboboxes.py create mode 100644 scalewiz/components/test_controls.py create mode 100644 scalewiz/components/test_info_widget.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d3dd12d..77712f9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -19,7 +19,7 @@ Changed - updated :code:`EvaluationFrame` to handle the :code:`Reading` class - updated the :code:`Test` object model to handle the :code:`Reading` class - minor performance buff to the :code:`LivePlot` component -- refactoring the :code:`TestHandlerView` to be less tempermental +- overhaul the :code:`TestHandlerView` to be less tempermental - misc. code cleanup 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..899f30f 100644 --- a/README.rst +++ b/README.rst @@ -9,7 +9,7 @@ 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 diff --git a/pyproject.toml b/pyproject.toml index 4216228..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" diff --git a/scalewiz/components/devices_comboboxes.py b/scalewiz/components/devices_comboboxes.py new file mode 100644 index 0000000..3a30ba1 --- /dev/null +++ b/scalewiz/components/devices_comboboxes.py @@ -0,0 +1,76 @@ +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 + +LOGGER: Logger = getLogger("scalewiz") + + +class DeviceBoxes(ttk.Frame): + """A widget for selecting devices.""" + + def __init__( + self, parent: ttk.Frame, dev1: tk.StringVar, dev2: tk.StringVar + ) -> None: + super().__init__(parent) + self.parent: ttk.Frame = parent + self.devices_list: List[str] = [] + self.dev1: tk.StringVar = dev1 + self.dev2: tk.StringVar = 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") + self.device1_entry = ttk.Combobox( + self, + width=15, + textvariable=self.dev1, + values=self.devices_list, + validate="all", + validatecommand=self.update_devices_list, + ) + self.device2_entry = ttk.Combobox( + self, + width=15, + textvariable=self.dev2, + values=self.devices_list, + validate="all", + validatecommand=self.update_devices_list, + ) + # 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 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.parent.handler.name, self.devices_list + ) diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index eb170cb..be5da8d 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -239,8 +239,8 @@ def save(self) -> None: file.write(self.log_text.get("1.0", "end-1c")) self.editor_project.dump_json() - - self.build(reload=True) + self.handler.rebuild_views() + self.handler.load_project(self.editor_project.path.get()) def score(self, *args) -> None: """Updates the result for every Test in the Project. diff --git a/scalewiz/components/live_plot.py b/scalewiz/components/live_plot.py index 57c14e6..2a185b1 100644 --- a/scalewiz/components/live_plot.py +++ b/scalewiz/components/live_plot.py @@ -27,15 +27,14 @@ def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: self.handler = handler # matplotlib objects - plt.close("all") + # plt.close("all") 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") # white + # 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) @@ -52,12 +51,17 @@ def animate(self, interval: float) -> None: if self.handler.is_running.get() and not self.handler.is_done.get(): # data access here 😳 readings = list(self.handler.readings.queue) - if len(readings) > 0: + if self.handler.readings.qsize() > 0: LOGGER.debug("%s: Drawing a new plot ...", self.handler.name) with plt.style.context("bmh"): self.axis.clear() + 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, None), auto=True) + self.axis.set_ylim((0, None), auto=True) + self.axis.margins(0) pump1 = [] pump2 = [] elapsed = [] # we will share this series as an axis diff --git a/scalewiz/components/test_controls.py b/scalewiz/components/test_controls.py new file mode 100644 index 0000000..2f52c14 --- /dev/null +++ b/scalewiz/components/test_controls.py @@ -0,0 +1,68 @@ +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.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.get(): + start_btn.configure(text="New", command=self.handler.new_test) + else: + start_btn.configure(text="Start", command=self.handler.start_test) + start_btn.grid(row=0, column=0, sticky="ew") + # row 0 col 1 + stop_btn = ttk.Button(self, text="Stop", command=self.handler.request_stop) + stop_btn.grid(row=0, column=1, sticky="ew") + progressbar = ttk.Progressbar(self, variable=self.handler.progress) + 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) + interval = round(self.handler.project.interval_seconds.get() * 1000) + self.after(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", msg) + self.log_text.insert("end", "\n") + self.log_text.configure(state="disabled") + self.log_text.yview("end") # scroll to bottom diff --git a/scalewiz/components/test_handler_view.py b/scalewiz/components/test_handler_view.py index b580ade..4099c73 100644 --- a/scalewiz/components/test_handler_view.py +++ b/scalewiz/components/test_handler_view.py @@ -2,21 +2,16 @@ 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.devices_comboboxes import DeviceBoxes from scalewiz.components.live_plot import LivePlot -from scalewiz.helpers.validation import can_be_pos_float +from scalewiz.components.test_controls import TestControls +from scalewiz.components.test_info_widget import TestInfo if typing.TYPE_CHECKING: - from typing import List from scalewiz.models.test_handler import TestHandler @@ -30,228 +25,41 @@ 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: LivePlot = 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.build) - # self.handler.is_done.trace_add("write", self.build) + self.handler.is_done.trace_add("write", self.build) self.build() - self.poll_log_queue() def build(self, *args) -> None: """Builds the UI, destroying any currently existing widgets.""" LOGGER.info("%s: rebuilding", self.handler.name) 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") - + self.grid_columnconfigure(0, weight=1) # 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 ------------------------------------------------------------- + dev_ent = DeviceBoxes(self, self.handler.dev1, self.handler.dev2) + dev_ent.grid(row=0, column=0, sticky="new") # row 1 ------------------------------------------------------------------------ - ent = ttk.Frame(self) - ent.grid_columnconfigure(0, weight=1) - ent.grid_columnconfigure(1, weight=1) - ent.grid_columnconfigure(2, weight=1) - 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) - - self.start_button.grid(row=0, column=0, sticky="ew") - stop_button.grid(row=0, column=2, sticky="ew") - - progressbar = ttk.Progressbar(ent, variable=self.handler.progress) - progressbar.grid(row=1, columnspan=3, sticky="nwe") - - ent.grid(row=1, column=0, padx=5, pady=1, sticky="nwe") + 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") - # rows 0-1 --------------------------------------------------------------------- - # close all pyplots to prevent memory leak - - self.grid_columnconfigure(1, weight=1) # let it grow - self.grid_rowconfigure(1, weight=1) - self.plot_frame = LivePlot(self, self.handler) - print(self.winfo_children()) - self.plot_frame.grid(row=0, column=1, rowspan=3) # 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.log_frame.grid(row=2, column=0, sticky="ew") - - self.update_test_type() - self.update_start_button() - self.update_devices_list() - self.update_input_frame() - - # 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="ne") - entry.grid(row=row, column=1, sticky="new", 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 - self.devices_list = sorted([i.device for i in list_ports.comports()]) - if len(self.devices_list) < 1: - self.devices_list = ["None found"] + test_info = TestInfo(self, self.handler.test) + test_info.grid(row=2, column=0, sticky="new") - self.device1_entry.configure(values=self.devices_list) - self.device2_entry.configure(values=self.devices_list) + # row 3------------------------------------------------------------------------- + test_controls = TestControls(self, self.handler) + test_controls.grid(row=3, column=0, sticky="nsew") - 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) + # row 0 col 1 ------------------------------------------------------------------ + plt_frm = ttk.Frame(self) + plot = LivePlot(plt_frm, self.handler) + 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.""" @@ -261,42 +69,3 @@ def update_input_frame(self) -> None: else: for widget in self.inputs: widget.configure(state="normal") - - def update_start_button(self) -> 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) -> 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 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 queue.Empty: - break - else: - self.display(record) - interval = round(self.handler.project.interval_seconds.get() * 1000) - self.after(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(tk.END, msg + "\n") - self.log_text.configure(state="disabled") - self.log_text.yview(tk.END) # scroll to bottom diff --git a/scalewiz/components/test_info_widget.py b/scalewiz/components/test_info_widget.py new file mode 100644 index 0000000..d80bda7 --- /dev/null +++ b/scalewiz/components/test_info_widget.py @@ -0,0 +1,111 @@ +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 import Test + + +LOGGER: Logger = getLogger("scalewiz") + + +class TestInfo(ttk.Frame): + """A widget for inputting Test information.""" + + def __init__(self, parent: tk.Widget, test: Test) -> None: + super().__init__(parent) + self.test: Test = test + self.build() + + def build(self) -> None: + """Builds the widget.""" + for child in self.winfo_children(): + child.destroy() + + self.grid_columnconfigure(1, weight=1) + + 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.test.is_blank, + value=True, + command=self.build, + ) + blank_btn.grid(row=0, column=0, sticky="e", padx=25) + trial_btn = ttk.Radiobutton( + radio_frm, + text="Trial", + variable=self.test.is_blank, + value=False, + command=self.build, + ) + 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.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.test.name) + 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") + notes_ent = ttk.Entry(test_frm, textvariable=self.test.notes) + 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.test.chemical) + 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.test.rate, + from_=1, + to=999999, + validate="key", + validatecommand=(vcmd, "%P"), + ) + 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.test.clarity, + ) + 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") + notes_ent = ttk.Entry(test_frm, textvariable=self.test.notes) + notes_ent.grid(row=3, column=1, sticky="ew") + + test_frm.grid(row=1, column=0, columnspan=2, sticky="ew") diff --git a/scalewiz/models/project.py b/scalewiz/models/project.py index 9c7e75a..395a9c7 100644 --- a/scalewiz/models/project.py +++ b/scalewiz/models/project.py @@ -189,6 +189,7 @@ def load_json(self, path: str) -> None: self.temperature.set(params.get("temperature")) self.limit_psi.set(params.get("limitPSI")) self.limit_minutes.set(params.get("limitMin")) + LOGGER.warning("set limit min to %s", self.limit_minutes.get()) self.interval_seconds.set(params.get("interval")) self.flowrate.set(params.get("flowrate")) self.uptake_seconds.set(params.get("uptake")) diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index 7f3ed31..fdf9c49 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -8,6 +8,7 @@ import typing from concurrent.futures import ThreadPoolExecutor from datetime import date +from pathlib import Path from queue import Queue from threading import Event from time import monotonic, sleep, time @@ -21,7 +22,6 @@ if typing.TYPE_CHECKING: from tkinter import ttk - from tkinter.scrolledtext import ScrolledText from typing import List @@ -44,7 +44,6 @@ def __init__(self, name: str = "Nemo") -> 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.log_queue: Queue[str] = Queue() # view pulls from this queue self.dev1 = tk.StringVar() @@ -69,7 +68,7 @@ def can_run(self) -> bool: 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 self.readings.qsize() < self.max_readings and not self.stop_requested.is_set() ) @@ -100,6 +99,7 @@ def load_project(self, path: str = None, loaded: List[str] = []) -> None: else: self.project = Project() self.project.load_json(path) + self.new_test() self.rebuild_views() self.logger.info("Loaded %s", self.project.name.get()) @@ -110,7 +110,7 @@ def start_test(self) -> None: 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) @@ -118,6 +118,10 @@ 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) @@ -179,7 +183,14 @@ def take_readings(self) -> None: self.readings.put(reading) self.elapsed_min.set(minutes_elapsed) - self.progress.set(round(len(self.readings.queue) / self.max_readings * 100)) + prog = round((self.readings.qsize() / self.max_readings) * 100) + self.logger.warning( + "qsize is %s max is %s prog is %s", + self.readings.qsize(), + self.max_readings, + prog, + ) + self.progress.set(prog) if psi1 > self.max_psi_1: self.max_psi_1 = psi1 @@ -263,9 +274,20 @@ def new_test(self) -> None: self.is_running.set(False) self.is_done.set(False) self.progress.set(0) + self.logger.warning( + "currently loaded %s with lim min", + self.project.name.get(), + self.project.limit_minutes.get(), + ) + self.logger.warning( + "lim_min is %s and interval is %s", + self.project.limit_minutes.get(), + self.project.interval_seconds.get(), + ) self.max_readings = round( self.project.limit_minutes.get() * 60 / self.project.interval_seconds.get() ) + self.logger.warning("max readings is %s", self.max_readings) self.rebuild_views() @@ -307,4 +329,3 @@ def update_log_handler(self) -> None: 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 From 1cf8c7089d69a4e305b93da0b13c159c3d670951 Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Mon, 17 May 2021 16:42:00 -0500 Subject: [PATCH 07/49] cleaning, updating to Pathlib operations --- CHANGELOG.rst | 5 +- scalewiz/components/evaluation_window.py | 44 +++++++-------- scalewiz/components/log_window.py | 19 ++++--- scalewiz/components/main_frame.py | 17 +++--- scalewiz/components/menu_bar.py | 11 ++-- scalewiz/components/project_window.py | 4 +- scalewiz/components/scalewiz.py | 50 +++++++++++------ scalewiz/helpers/configuration.py | 2 +- scalewiz/helpers/export_csv.py | 25 +++++---- scalewiz/helpers/set_icon.py | 7 ++- scalewiz/models/logger.py | 31 ----------- scalewiz/models/project.py | 21 ++++--- scalewiz/models/test_handler.py | 71 ++++++++++-------------- 13 files changed, 139 insertions(+), 168 deletions(-) delete mode 100644 scalewiz/models/logger.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 77712f9..f0dec6a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,11 +15,12 @@ Versioning `__. Changed ~~~~~~~ -- updated some path operations in :code:`EvaluationFrame` to use the :code:`pathlib.Path` API - updated :code:`EvaluationFrame` to handle the :code:`Reading` class - updated the :code:`Test` object model to handle the :code:`Reading` class +- overhaul the :code:`TestHandlerView` to be better oragnized and less bad +- replace all :code:`os.path` operations with fancy :code:`pathlib.Path` operations - minor performance buff to the :code:`LivePlot` component -- overhaul the :code:`TestHandlerView` to be less tempermental +- minor performance buffs; using sets/tuples over lists where appropriate/able - misc. code cleanup diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index be5da8d..a05a9b7 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -2,10 +2,11 @@ from __future__ import annotations -import os +import logging import time import tkinter as tk import typing +from logging import getLogger from pathlib import Path from tkinter import font, ttk @@ -20,13 +21,13 @@ from scalewiz.models.project import Project if typing.TYPE_CHECKING: - from typing import List + from typing import Set from scalewiz.models.test import Test from scalewiz.models.test_handler import TestHandler -COLORS = [ +COLORS = ( "orange", "blue", "red", @@ -37,7 +38,9 @@ "darkcyan", "maroon", "darkslategrey", -] +) + +LOGGER = getLogger("scalewiz") class EvaluationWindow(tk.Toplevel): @@ -52,8 +55,8 @@ def __init__(self, handler: TestHandler) -> None: # matplotlib uses these later self.fig, self.axis, self.canvas = None, None, None self.plot_frame: ttk.Frame = None # this gets destroyed in plot() - self.trials: List[Test] = [] - self.blanks: List[Test] = [] + self.trials: Set[Test] = set() + self.blanks: Set[Test] = set() self.build() def render(self, label: tk.Widget, entry: tk.Widget, row: int) -> None: @@ -108,9 +111,9 @@ def build(self, reload: bool = False) -> None: # filter through blanks and trials for test in self.editor_project.tests: if test.is_blank.get(): - self.blanks.append(test) + self.blanks.add(test) else: - self.trials.append(test) + self.trials.add(test) tk.Label(tests_frame, text="Blanks:", font=bold_font).grid( row=1, column=0, sticky="w", padx=3, pady=1 @@ -214,28 +217,24 @@ def plot(self) -> None: 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" - ).strip() - output_path = os.path.join( - os.path.dirname(self.editor_project.path.get()), output_path ) - self.fig.savefig(output_path) - # store this path so we can find it later - self.editor_project.plot.set(output_path) + parent_dir = Path(self.editor_project.path.get()).parent + plot_output = Path(parent_dir, plot_output).resolve() + self.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" ).strip() - output_path = os.path.join( - os.path.dirname(self.editor_project.path.get()), output_path - ) + log_output = Path(parent_dir, log_output).resolve() - with Path(output_path).open("w") as file: + with log_output.open("w") as file: file.write(self.log_text.get("1.0", "end-1c")) self.editor_project.dump_json() @@ -291,7 +290,7 @@ def score(self, *args) -> None: log.append("") areas_over_blanks.append(area) - if len(areas_over_blanks) == 0: + if len(areas_over_blanks) < 1: return # get protectable area avg_blank_area = round(sum(areas_over_blanks) / len(areas_over_blanks)) @@ -350,6 +349,5 @@ def to_log(self, log: list[str]) -> None: self.log_text.configure(state="normal") self.log_text.delete(1.0, "end") for msg in log: - self.log_text.insert("end", msg) - self.log_text.insert("end", "\n") + self.log_text.insert("end", "".join((msg, "/n"))) self.log_text.configure(state="disabled") diff --git a/scalewiz/components/log_window.py b/scalewiz/components/log_window.py index 1761d10..8c7c6ae 100644 --- a/scalewiz/components/log_window.py +++ b/scalewiz/components/log_window.py @@ -8,25 +8,27 @@ from tkinter.scrolledtext import ScrolledText from scalewiz.helpers.set_icon import set_icon -from scalewiz.models.logger import Logger if typing.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: + def __init__(self, core: ScaleWiz) -> None: tk.Toplevel.__init__(self) - self.winfo_toplevel().title("Log Window") + self.log_queue = core.log_queue + self.title("Log Window") # replace the window closing behavior with withdrawing instead 🐱‍👤 - self.winfo_toplevel().protocol( + self.protocol( "WM_DELETE_WINDOW", lambda: self.winfo_toplevel().withdraw() ) - self.log_queue = logger.log_queue self.build() def build(self) -> None: @@ -34,14 +36,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) @@ -61,7 +62,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/main_frame.py b/scalewiz/components/main_frame.py index a4e103b..01af7f5 100644 --- a/scalewiz/components/main_frame.py +++ b/scalewiz/components/main_frame.py @@ -23,7 +23,7 @@ def __init__(self, parent: ttk.Frame) -> None: def build(self) -> None: """Build the UI.""" - MenuBar(self) + self.winfo_toplevel().configure(menu=MenuBar(self).menubar) self.tab_control = ttk.Notebook(self) self.tab_control.grid(sticky="nsew") self.add_handler() @@ -41,17 +41,16 @@ def add_handler(self) -> None: # 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")) + handler.load_project(config.get("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 + if widget.handler.is_running.get() and 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 index 2fa7fca..d155453 100644 --- a/scalewiz/components/menu_bar.py +++ b/scalewiz/components/menu_bar.py @@ -4,6 +4,7 @@ import logging import tkinter as tk +from pathlib import Path from tkinter.messagebox import showinfo from scalewiz.components.evaluation_window import EvaluationWindow @@ -42,8 +43,8 @@ def __init__(self, parent: tk.Frame) -> None: menubar.add_command(label="About", command=self.about) menubar.add_command(label="Debug", command=self._debug) - - self.main_frame.winfo_toplevel().configure(menu=menubar) + self.menubar = menubar + # self.main_frame.winfo_toplevel().configure(menu=menubar) def spawn_editor(self) -> None: """Spawn a Toplevel for editing Projects.""" @@ -64,14 +65,14 @@ def spawn_evaluator(self) -> None: 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 = [] + currently_loaded = set() for tab in self.main_frame.tab_control.tabs(): widget = self.main_frame.nametowidget(tab) - currently_loaded.append(widget.handler.project.path.get()) + 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.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.handler.load_project(loaded=tuple(currently_loaded)) widget.build() def spawn_rinse(self) -> None: diff --git a/scalewiz/components/project_window.py b/scalewiz/components/project_window.py index 6f70c4b..93dea89 100644 --- a/scalewiz/components/project_window.py +++ b/scalewiz/components/project_window.py @@ -2,9 +2,9 @@ from __future__ import annotations -import os.path import tkinter as tk import typing +from pathlib import Path from tkinter import filedialog, ttk from scalewiz.components.project_info import ProjectInfo @@ -28,7 +28,7 @@ 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()): + if Path(handler.project.path.get()).is_file: self.editor_project.load_json(handler.project.path.get()) self.build() diff --git a/scalewiz/components/scalewiz.py b/scalewiz/components/scalewiz.py index c37a9d1..d97d71a 100644 --- a/scalewiz/components/scalewiz.py +++ b/scalewiz/components/scalewiz.py @@ -3,45 +3,59 @@ 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.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 + # parent.resizable(0, 0) # apparently this is a bad practice... # 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 - %(func)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/helpers/configuration.py b/scalewiz/helpers/configuration.py index 94f26cb..9aeca9e 100644 --- a/scalewiz/helpers/configuration.py +++ b/scalewiz/helpers/configuration.py @@ -15,7 +15,7 @@ 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: diff --git a/scalewiz/helpers/export_csv.py b/scalewiz/helpers/export_csv.py index 69b7c9c..30b9d7d 100644 --- a/scalewiz/helpers/export_csv.py +++ b/scalewiz/helpers/export_csv.py @@ -2,8 +2,8 @@ import json import logging -import os import time +from pathlib import Path from pandas import DataFrame @@ -45,18 +45,18 @@ def export_csv(project: Project) -> None: "plotPath": project.plot.get(), } # filter the blanks and trials to sort them - blanks = [ + blanks = { test for test in project.tests if test.include_on_report.get() and test.is_blank.get() - ] - trials = [ + } + trials = { test for test in project.tests 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,15 +69,16 @@ 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) - 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( diff --git a/scalewiz/helpers/set_icon.py b/scalewiz/helpers/set_icon.py index 94ed5f2..18a9d11 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,14 @@ 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")) except FileNotFoundError: LOGGER.error("Failed to set the icon") - if os.path.isfile(icon_path): + if icon_path.is_file: widget.winfo_toplevel().wm_iconbitmap(icon_path) # for windows, set the taskbar icon - if os.name == "nt": + if "nt" in os.name: 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 395a9c7..ce4c382 100644 --- a/scalewiz/models/project.py +++ b/scalewiz/models/project.py @@ -4,8 +4,8 @@ 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 @@ -123,7 +123,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": { @@ -141,7 +141,7 @@ 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: @@ -152,10 +152,10 @@ def dump_json(self, path: str = None) -> None: 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. @@ -163,7 +163,7 @@ def load_json(self, path: str) -> None: LOGGER.warning( "Opened a Project whose actual path didn't match its path property" ) - obj["info"]["path"] = path + obj["info"]["path"] = str(path) info = obj.get("info") self.customer.set(info.get("customer")) @@ -189,7 +189,6 @@ def load_json(self, path: str) -> None: self.temperature.set(params.get("temperature")) self.limit_psi.set(params.get("limitPSI")) self.limit_minutes.set(params.get("limitMin")) - LOGGER.warning("set limit min to %s", self.limit_minutes.get()) self.interval_seconds.set(params.get("interval")) self.flowrate.set(params.get("flowrate")) self.uptake_seconds.set(params.get("uptake")) @@ -197,6 +196,7 @@ def load_json(self, path: str) -> None: self.plot.set(obj.get("plot")) self.output_format.set(obj.get("outputFormat")) + self.tests.clear() for entry in obj.get("tests"): test = Test() test.load_json(entry) @@ -214,13 +214,12 @@ def remove_traces(self) -> None: 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_handler.py b/scalewiz/models/test_handler.py index fdf9c49..a5ded7b 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -2,12 +2,11 @@ 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 @@ -22,7 +21,7 @@ if typing.TYPE_CHECKING: from tkinter import ttk - from typing import List + from typing import List, Tuple class TestHandler: @@ -32,7 +31,7 @@ class TestHandler: def __init__(self, name: str = "Nemo") -> None: self.name = name - self.logger = logging.getLogger(f"scalewiz.{name}") + self.logger = getLogger(f"scalewiz.{name}") self.view: TestHandlerView = None self.project = Project() self.test: Test = None @@ -42,7 +41,7 @@ def __init__(self, name: str = "Nemo") -> None: self.max_readings: int = None # max # of readings to collect self.max_psi_1: int = None self.max_psi_2: int = None - self.log_handler: logging.FileHandler = None # handles logging to log window + self.log_handler: FileHandler = None # handles logging to log window # test handler view overwrites this attribute in the view's build() self.log_queue: Queue[str] = Queue() # view pulls from this queue @@ -72,31 +71,35 @@ def can_run(self) -> bool: and not self.stop_requested.is_set() ) - 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() + def load_project(self, path: str = None, loaded: Tuple[Path] = []) -> None: + """Opens a file dialog then loads the selected Project file. + `loaded` gets built from scratch every time it is passed in -- no need to update + """ if path is None: - path = os.path.abspath( + path = Path( filedialog.askopenfilename( initialdir='C:"', title="Select project file:", filetypes=[("JSON files", "*.json")], ) - ) + ).resolve() + else: + path = Path(path) # check that the dialog succeeded, the file exists, and isn't already loaded - if path != "" and os.path.isfile(path): + if path != "" and 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: + # 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() self.project = Project() self.project.load_json(path) self.new_test() @@ -184,12 +187,6 @@ def take_readings(self) -> None: self.readings.put(reading) self.elapsed_min.set(minutes_elapsed) prog = round((self.readings.qsize() / self.max_readings) * 100) - self.logger.warning( - "qsize is %s max is %s prog is %s", - self.readings.qsize(), - self.max_readings, - prog, - ) self.progress.set(prog) if psi1 > self.max_psi_1: @@ -226,6 +223,8 @@ def stop_test(self) -> None: self.is_done.set(True) self.logger.info("Test for %s has been stopped", self.test.name.get()) + for _ in range(3): + self.view.bell() def save_test(self) -> None: """Saves the test to the Project file in JSON format.""" @@ -274,21 +273,9 @@ def new_test(self) -> None: self.is_running.set(False) self.is_done.set(False) self.progress.set(0) - self.logger.warning( - "currently loaded %s with lim min", - self.project.name.get(), - self.project.limit_minutes.get(), - ) - self.logger.warning( - "lim_min is %s and interval is %s", - self.project.limit_minutes.get(), - self.project.interval_seconds.get(), - ) self.max_readings = round( self.project.limit_minutes.get() * 60 / self.project.interval_seconds.get() ) - self.logger.warning("max readings is %s", self.max_readings) - self.rebuild_views() def rebuild_views(self) -> None: @@ -306,22 +293,22 @@ def rebuild_views(self) -> None: 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) + parent_dir = Path(self.project.path.get()).resolve().parent + logs_dir = Path(parent_dir, "/logs") + if not logs_dir.is_dir: + logs_dir.mkdir() + log_path = Path(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.log_handler = FileHandler(log_path) - formatter = logging.Formatter( + formatter = Formatter( "%(asctime)s - %(thread)d - %(levelname)s - %(message)s", "%Y-%m-%d %H:%M:%S", ) 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()) From 5b51bb7f367bcfb8590f3ead5c27bd3154248b73 Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Mon, 17 May 2021 21:27:50 -0500 Subject: [PATCH 08/49] hanging from surface --- scalewiz/components/evaluation_window.py | 5 ++-- scalewiz/components/live_plot.py | 6 ++--- scalewiz/components/log_window.py | 23 ++++++++---------- scalewiz/components/main_frame.py | 2 +- scalewiz/components/project_info.py | 9 +++---- scalewiz/components/project_params.py | 6 ++--- scalewiz/components/project_report.py | 6 ++--- scalewiz/components/project_window.py | 15 ++++++------ scalewiz/components/rinse_window.py | 2 +- scalewiz/components/test_controls.py | 15 ++++++------ scalewiz/components/test_handler_view.py | 13 +++++----- scalewiz/models/project.py | 2 +- scalewiz/models/test_handler.py | 31 ++++++++++++------------ todo | 8 +++--- 14 files changed, 68 insertions(+), 75 deletions(-) diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index a05a9b7..bf4554c 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -2,13 +2,12 @@ from __future__ import annotations -import logging import time import tkinter as tk -import typing from logging import getLogger from pathlib import Path from tkinter import font, ttk +from typing import TYPE_CHECKING import matplotlib as mpl import matplotlib.pyplot as plt @@ -20,7 +19,7 @@ from scalewiz.helpers.set_icon import set_icon from scalewiz.models.project import Project -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from typing import Set from scalewiz.models.test import Test diff --git a/scalewiz/components/live_plot.py b/scalewiz/components/live_plot.py index 2a185b1..39026cb 100644 --- a/scalewiz/components/live_plot.py +++ b/scalewiz/components/live_plot.py @@ -3,8 +3,8 @@ from __future__ import annotations import logging -import typing from tkinter import ttk +from typing import TYPE_CHECKING import matplotlib.pyplot as plt from matplotlib.animation import FuncAnimation @@ -12,7 +12,7 @@ # from matplotlib.ticker import MultipleLocator -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from scalewiz.models.test_handler import TestHandler LOGGER = logging.getLogger("scalewiz") @@ -48,7 +48,7 @@ def animate(self, interval: float) -> None: # 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(): + if self.handler.is_running and not self.handler.is_done: # data access here 😳 readings = list(self.handler.readings.queue) if self.handler.readings.qsize() > 0: diff --git a/scalewiz/components/log_window.py b/scalewiz/components/log_window.py index 8c7c6ae..2118c82 100644 --- a/scalewiz/components/log_window.py +++ b/scalewiz/components/log_window.py @@ -2,14 +2,14 @@ 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 -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from logging import LogRecord from scalewiz.components.scalewiz import ScaleWiz @@ -26,9 +26,7 @@ def __init__(self, core: ScaleWiz) -> None: self.log_queue = core.log_queue self.title("Log Window") # replace the window closing behavior with withdrawing instead 🐱‍👤 - self.protocol( - "WM_DELETE_WINDOW", lambda: self.winfo_toplevel().withdraw() - ) + self.protocol("WM_DELETE_WINDOW", lambda: self.winfo_toplevel().withdraw()) self.build() def build(self) -> None: @@ -48,13 +46,12 @@ def build(self) -> None: 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.log_queue.get(block=False) - except queue.Empty: - break - else: - self.display(record) + try: + record = self.log_queue.get(block=False) + except Empty: + pass + else: + self.display(record) self.after(100, self.poll_log_queue) def display(self, record: LogRecord) -> None: diff --git a/scalewiz/components/main_frame.py b/scalewiz/components/main_frame.py index 01af7f5..69a6b90 100644 --- a/scalewiz/components/main_frame.py +++ b/scalewiz/components/main_frame.py @@ -47,7 +47,7 @@ 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() and not widget.handler.is_done.get(): + if widget.handler.is_running and not widget.handler.is_done: LOGGER.warning( "Attempted to close while a test was running on %s", widget.handler.name, diff --git a/scalewiz/components/project_info.py b/scalewiz/components/project_info.py index d431453..445f457 100644 --- a/scalewiz/components/project_info.py +++ b/scalewiz/components/project_info.py @@ -2,23 +2,22 @@ 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 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_params.py index a2ed3a0..40d4c4e 100644 --- a/scalewiz/components/project_params.py +++ b/scalewiz/components/project_params.py @@ -2,13 +2,13 @@ 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 @@ -16,7 +16,7 @@ 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_report.py index 5378d4c..956378a 100644 --- a/scalewiz/components/project_report.py +++ b/scalewiz/components/project_report.py @@ -2,12 +2,12 @@ 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 @@ -15,7 +15,7 @@ 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:") diff --git a/scalewiz/components/project_window.py b/scalewiz/components/project_window.py index 93dea89..9fde492 100644 --- a/scalewiz/components/project_window.py +++ b/scalewiz/components/project_window.py @@ -3,9 +3,9 @@ from __future__ import annotations import tkinter as tk -import typing from pathlib import Path from tkinter import filedialog, ttk +from typing import TYPE_CHECKING from scalewiz.components.project_info import ProjectInfo from scalewiz.components.project_params import ProjectParams @@ -14,7 +14,7 @@ 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,11 +25,14 @@ class ProjectWindow(tk.Toplevel): """ def __init__(self, handler: TestHandler) -> None: - tk.Toplevel.__init__(self) - self.handler = handler - self.editor_project = Project() + 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: @@ -42,8 +45,6 @@ def build(self, reload: bool = False) -> None: 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() diff --git a/scalewiz/components/rinse_window.py b/scalewiz/components/rinse_window.py index 8c0e36a..ecc9e53 100644 --- a/scalewiz/components/rinse_window.py +++ b/scalewiz/components/rinse_window.py @@ -43,7 +43,7 @@ def __init__(self, handler: TestHandler) -> None: 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 not self.handler.is_running or self.handler.is_done: self.pool.submit(self.rinse) def rinse(self) -> None: diff --git a/scalewiz/components/test_controls.py b/scalewiz/components/test_controls.py index 2f52c14..b9d5776 100644 --- a/scalewiz/components/test_controls.py +++ b/scalewiz/components/test_controls.py @@ -29,7 +29,7 @@ def build(self) -> None: self.grid_columnconfigure(1, weight=1) # row 0 col 0 start_btn = ttk.Button(self) - if self.handler.is_done.get(): + if self.handler.is_done: start_btn.configure(text="New", command=self.handler.new_test) else: start_btn.configure(text="Start", command=self.handler.start_test) @@ -49,13 +49,12 @@ def build(self) -> None: 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) + try: + record = self.handler.log_queue.get(block=False) + except Empty: + pass + else: + self.display(record) interval = round(self.handler.project.interval_seconds.get() * 1000) self.after(interval, self.poll_log_queue) diff --git a/scalewiz/components/test_handler_view.py b/scalewiz/components/test_handler_view.py index 4099c73..0f477aa 100644 --- a/scalewiz/components/test_handler_view.py +++ b/scalewiz/components/test_handler_view.py @@ -2,16 +2,16 @@ from __future__ import annotations -import typing from logging import getLogger from tkinter import ttk +from typing import TYPE_CHECKING from scalewiz.components.devices_comboboxes import DeviceBoxes from scalewiz.components.live_plot import LivePlot from scalewiz.components.test_controls import TestControls from scalewiz.components.test_info_widget import TestInfo -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from scalewiz.models.test_handler import TestHandler @@ -22,10 +22,9 @@ 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.is_done.trace_add("write", self.build) + super().__init__(parent) + self.parent: ttk.Frame = parent + self.handler: TestHandler = handler self.build() def build(self, *args) -> None: @@ -63,7 +62,7 @@ def build(self, *args) -> None: def update_input_frame(self) -> None: """Disables widgets in the input frame if a Test is running.""" - if self.handler.is_running.get(): + if self.handler.is_running: for widget in self.inputs: widget.configure(state="disabled") else: diff --git a/scalewiz/models/project.py b/scalewiz/models/project.py index ce4c382..a0e3776 100644 --- a/scalewiz/models/project.py +++ b/scalewiz/models/project.py @@ -144,7 +144,7 @@ def dump_json(self, path: str = None) -> None: "plot": str(Path(self.plot.get()).resolve()), } - with open(path, "w") as file: + 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()) diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index a5ded7b..efa5500 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -3,7 +3,6 @@ from __future__ import annotations import tkinter as tk -import typing from concurrent.futures import ThreadPoolExecutor from datetime import date from logging import DEBUG, FileHandler, Formatter, getLogger @@ -12,6 +11,7 @@ from threading import Event from time import monotonic, sleep, time from tkinter import filedialog, messagebox +from typing import TYPE_CHECKING from py_hplc import NextGenPump @@ -19,7 +19,7 @@ from scalewiz.models.project import Project from scalewiz.models.test import Reading, Test -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from tkinter import ttk from typing import List, Tuple @@ -44,19 +44,18 @@ def __init__(self, name: str = "Nemo") -> None: self.log_handler: FileHandler = None # handles logging to log window # test handler view overwrites this attribute in the view's build() 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.progress = tk.IntVar() - self.elapsed_min = tk.DoubleVar() # used for evaluations + self.elapsed_min: float = None # used for evaluations self.pump1: NextGenPump = None self.pump2: NextGenPump = None # UI concerns - self.is_running = tk.BooleanVar() - self.is_done = tk.BooleanVar() + self.is_running = bool() + self.is_done = bool() self.new_test() def can_run(self) -> bool: @@ -66,7 +65,7 @@ def can_run(self) -> bool: 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 self.elapsed_min <= self.project.limit_minutes.get() and self.readings.qsize() < self.max_readings and not self.stop_requested.is_set() ) @@ -109,7 +108,7 @@ def load_project(self, path: str = None, loaded: Tuple[Path] = []) -> None: 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(): + if self.is_running: return issues = [] @@ -137,11 +136,10 @@ def start_test(self) -> None: pump.close() else: self.stop_requested.clear() - self.is_done.set(False) - self.is_running.set(True) + self.is_done = False + self.is_running = True self.rebuild_views() self.update_log_handler() - self.logger.info("submitting") self.pool.submit(self.take_readings) def take_readings(self) -> None: @@ -185,7 +183,7 @@ def take_readings(self) -> None: self.logger.info(msg) self.readings.put(reading) - self.elapsed_min.set(minutes_elapsed) + self.elapsed_min = minutes_elapsed prog = round((self.readings.qsize() / self.max_readings) * 100) self.progress.set(prog) @@ -205,7 +203,7 @@ def take_readings(self) -> None: # this method is intended to be called from the test handler view def request_stop(self) -> None: """Requests that the Test stop.""" - if self.is_running.get(): + if self.is_running: # the readings loop thread checks this flag on each iteration self.stop_requested.set() self.logger.info("Received a stop request") @@ -221,10 +219,11 @@ def stop_test(self) -> None: pump.serial.name, ) - self.is_done.set(True) + self.is_done = True self.logger.info("Test for %s has been stopped", self.test.name.get()) for _ in range(3): self.view.bell() + self.rebuild_views() def save_test(self) -> None: """Saves the test to the Project file in JSON format.""" @@ -270,8 +269,8 @@ def new_test(self) -> None: 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.is_running = False + self.is_done = False self.progress.set(0) self.max_readings = round( self.project.limit_minutes.get() * 60 / self.project.interval_seconds.get() diff --git a/todo b/todo index 6923e96..57d0848 100644 --- a/todo +++ b/todo @@ -10,10 +10,10 @@ bugs refactoring ----------- -- refactor state management in the test handler (is_running, is_done....) -- refactor TestHandlerView - - see above -- probably better to just brute force with calls to build than to do all - the current tkVar tracing sillyness +- TestHandlerView refactor is pretty much done + - the LivePlot seems to not get rebuilt after a test ends + - we still need to disable all the entries while a test is running + - we have a dep. on Pandas for one little call in export_csv -- could be worked around updates / new features From bd1bf1e9e5359ed867392d5a4f7100424cceb6d6 Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Tue, 18 May 2021 12:15:17 -0500 Subject: [PATCH 09/49] plot cleaning --- scalewiz/components/live_plot.py | 51 ++++++++++++------------ scalewiz/components/test_handler_view.py | 8 +++- todo | 5 ++- 3 files changed, 36 insertions(+), 28 deletions(-) diff --git a/scalewiz/components/live_plot.py b/scalewiz/components/live_plot.py index 39026cb..a24aefe 100644 --- a/scalewiz/components/live_plot.py +++ b/scalewiz/components/live_plot.py @@ -5,6 +5,7 @@ import logging from tkinter import ttk from typing import TYPE_CHECKING +from matplotlib.figure import SubplotParams import matplotlib.pyplot as plt from matplotlib.animation import FuncAnimation @@ -28,47 +29,47 @@ def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: # matplotlib objects # plt.close("all") - fig, self.axis = plt.subplots(figsize=(5, 3), dpi=100) - fig.patch.set_facecolor("#FAFAFA") + + self.fig, self.axis = plt.subplots(figsize=(5, 3), dpi=100, constrained_layout=True, + subplotpars=SubplotParams(left=0.15, bottom=0.15, right=0.97, top=0.95)) + self.fig.patch.set_facecolor("#FAFAFA") # 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) - - plt.tight_layout() - plt.subplots_adjust(left=0.15, bottom=0.15, right=0.97, top=0.95) - self.canvas = FigureCanvasTkAgg(fig, master=self) + # plt.tight_layout() + # plt.subplots_adjust(left=0.15, bottom=0.15, right=0.97, top=0.95) + self.canvas = FigureCanvasTkAgg(self.fig, master=self) self.canvas.get_tk_widget().pack(side="top", fill="both", expand=True) - interval = round(handler.project.interval_seconds.get() * 1000) # ms - self.ani = FuncAnimation(fig, self.animate, interval=interval) + interval = round(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 - + """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 and not self.handler.is_done: - # data access here 😳 - readings = list(self.handler.readings.queue) if self.handler.readings.qsize() > 0: LOGGER.debug("%s: Drawing a new plot ...", self.handler.name) + pump1 = [] + pump2 = [] + elapsed = [] # we will share this series as an axis + readings = tuple(self.handler.readings.queue) + for reading in readings: + pump1.append(reading.pump1) + pump2.append(reading.pump2) + elapsed.append(reading.elapsedMin) + self.axis.clear() with plt.style.context("bmh"): - self.axis.clear() 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, None), auto=True) - self.axis.set_ylim((0, None), auto=True) - self.axis.margins(0) - pump1 = [] - pump2 = [] - elapsed = [] # we will share this series as an axis - for reading in readings: - pump1.append(reading.pump1) - pump2.append(reading.pump2) - elapsed.append(reading.elapsedMin) + self.axis.set_ylim((0, None), auto=True) # this doesn't work ? + 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=0) + self.axis.legend(loc='best') diff --git a/scalewiz/components/test_handler_view.py b/scalewiz/components/test_handler_view.py index 0f477aa..9696270 100644 --- a/scalewiz/components/test_handler_view.py +++ b/scalewiz/components/test_handler_view.py @@ -6,6 +6,8 @@ from tkinter import ttk from typing import TYPE_CHECKING +from matplotlib import pyplot as plt + from scalewiz.components.devices_comboboxes import DeviceBoxes from scalewiz.components.live_plot import LivePlot from scalewiz.components.test_controls import TestControls @@ -30,6 +32,8 @@ def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: def build(self, *args) -> None: """Builds the UI, destroying any currently existing widgets.""" LOGGER.info("%s: rebuilding", self.handler.name) + if hasattr(self, 'plot'): # explicityly close to prevent memory leak + plt.close(self.plot.fig) for child in self.winfo_children(): child.destroy() self.grid_columnconfigure(0, weight=1) @@ -56,8 +60,8 @@ def build(self, *args) -> None: # row 0 col 1 ------------------------------------------------------------------ plt_frm = ttk.Frame(self) - plot = LivePlot(plt_frm, self.handler) - plot.grid(row=0, column=0, sticky="nsew") + 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: diff --git a/todo b/todo index 57d0848..a5cc199 100644 --- a/todo +++ b/todo @@ -5,11 +5,14 @@ bugs - this may be a recently introduced bug from matplotlib itself - may need to open an issue upstream if it isn't my fault related: - - current calls to matplotlib api (LivePlot, EvaluationFrame.plot) are messy + - current calls to matplotlib api (LivePlot.plot, EvaluationFrame.plot) are messy refactoring ----------- +- the way the EvaluationWindow is rendered makes my itchy and must change + + - TestHandlerView refactor is pretty much done - the LivePlot seems to not get rebuilt after a test ends - we still need to disable all the entries while a test is running From 595815df53fae34a6c41e18f47d167d1658f45b3 Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Tue, 18 May 2021 12:38:41 -0500 Subject: [PATCH 10/49] fix logfile creation --- scalewiz/components/menu_bar.py | 3 ++- scalewiz/helpers/export_csv.py | 2 +- scalewiz/models/test_handler.py | 7 +++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/scalewiz/components/menu_bar.py b/scalewiz/components/menu_bar.py index d155453..3e2987c 100644 --- a/scalewiz/components/menu_bar.py +++ b/scalewiz/components/menu_bar.py @@ -97,5 +97,6 @@ def _debug(self) -> None: """Used for debugging.""" current_tab = self.main_frame.tab_control.select() widget = self.main_frame.nametowidget(current_tab) - widget.handler.rebuild_views() + # widget.handler.rebuild_views() + widget.handler.update_log_handler() widget.bell() diff --git a/scalewiz/helpers/export_csv.py b/scalewiz/helpers/export_csv.py index 30b9d7d..6603b68 100644 --- a/scalewiz/helpers/export_csv.py +++ b/scalewiz/helpers/export_csv.py @@ -72,7 +72,7 @@ def export_csv(project: Project) -> None: 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) + out = Path(Path(project.path.get()).parent).joinpath(out).resolve() with out.open("w") as output: if fmt == "CSV": diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index efa5500..199caf3 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -292,12 +292,11 @@ def rebuild_views(self) -> None: 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 = Path(self.project.path.get()).resolve().parent - logs_dir = Path(parent_dir, "/logs") + 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, log_file) - + log_path = Path(logs_dir).joinpath(log_file).resolve() if self.log_handler in self.logger.handlers: self.logger.removeHandler(self.log_handler) self.log_handler = FileHandler(log_path) From 2c48c90bb49106a1c12996615ce287406fec78af Mon Sep 17 00:00:00 2001 From: teauxfu Date: Tue, 18 May 2021 16:39:27 -0500 Subject: [PATCH 11/49] close but not quite --- scalewiz/components/devices_comboboxes.py | 17 +++-- scalewiz/components/live_plot.py | 18 +++--- scalewiz/components/menu_bar.py | 5 +- scalewiz/components/test_controls.py | 10 ++- scalewiz/components/test_handler_view.py | 11 ++-- scalewiz/components/test_info_widget.py | 48 ++++++++++----- scalewiz/helpers/set_icon.py | 17 +++-- scalewiz/models/test_handler.py | 75 ++++++++++++++--------- 8 files changed, 128 insertions(+), 73 deletions(-) diff --git a/scalewiz/components/devices_comboboxes.py b/scalewiz/components/devices_comboboxes.py index 3a30ba1..037abae 100644 --- a/scalewiz/components/devices_comboboxes.py +++ b/scalewiz/components/devices_comboboxes.py @@ -10,20 +10,21 @@ 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, dev1: tk.StringVar, dev2: tk.StringVar - ) -> None: + 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 = dev1 - self.dev2: tk.StringVar = dev2 + self.dev1: tk.StringVar = handler.dev1 + self.dev2: tk.StringVar = handler.dev2 self.build() def build(self) -> None: @@ -33,6 +34,10 @@ def build(self) -> None: 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, @@ -40,6 +45,7 @@ def build(self) -> None: values=self.devices_list, validate="all", validatecommand=self.update_devices_list, + state=state, ) self.device2_entry = ttk.Combobox( self, @@ -48,6 +54,7 @@ def build(self) -> None: values=self.devices_list, validate="all", validatecommand=self.update_devices_list, + state=state, ) # grid the widgets label.grid(row=0, column=0, sticky="ne") diff --git a/scalewiz/components/live_plot.py b/scalewiz/components/live_plot.py index a24aefe..0a2ffb3 100644 --- a/scalewiz/components/live_plot.py +++ b/scalewiz/components/live_plot.py @@ -5,11 +5,11 @@ import logging from tkinter import ttk from typing import TYPE_CHECKING -from matplotlib.figure import SubplotParams import matplotlib.pyplot as plt from matplotlib.animation import FuncAnimation from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +from matplotlib.figure import SubplotParams # from matplotlib.ticker import MultipleLocator @@ -30,9 +30,12 @@ def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: # matplotlib objects # plt.close("all") - - self.fig, self.axis = plt.subplots(figsize=(5, 3), dpi=100, constrained_layout=True, - subplotpars=SubplotParams(left=0.15, bottom=0.15, right=0.97, top=0.95)) + self.fig, self.axis = plt.subplots( + figsize=(5, 3), + dpi=100, + constrained_layout=True, + subplotpars=SubplotParams(left=0.15, bottom=0.15, right=0.97, top=0.95), + ) self.fig.patch.set_facecolor("#FAFAFA") # self.axis.set_ylim(top=self.handler.project.limit_psi.get()) # self.axis.yaxis.set_major_locator(MultipleLocator(100)) @@ -47,7 +50,7 @@ def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: # 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 @@ -62,14 +65,15 @@ def animate(self, interval: float) -> None: pump1.append(reading.pump1) pump2.append(reading.pump2) elapsed.append(reading.elapsedMin) + max_psi = max(pump1) if max(pump1) > max(pump2) else max(pump2) 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, None), auto=True) # this doesn't work ? + 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') + self.axis.legend(loc="best") diff --git a/scalewiz/components/menu_bar.py b/scalewiz/components/menu_bar.py index 3e2987c..a10ddfa 100644 --- a/scalewiz/components/menu_bar.py +++ b/scalewiz/components/menu_bar.py @@ -95,8 +95,9 @@ def about(self) -> None: def _debug(self) -> None: """Used for debugging.""" + LOGGER.warn("DEBUGGING") current_tab = self.main_frame.tab_control.select() widget = self.main_frame.nametowidget(current_tab) - # widget.handler.rebuild_views() - widget.handler.update_log_handler() + widget.handler.rebuild_views() + # widget.handler.update_log_handler() widget.bell() diff --git a/scalewiz/components/test_controls.py b/scalewiz/components/test_controls.py index b9d5776..6940610 100644 --- a/scalewiz/components/test_controls.py +++ b/scalewiz/components/test_controls.py @@ -21,7 +21,6 @@ class TestControls(ttk.Frame): def __init__(self, parent: tk.Widget, handler: TestHandler) -> None: super().__init__(parent) self.handler: TestHandler = handler - self.build() def build(self) -> None: @@ -29,9 +28,16 @@ def build(self) -> None: self.grid_columnconfigure(1, weight=1) # row 0 col 0 start_btn = ttk.Button(self) - if self.handler.is_done: + if self.handler.is_done and not self.handler.is_running: + LOGGER.warn("building enabled new") start_btn.configure(text="New", command=self.handler.new_test) + elif self.handler.is_running and not self.handler.is_done: + LOGGER.warn("building disabled new") + start_btn.configure( + text="New", command=self.handler.new_test, state="disabled" + ) else: + LOGGER.warn("building enabled start") start_btn.configure(text="Start", command=self.handler.start_test) start_btn.grid(row=0, column=0, sticky="ew") # row 0 col 1 diff --git a/scalewiz/components/test_handler_view.py b/scalewiz/components/test_handler_view.py index 9696270..11db48a 100644 --- a/scalewiz/components/test_handler_view.py +++ b/scalewiz/components/test_handler_view.py @@ -31,14 +31,17 @@ def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: def build(self, *args) -> None: """Builds the UI, destroying any currently existing widgets.""" - LOGGER.info("%s: rebuilding", self.handler.name) - if hasattr(self, 'plot'): # explicityly close to prevent memory leak + LOGGER.info("%s: rebuilding", self) + if hasattr(self, "plot"): # explicityly close to prevent memory leak + LOGGER.info("closing plot") plt.close(self.plot.fig) for child in self.winfo_children(): + LOGGER.info("%s", child) + LOGGER.info("destroying %s", child) child.destroy() self.grid_columnconfigure(0, weight=1) # row 0 ------------------------------------------------------------------------ - dev_ent = DeviceBoxes(self, self.handler.dev1, self.handler.dev2) + dev_ent = DeviceBoxes(self, self.handler) dev_ent.grid(row=0, column=0, sticky="new") # row 1 ------------------------------------------------------------------------ @@ -51,7 +54,7 @@ def build(self, *args) -> None: frm.grid(row=1, column=0, sticky="new") # row 2 ------------------------------------------------------------------------ - test_info = TestInfo(self, self.handler.test) + test_info = TestInfo(self, self.handler) test_info.grid(row=2, column=0, sticky="new") # row 3------------------------------------------------------------------------- diff --git a/scalewiz/components/test_info_widget.py b/scalewiz/components/test_info_widget.py index d80bda7..ed5859c 100644 --- a/scalewiz/components/test_info_widget.py +++ b/scalewiz/components/test_info_widget.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: - from scalewiz.models.test import Test + from scalewiz.models.test_handler import TestHandler LOGGER: Logger = getLogger("scalewiz") @@ -18,18 +18,20 @@ class TestInfo(ttk.Frame): """A widget for inputting Test information.""" - def __init__(self, parent: tk.Widget, test: Test) -> None: + def __init__(self, parent: tk.Widget, handler: TestHandler) -> None: super().__init__(parent) - self.test: Test = test + self.handler: TestHandler = handler self.build() def build(self) -> None: """Builds the widget.""" - for child in self.winfo_children(): - child.destroy() - self.grid_columnconfigure(1, weight=1) + if self.handler.is_running and not self.handler.is_done: + state = "disabled" + else: + state = "normal" + radio_lbl = ttk.Label(self, text=" Test Type:", anchor="e") radio_lbl.grid(row=0, column=0, sticky="ew") @@ -39,17 +41,19 @@ def build(self) -> None: blank_btn = ttk.Radiobutton( radio_frm, text="Blank", - variable=self.test.is_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.test.is_blank, + 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") @@ -57,16 +61,20 @@ def build(self) -> None: test_frm = ttk.Frame(self) test_frm.grid_columnconfigure(1, weight=1) - if self.test.is_blank.get(): + 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.test.name) + 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") - notes_ent = ttk.Entry(test_frm, textvariable=self.test.notes) + 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) @@ -76,7 +84,9 @@ def build(self) -> None: # 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.test.chemical) + 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") @@ -85,11 +95,12 @@ def build(self) -> None: vcmd = self.register(lambda s: can_be_pos_float(s)) rate_ent = ttk.Spinbox( test_frm, - textvariable=self.test.rate, + 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 ----------------------------------------------------------- @@ -98,14 +109,21 @@ def build(self) -> None: clarity_ent = ttk.Combobox( test_frm, values=["Clear", "Slightly hazy", "Hazy"], - textvariable=self.test.clarity, + 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") - notes_ent = ttk.Entry(test_frm, textvariable=self.test.notes) + 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/helpers/set_icon.py b/scalewiz/helpers/set_icon.py index 18a9d11..d025c21 100644 --- a/scalewiz/helpers/set_icon.py +++ b/scalewiz/helpers/set_icon.py @@ -15,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 = 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 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") diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index 199caf3..2e35a71 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -20,6 +20,7 @@ from scalewiz.models.test import Reading, Test if TYPE_CHECKING: + from logging import Logger from tkinter import ttk from typing import List, Tuple @@ -31,9 +32,9 @@ class TestHandler: def __init__(self, name: str = "Nemo") -> None: self.name = name - self.logger = getLogger(f"scalewiz.{name}") + self.logger: Logger = getLogger(f"scalewiz.{name}") self.view: TestHandlerView = None - self.project = Project() + self.project: Project = Project() self.test: Test = None self.pool = ThreadPoolExecutor(max_workers=1) self.readings: Queue[dict] = Queue() @@ -48,16 +49,17 @@ def __init__(self, name: str = "Nemo") -> None: self.dev2 = tk.StringVar() self.stop_requested: Event = Event() self.progress = tk.IntVar() - self.elapsed_min: float = None # used for evaluations + self.elapsed_min: float = float() # used for evaluations self.pump1: NextGenPump = None self.pump2: NextGenPump = None # UI concerns - self.is_running = bool() - self.is_done = bool() + 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 ( @@ -128,6 +130,8 @@ def start_test(self) -> None: msg = "Water clarity cannot be blank" issues.append(msg) + self.update_log_handler(issues) + # this method will append issue msgs if any occur self.setup_pumps(issues) # hooray for pointers if len(issues) > 0: @@ -139,7 +143,7 @@ def start_test(self) -> None: self.is_done = False self.is_running = True self.rebuild_views() - self.update_log_handler() + self.pool.submit(self.take_readings) def take_readings(self) -> None: @@ -152,7 +156,7 @@ def take_readings(self) -> None: rinse_start = monotonic() sleep(step) for i in range(100): - if self.can_run(): + if self.can_run: self.progress.set(i) sleep(step - ((monotonic() - rinse_start) % step)) else: @@ -164,7 +168,10 @@ def take_readings(self) -> None: test_start_time = monotonic() sleep(interval) # readings loop ---------------------------------------------------------------- - while self.can_run(): + self.logger.warning("starting loop") + while self.can_run: + self.logger.warning("starting reading") + minutes_elapsed = round((monotonic() - test_start_time) / 60, 2) psi1 = self.pump1.pressure @@ -195,8 +202,11 @@ def take_readings(self) -> None: # TYSM https://stackoverflow.com/a/25251804 sleep(interval - ((monotonic() - test_start_time) % interval)) # end of readings loop --------------------------------------------------------- + self.logger.warning("exited loop") self.stop_test() + self.logger.warning("stopped test") self.save_test() + self.logger.warning("saved test -- end of take_readigs") # 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 @@ -220,6 +230,7 @@ def stop_test(self) -> None: ) self.is_done = True + self.is_running = False self.logger.info("Test for %s has been stopped", self.test.name.get()) for _ in range(3): self.view.bell() @@ -268,7 +279,8 @@ def new_test(self) -> None: self.test = Test() with self.readings.mutex: self.readings.queue.clear() - self.max_psi_1 = self.max_psi_2 = 0 + self.max_psi_1 = 0 + self.max_psi_2 = 0 self.is_running = False self.is_done = False self.progress.set(0) @@ -289,27 +301,32 @@ def rebuild_views(self) -> None: self.view.build() self.logger.info("Rebuilt all view widgets") - def update_log_handler(self) -> None: + def update_log_handler(self, issues: List[str]) -> None: """Sets up the logging FileHandler to the passed path.""" - log_file = f"{round(time())}_{self.test.name.get()}_{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() - if self.log_handler in self.logger.handlers: - self.logger.removeHandler(self.log_handler) - self.log_handler = FileHandler(log_path) - - formatter = Formatter( - "%(asctime)s - %(thread)d - %(levelname)s - %(message)s", - "%Y-%m-%d %H:%M:%S", - ) - self.log_handler.setFormatter(formatter) - 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()) + try: + log_file = f"{round(time())}_{self.test.name.get()}_{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) + except Exception as err: # bad path chars from user can bug here + issues.append("Bad log file") + issues.append(str(err)) + return + else: + 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(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.""" From e3597467a66aa1da9f1c9f2605a382fca66cf52f Mon Sep 17 00:00:00 2001 From: teauxfu Date: Wed, 19 May 2021 16:28:47 -0500 Subject: [PATCH 12/49] mostly done with eval refactor --- scalewiz/__main__.py | 2 +- scalewiz/components/evaluation_tests_frame.py | 152 ++++++++++++++++++ scalewiz/components/evaluation_window.py | 109 ++++++------- scalewiz/components/live_plot.py | 19 +-- scalewiz/components/scalewiz.py | 4 +- scalewiz/components/test_controls.py | 10 +- scalewiz/components/test_evaluation_row.py | 66 +++++--- scalewiz/components/test_handler_view.py | 6 +- scalewiz/components/test_info_widget.py | 2 +- scalewiz/models/test_handler.py | 37 +++-- todo | 9 +- 11 files changed, 292 insertions(+), 124 deletions(-) create mode 100644 scalewiz/components/evaluation_tests_frame.py diff --git a/scalewiz/__main__.py b/scalewiz/__main__.py index fef41db..0d9ae78 100644 --- a/scalewiz/__main__.py +++ b/scalewiz/__main__.py @@ -8,7 +8,7 @@ def main() -> None: """The Tkinter entry point of the program; enters mainloop.""" root = tk.Tk() - ScaleWiz(root).grid() + ScaleWiz(root).grid(sticky="nsew") root.mainloop() diff --git a/scalewiz/components/evaluation_tests_frame.py b/scalewiz/components/evaluation_tests_frame.py new file mode 100644 index 0000000..e76f867 --- /dev/null +++ b/scalewiz/components/evaluation_tests_frame.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import tkinter as tk +from logging import Logger, getLogger +from tkinter import ttk +from typing import TYPE_CHECKING + +from scalewiz.components.test_evaluation_row import TestResultRow +from scalewiz.models.project import Project + +if TYPE_CHECKING: + from tkinter.font import Font + from typing import List + + from scalewiz.models.test import Test + + +LOGGER: Logger = getLogger("scalewiz") + + +class EvaluationTestsFrame(ttk.Frame): + """A widget for selecting devices.""" + + def __init__( + self, + parent: ttk.Frame, + project: Project, + mode: str, + font: Font, + col_labels: bool = True, + ) -> None: + super().__init__(parent) + self.tests: List[Test] = [] + self.mode: str = mode + if self.mode == "blanks": + self.label: str = "Blanks:" + elif self.mode == "trials": + self.label: str = "Trials:" + self.font: Font = font + self.project: Project = project + self.col_labels: bool = col_labels + # child TestResultRow widgets come to find these + self.baseline_len: int = None + self.name_len: int = None + self.clarity_len: int = None + self.max_psi_len: int = None + self.minutes_len: int = None + + self.build() + + def build(self) -> None: + """Build the UI.""" + for child in self.winfo_children(): + child.destroy() + self.grid_columnconfigure(0, weight=1) + + self.sort_tests() + self.set_label_lengths() + + if self.col_labels: + col_header = ttk.Frame(self) + labels = [] + labels.append( + tk.Label( + col_header, + text="Name", + font=self.font, + width=self.name_len - 5, + anchor="w", + ) + ) + labels.append( + tk.Label(col_header, text="Label", font=self.font, width=21, anchor="w") + ) + labels.append( + tk.Label( + col_header, + text="Minutes", + font=self.font, + width=self.minutes_len + 10, + anchor="w", + ) + ) + labels.append(tk.Label(col_header, text="Pump", font=self.font, anchor="w")) + labels.append( + tk.Label(col_header, text="Baseline", font=self.font, anchor="w") + ) + labels.append(tk.Label(col_header, text="Max", font=self.font, anchor="w")) + labels.append( + tk.Label(col_header, text="Clarity", font=self.font, anchor="w") + ) + labels.append( + tk.Label(col_header, text="Notes", font=self.font, anchor="w") + ) + labels.append( + tk.Label(col_header, text="Result", font=self.font, anchor="w") + ) + labels.append( + tk.Label(col_header, text="Report", font=self.font, anchor="w") + ) + labels.append(tk.Label(col_header, text=" ", font=self.font, anchor="w")) + + for i, lbl in enumerate(labels): + col_header.grid_columnconfigure(i, weight=1) + lbl.grid(row=0, column=i, padx=0, sticky="w") + col_header.grid(row=0, column=0, sticky="ew") + + type_header = ttk.Label(self, text=self.label, font=self.font) + type_header.grid(row=1, column=0, sticky="ew", padx=0, pady=1) + + for i, test in enumerate(self.tests): + row = TestResultRow(self, test, self.project) + row.grid(row=i + 2, column=0, sticky="ew") + + def sort_tests(self) -> None: + """ + Sort through the editor_project, populating the lists of blanks and trials. + """ + self.tests.clear() + for test in self.project.tests: + if self.mode == "blanks" and test.is_blank.get(): + self.tests.append(test) + elif self.mode == "trials" and not test.is_blank.get(): + self.tests.append(test) + + def set_label_lengths(self) -> None: + """Sets label length values.""" + name_len = int() + baseline_len = int() + clarity_len = int() + + for test in self.project.tests: + _name_len = len(test.name.get()) + if _name_len > name_len: + name_len = _name_len + + _baseline_len = len(str(test.observed_baseline.get())) + if _baseline_len > baseline_len: + baseline_len = _baseline_len + + _clarity_len = len(test.clarity.get()) + if _clarity_len > clarity_len: + clarity_len = _clarity_len + + minutes_len = len(str(self.project.limit_minutes.get())) + max_psi_len = len(str(self.project.limit_psi.get())) + + self.name_len = name_len + self.clarity_len = clarity_len + self.baseline_len = baseline_len + self.minutes_len = minutes_len + self.max_psi_len = max_psi_len diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index bf4554c..00f8c84 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -14,13 +14,13 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.ticker import MultipleLocator -from scalewiz.components.test_evaluation_row import TestResultRow +from scalewiz.components.evaluation_tests_frame import EvaluationTestsFrame from scalewiz.helpers.export_csv import export_csv from scalewiz.helpers.set_icon import set_icon from scalewiz.models.project import Project if TYPE_CHECKING: - from typing import Set + from typing import List from scalewiz.models.test import Test from scalewiz.models.test_handler import TestHandler @@ -46,7 +46,7 @@ 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 Path(self.handler.project.path.get()).is_file: @@ -54,8 +54,13 @@ def __init__(self, handler: TestHandler) -> None: # matplotlib uses these later self.fig, self.axis, self.canvas = None, None, None self.plot_frame: ttk.Frame = None # this gets destroyed in plot() - self.trials: Set[Test] = set() - self.blanks: Set[Test] = set() + self.trials: List[Test] = set() + self.blanks: List[Test] = set() + self.winfo_toplevel().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: @@ -74,64 +79,60 @@ def build(self, reload: bool = False) -> None: 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() + 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) - + container = ttk.Frame( + self.tab_control + ) # container to be added to the tab_control + # build the header row + header_row = ttk.Frame(container) 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.clear() - self.trials.clear() - # filter through blanks and trials - for test in self.editor_project.tests: - if test.is_blank.get(): - self.blanks.add(test) - else: - self.trials.add(test) + # grid into tests_frame + header_row.grid(row=0, column=0, sticky="ew") - tk.Label(tests_frame, text="Blanks:", font=bold_font).grid( - row=1, column=0, sticky="w", padx=3, pady=1 + # max + blanks = EvaluationTestsFrame( + container, + self.editor_project, + mode="blanks", + font=bold_font, + col_labels=True, ) - tk.Label(tests_frame, text="Trials:", font=bold_font).grid( - row=2 + len(self.blanks), column=0, sticky="w", padx=3, pady=1 + blanks.grid(row=1, column=0, sticky="ew") + trials = EvaluationTestsFrame( + container, + self.editor_project, + mode="trials", + font=bold_font, + col_labels=False, ) - - 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 ") + trials.grid(row=2, column=0, sticky="ew") + + # tk.Label(header_row, text="Blanks:", font=bold_font).grid( + # row=1, column=0, sticky="w", padx=3, pady=1 + # ) + # tk.Label(header_row, 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(header_row, 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(header_row, trial, self.editor_project, i + count + 3).grid( + # row=i + count + 3, column=0, sticky="w", padx=3, pady=1 + # ) + + self.tab_control.add(container, text=" Data ") # plot stuff ---------------------------------------------------------- self.plot() @@ -334,7 +335,7 @@ def score(self, *args) -> None: f"Result: 1 - ({int_psi} - {baseline_area}) / {avg_protectable_area}" ) log.append(f"Result: {result} \n") - trial.result.set(result) + trial.result.set(f"{result:.2f}") self.plot() @@ -348,5 +349,5 @@ def to_log(self, log: list[str]) -> None: self.log_text.configure(state="normal") self.log_text.delete(1.0, "end") for msg in log: - self.log_text.insert("end", "".join((msg, "/n"))) + self.log_text.insert("end", "".join((msg, "\n"))) self.log_text.configure(state="disabled") diff --git a/scalewiz/components/live_plot.py b/scalewiz/components/live_plot.py index 0a2ffb3..f4e72b2 100644 --- a/scalewiz/components/live_plot.py +++ b/scalewiz/components/live_plot.py @@ -11,8 +11,6 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import SubplotParams -# from matplotlib.ticker import MultipleLocator - if TYPE_CHECKING: from scalewiz.models.test_handler import TestHandler @@ -26,22 +24,17 @@ def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: """Initialize a LivePlot.""" super().__init__(parent) self.handler = handler - - # matplotlib objects - # plt.close("all") - self.fig, self.axis = plt.subplots( figsize=(5, 3), dpi=100, constrained_layout=True, - subplotpars=SubplotParams(left=0.15, bottom=0.15, right=0.97, top=0.95), + subplotpars=SubplotParams(left=0.15, bottom=0.15, 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.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) - # plt.tight_layout() - # plt.subplots_adjust(left=0.15, bottom=0.15, right=0.97, top=0.95) self.canvas = FigureCanvasTkAgg(self.fig, master=self) self.canvas.get_tk_widget().pack(side="top", fill="both", expand=True) interval = round(handler.project.interval_seconds.get() * 1000) # -> ms @@ -65,7 +58,7 @@ def animate(self, interval: float) -> None: pump1.append(reading.pump1) pump2.append(reading.pump2) elapsed.append(reading.elapsedMin) - max_psi = max(pump1) if max(pump1) > max(pump2) else max(pump2) + 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="-") diff --git a/scalewiz/components/scalewiz.py b/scalewiz/components/scalewiz.py index d97d71a..4830449 100644 --- a/scalewiz/components/scalewiz.py +++ b/scalewiz/components/scalewiz.py @@ -24,7 +24,7 @@ def __init__(self, parent) -> None: # icon / version set_icon(parent) parent.title(f"ScaleWiz {version('scalewiz')}") - # parent.resizable(0, 0) # apparently this is a bad practice... + parent.resizable(0, 0) # apparently this is a bad practice... # font 🔠 default_font = font.nametofont("TkDefaultFont") default_font.configure(family="Arial") @@ -52,7 +52,7 @@ def __init__(self, parent) -> None: 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 = logging.getLogger("scalewiz") logger.addHandler(queue_handler) # holding a ref to the toplevel for the menubar to find self.log_window = LogWindow(self) diff --git a/scalewiz/components/test_controls.py b/scalewiz/components/test_controls.py index 6940610..40e8501 100644 --- a/scalewiz/components/test_controls.py +++ b/scalewiz/components/test_controls.py @@ -41,9 +41,15 @@ def build(self) -> None: start_btn.configure(text="Start", command=self.handler.start_test) start_btn.grid(row=0, column=0, sticky="ew") # row 0 col 1 - stop_btn = ttk.Button(self, text="Stop", command=self.handler.request_stop) + 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) + 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( diff --git a/scalewiz/components/test_evaluation_row.py b/scalewiz/components/test_evaluation_row.py index 875b46e..545966d 100644 --- a/scalewiz/components/test_evaluation_row.py +++ b/scalewiz/components/test_evaluation_row.py @@ -8,6 +8,7 @@ if TYPE_CHECKING: from typing import List + from scalewiz.components.evaluation_tests_frame import EvaluationTestsFrame from scalewiz.models.project import Project from scalewiz.models.test import Test @@ -16,26 +17,25 @@ 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 + self, + parent: EvaluationTestsFrame, + test: Test, + project: Project, + test_name_len: int = 0, ) -> None: - ttk.Frame.__init__(self, parent) + super().__init__(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.master refers to the EvaluationFrame itself 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)) + cols.append(ttk.Label(self, textvariable=self.test.name, width=parent.name_len)) # col 1 - label cols.append( ttk.Entry( - self.parent, + self, textvariable=self.test.label, width=25, validate="focusout", @@ -48,14 +48,15 @@ def build(self) -> None: ) cols.append( ttk.Label( - self.parent, - text=f"{duration:.2f}, ({len(self.test.readings)})", + self, + text=f"{duration:.2f}", + width=parent.minutes_len, anchor="center", ) ) # col 3 - pump to score to_score = ttk.Combobox( - self.parent, + self, textvariable=self.test.pump_to_score, values=["pump 1", "pump 2", "average"], state="readonly", @@ -68,27 +69,40 @@ def build(self) -> None: # col 4 - obs baseline cols.append( ttk.Label( - self.parent, textvariable=self.test.observed_baseline, anchor="center" + self, + textvariable=self.test.observed_baseline, + width=parent.baseline_len, + anchor="center", ) ) # col 5 - max psi cols.append( - ttk.Label(self.parent, textvariable=self.test.max_psi, anchor="center") + ttk.Label( + self, + textvariable=self.test.max_psi, + width=parent.max_psi_len, + anchor="center", + ) ) # col 6 - clarity cols.append( - ttk.Label(self.parent, textvariable=self.test.clarity, anchor="center") + ttk.Label( + self, + textvariable=self.test.clarity, + width=parent.clarity_len, + anchor="center", + ) ) # col 7 - notes - cols.append(ttk.Entry(self.parent, textvariable=self.test.notes)) + cols.append(ttk.Entry(self, textvariable=self.test.notes)) # col 8 - result cols.append( - ttk.Label(self.parent, textvariable=self.test.result, anchor="center") + ttk.Label(self, textvariable=self.test.result, width=5, anchor="center") ) # col 9 - include on report cols.append( ttk.Checkbutton( - self.parent, + self, variable=self.test.include_on_report, command=self.update_score, ) @@ -96,7 +110,7 @@ def build(self) -> None: # col 10 - delete cols.append( ttk.Button( - self.parent, + self, command=self.remove_from_project, text="Delete", width=7, @@ -105,13 +119,13 @@ def build(self) -> None: 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") + col.grid(row=0, column=i, padx=(3, 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") + # self.grid_columnconfigure(7, weight=1) + col.grid(row=0, column=i, padx=1, pady=1, sticky="ew") else: # defaults for the rest col.grid( - row=self.row, + row=0, column=i, padx=1, pady=1, @@ -127,10 +141,10 @@ def remove_from_project(self) -> None: 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() + self.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) + self.after(1, self.master.score) return True diff --git a/scalewiz/components/test_handler_view.py b/scalewiz/components/test_handler_view.py index 11db48a..8b6ac6a 100644 --- a/scalewiz/components/test_handler_view.py +++ b/scalewiz/components/test_handler_view.py @@ -31,13 +31,9 @@ def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: def build(self, *args) -> None: """Builds the UI, destroying any currently existing widgets.""" - LOGGER.info("%s: rebuilding", self) if hasattr(self, "plot"): # explicityly close to prevent memory leak - LOGGER.info("closing plot") - plt.close(self.plot.fig) + self.after(0, plt.close, self.plot.fig) for child in self.winfo_children(): - LOGGER.info("%s", child) - LOGGER.info("destroying %s", child) child.destroy() self.grid_columnconfigure(0, weight=1) # row 0 ------------------------------------------------------------------------ diff --git a/scalewiz/components/test_info_widget.py b/scalewiz/components/test_info_widget.py index ed5859c..681b5c9 100644 --- a/scalewiz/components/test_info_widget.py +++ b/scalewiz/components/test_info_widget.py @@ -27,7 +27,7 @@ def build(self) -> None: """Builds the widget.""" self.grid_columnconfigure(1, weight=1) - if self.handler.is_running and not self.handler.is_done: + if self.handler.is_done or self.handler.is_running: state = "disabled" else: state = "normal" diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index 2e35a71..abaecfd 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -72,7 +72,9 @@ def can_run(self) -> bool: and not self.stop_requested.is_set() ) - def load_project(self, path: str = None, loaded: Tuple[Path] = []) -> None: + def load_project( + self, path: str = None, loaded: Tuple[Path] = [], new_test: bool = True + ) -> None: """Opens a file dialog then loads the selected Project file. `loaded` gets built from scratch every time it is passed in -- no need to update @@ -103,8 +105,8 @@ def load_project(self, path: str = None, loaded: Tuple[Path] = []) -> None: self.project.remove_traces() self.project = Project() self.project.load_json(path) - self.new_test() - self.rebuild_views() + if new_test: + self.new_test() self.logger.info("Loaded %s", self.project.name.get()) def start_test(self) -> None: @@ -143,7 +145,6 @@ def start_test(self) -> None: self.is_done = False self.is_running = True self.rebuild_views() - self.pool.submit(self.take_readings) def take_readings(self) -> None: @@ -168,10 +169,7 @@ def take_readings(self) -> None: test_start_time = monotonic() sleep(interval) # readings loop ---------------------------------------------------------------- - self.logger.warning("starting loop") while self.can_run: - self.logger.warning("starting reading") - minutes_elapsed = round((monotonic() - test_start_time) / 60, 2) psi1 = self.pump1.pressure @@ -202,11 +200,8 @@ def take_readings(self) -> None: # TYSM https://stackoverflow.com/a/25251804 sleep(interval - ((monotonic() - test_start_time) % interval)) # end of readings loop --------------------------------------------------------- - self.logger.warning("exited loop") self.stop_test() - self.logger.warning("stopped test") self.save_test() - self.logger.warning("saved test -- end of take_readigs") # 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 @@ -238,12 +233,15 @@ def stop_test(self) -> None: def save_test(self) -> None: """Saves the test to the Project file in JSON format.""" - for reading in list(self.readings.queue): + for reading in tuple(self.readings.queue): self.test.readings.append(reading) + self.logger.info( + "saved %s readings to %s", len(self.test.readings), self.test.name.get() + ) self.project.tests.append(self.test) self.project.dump_json() # refresh data / UI - self.load_project(path=self.project.path.get()) + self.load_project(path=self.project.path.get(), new_test=False) self.rebuild_views() def setup_pumps(self, issues: List[str] = None) -> None: @@ -270,19 +268,20 @@ def setup_pumps(self, issues: List[str] = None) -> None: 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) + 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.logger.info("Initializing a new test") + if isinstance(self.test, Test): + self.test.remove_traces() + del self.test self.test = Test() with self.readings.mutex: self.readings.queue.clear() - self.max_psi_1 = 0 - self.max_psi_2 = 0 - self.is_running = False - self.is_done = False + 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() @@ -298,7 +297,7 @@ def rebuild_views(self) -> None: else: # clean up as we go self.editors.remove(widget) if isinstance(self.view, TestHandlerView): - self.view.build() + self.view.after(0, self.view.build) self.logger.info("Rebuilt all view widgets") def update_log_handler(self, issues: List[str]) -> None: diff --git a/todo b/todo index a5cc199..511d498 100644 --- a/todo +++ b/todo @@ -1,6 +1,10 @@ bugs ---- + +done +~~~~ + - the LivePlot currently seems rather unreliable - this may be a recently introduced bug from matplotlib itself - may need to open an issue upstream if it isn't my fault @@ -10,7 +14,10 @@ bugs refactoring ----------- -- the way the EvaluationWindow is rendered makes my itchy and must change +done +~~~~ + +- (mostly done) the way the EvaluationWindow is rendered makes my itchy and must change - TestHandlerView refactor is pretty much done From 214adc8267bf18909d716cc2c4a390a6e82fde0a Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Mon, 24 May 2021 16:19:22 -0500 Subject: [PATCH 13/49] move scoring out of eval window refactor UI building --- scalewiz/components/evaluation_data_view.py | 238 ++++++++++++++++++ scalewiz/components/evaluation_tests_frame.py | 152 ----------- scalewiz/components/evaluation_window.py | 182 ++------------ scalewiz/components/test_evaluation_row.py | 27 +- scalewiz/helpers/score.py | 121 +++++++++ 5 files changed, 380 insertions(+), 340 deletions(-) create mode 100644 scalewiz/components/evaluation_data_view.py delete mode 100644 scalewiz/components/evaluation_tests_frame.py create mode 100644 scalewiz/helpers/score.py diff --git a/scalewiz/components/evaluation_data_view.py b/scalewiz/components/evaluation_data_view.py new file mode 100644 index 0000000..26d4160 --- /dev/null +++ b/scalewiz/components/evaluation_data_view.py @@ -0,0 +1,238 @@ +"""A table view to be displayed in the Evaluation Window.""" + +from __future__ import annotations +from scalewiz.helpers.score import score + +import tkinter as tk +from logging import Logger, getLogger +from tkinter import ttk, messagebox +from tkinter.font import Font +from typing import TYPE_CHECKING + +from scalewiz.components.test_evaluation_row import TestResultRow + +if TYPE_CHECKING: + from scalewiz.models.project import Project + from scalewiz.models.test import Test + from typing import List + + +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 = 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: + for child in self.winfo_children(): + 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 + + def apply_col_headers(self) -> None: + labels = [] + labels.append( + tk.Label( + self, + text="Name", + font=self.bold_font, + anchor='w', + ) + ) + labels.append( + tk.Label(self, text="Label", font=self.bold_font, width=20, 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') + ) + 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=0, column=i, padx=0, sticky='w') + else: + lbl.grid(row=0, column=i, padx=3, sticky="ew") + + + def apply_test_row(self, test, row) -> 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 1 - label + cols.append( + ttk.Entry( + self, + textvariable=test.label, + validate="focusout", + validatecommand=vcmd, + width=30 + ) + ) + # 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=30)) + # 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 + delete = lambda: self.remove_from_project(test) + cols.append( + ttk.Button( + self, + command=delete, + 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: + 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 editor_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: + """Method to call 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)) + return True \ No newline at end of file diff --git a/scalewiz/components/evaluation_tests_frame.py b/scalewiz/components/evaluation_tests_frame.py deleted file mode 100644 index e76f867..0000000 --- a/scalewiz/components/evaluation_tests_frame.py +++ /dev/null @@ -1,152 +0,0 @@ -from __future__ import annotations - -import tkinter as tk -from logging import Logger, getLogger -from tkinter import ttk -from typing import TYPE_CHECKING - -from scalewiz.components.test_evaluation_row import TestResultRow -from scalewiz.models.project import Project - -if TYPE_CHECKING: - from tkinter.font import Font - from typing import List - - from scalewiz.models.test import Test - - -LOGGER: Logger = getLogger("scalewiz") - - -class EvaluationTestsFrame(ttk.Frame): - """A widget for selecting devices.""" - - def __init__( - self, - parent: ttk.Frame, - project: Project, - mode: str, - font: Font, - col_labels: bool = True, - ) -> None: - super().__init__(parent) - self.tests: List[Test] = [] - self.mode: str = mode - if self.mode == "blanks": - self.label: str = "Blanks:" - elif self.mode == "trials": - self.label: str = "Trials:" - self.font: Font = font - self.project: Project = project - self.col_labels: bool = col_labels - # child TestResultRow widgets come to find these - self.baseline_len: int = None - self.name_len: int = None - self.clarity_len: int = None - self.max_psi_len: int = None - self.minutes_len: int = None - - self.build() - - def build(self) -> None: - """Build the UI.""" - for child in self.winfo_children(): - child.destroy() - self.grid_columnconfigure(0, weight=1) - - self.sort_tests() - self.set_label_lengths() - - if self.col_labels: - col_header = ttk.Frame(self) - labels = [] - labels.append( - tk.Label( - col_header, - text="Name", - font=self.font, - width=self.name_len - 5, - anchor="w", - ) - ) - labels.append( - tk.Label(col_header, text="Label", font=self.font, width=21, anchor="w") - ) - labels.append( - tk.Label( - col_header, - text="Minutes", - font=self.font, - width=self.minutes_len + 10, - anchor="w", - ) - ) - labels.append(tk.Label(col_header, text="Pump", font=self.font, anchor="w")) - labels.append( - tk.Label(col_header, text="Baseline", font=self.font, anchor="w") - ) - labels.append(tk.Label(col_header, text="Max", font=self.font, anchor="w")) - labels.append( - tk.Label(col_header, text="Clarity", font=self.font, anchor="w") - ) - labels.append( - tk.Label(col_header, text="Notes", font=self.font, anchor="w") - ) - labels.append( - tk.Label(col_header, text="Result", font=self.font, anchor="w") - ) - labels.append( - tk.Label(col_header, text="Report", font=self.font, anchor="w") - ) - labels.append(tk.Label(col_header, text=" ", font=self.font, anchor="w")) - - for i, lbl in enumerate(labels): - col_header.grid_columnconfigure(i, weight=1) - lbl.grid(row=0, column=i, padx=0, sticky="w") - col_header.grid(row=0, column=0, sticky="ew") - - type_header = ttk.Label(self, text=self.label, font=self.font) - type_header.grid(row=1, column=0, sticky="ew", padx=0, pady=1) - - for i, test in enumerate(self.tests): - row = TestResultRow(self, test, self.project) - row.grid(row=i + 2, column=0, sticky="ew") - - def sort_tests(self) -> None: - """ - Sort through the editor_project, populating the lists of blanks and trials. - """ - self.tests.clear() - for test in self.project.tests: - if self.mode == "blanks" and test.is_blank.get(): - self.tests.append(test) - elif self.mode == "trials" and not test.is_blank.get(): - self.tests.append(test) - - def set_label_lengths(self) -> None: - """Sets label length values.""" - name_len = int() - baseline_len = int() - clarity_len = int() - - for test in self.project.tests: - _name_len = len(test.name.get()) - if _name_len > name_len: - name_len = _name_len - - _baseline_len = len(str(test.observed_baseline.get())) - if _baseline_len > baseline_len: - baseline_len = _baseline_len - - _clarity_len = len(test.clarity.get()) - if _clarity_len > clarity_len: - clarity_len = _clarity_len - - minutes_len = len(str(self.project.limit_minutes.get())) - max_psi_len = len(str(self.project.limit_psi.get())) - - self.name_len = name_len - self.clarity_len = clarity_len - self.baseline_len = baseline_len - self.minutes_len = minutes_len - self.max_psi_len = max_psi_len diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index 00f8c84..214b74d 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -1,12 +1,14 @@ """Evaluation window for a Project.""" from __future__ import annotations +from scalewiz.helpers.score import score import time import tkinter as tk from logging import getLogger from pathlib import Path -from tkinter import font, ttk +from tkinter import ttk +from tkinter.scrolledtext import ScrolledText from typing import TYPE_CHECKING import matplotlib as mpl @@ -14,7 +16,7 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.ticker import MultipleLocator -from scalewiz.components.evaluation_tests_frame import EvaluationTestsFrame +from scalewiz.components.evaluation_data_view import EvaluationDataView from scalewiz.helpers.export_csv import export_csv from scalewiz.helpers.set_icon import set_icon from scalewiz.models.project import Project @@ -87,52 +89,8 @@ def build(self, reload: bool = False) -> None: self.tab_control = ttk.Notebook(self) self.tab_control.grid(row=0, column=0) - container = ttk.Frame( - self.tab_control - ) # container to be added to the tab_control - # build the header row - header_row = ttk.Frame(container) - bold_font = font.Font(family="Arial", weight="bold", size=10) - - # grid into tests_frame - header_row.grid(row=0, column=0, sticky="ew") - - # max - blanks = EvaluationTestsFrame( - container, - self.editor_project, - mode="blanks", - font=bold_font, - col_labels=True, - ) - blanks.grid(row=1, column=0, sticky="ew") - trials = EvaluationTestsFrame( - container, - self.editor_project, - mode="trials", - font=bold_font, - col_labels=False, - ) - trials.grid(row=2, column=0, sticky="ew") - - # tk.Label(header_row, text="Blanks:", font=bold_font).grid( - # row=1, column=0, sticky="w", padx=3, pady=1 - # ) - # tk.Label(header_row, 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(header_row, 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(header_row, trial, self.editor_project, i + count + 3).grid( - # row=i + count + 3, column=0, sticky="w", padx=3, pady=1 - # ) - - self.tab_control.add(container, text=" Data ") + data_view = EvaluationDataView(self.tab_control, self.editor_project) + self.tab_control.add(data_view, text=" Data ") # plot stuff ---------------------------------------------------------- self.plot() @@ -140,7 +98,7 @@ def build(self, reload: bool = False) -> None: # evaluation stuff ---------------------------------------------------- log_frame = ttk.Frame(self) log_frame.grid_columnconfigure(0, weight=1) - self.log_text = tk.scrolledtext.ScrolledText( + self.log_text = ScrolledText( log_frame, background="white", state="disabled" ) self.log_text.grid(sticky="ew") @@ -158,10 +116,13 @@ def build(self, reload: bool = False) -> None: ).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.""" + + # todo update to OOP-style matplotlib calls + # close all pyplots to prevent memory leak plt.close("all") # get rid of our old plot tab @@ -172,7 +133,7 @@ def plot(self) -> None: 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) + self.canvas.get_tk_widget().pack(fill='none', expand=False) with plt.style.context("bmh"): mpl.rcParams["axes.prop_cycle"] = mpl.cycler(color=COLORS) self.axis.grid(color="darkgrey", alpha=0.65, linestyle="-") @@ -180,8 +141,8 @@ def plot(self) -> None: self.axis.clear() # plot everything - for blank in self.blanks: - if blank.include_on_report.get(): + for blank in self.editor_project.tests: + if blank.is_blank.get() and blank.include_on_report.get(): elapsed = [] for reading in blank.readings: elapsed.append(reading.elapsedMin) @@ -192,8 +153,8 @@ def plot(self) -> None: linestyle=("-."), ) - for trial in self.trials: - if trial.include_on_report.get(): + for trial in self.editor_project.tests: + if trial.include_on_report.get() and not trial.is_blank.get(): elapsed = [] for reading in trial.readings: elapsed.append(reading.elapsedMin) @@ -206,7 +167,7 @@ def plot(self) -> None: 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.legend(loc='best') self.axis.margins(0) plt.tight_layout() @@ -241,113 +202,4 @@ def save(self) -> None: self.handler.rebuild_views() self.handler.load_project(self.editor_project.path.get()) - 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) < 1: - 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() - ) - 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}") - - 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.""" - if self.log_text.grid_info() != {}: - self.log_text.configure(state="normal") - self.log_text.delete(1.0, "end") - for msg in log: - self.log_text.insert("end", "".join((msg, "\n"))) - self.log_text.configure(state="disabled") diff --git a/scalewiz/components/test_evaluation_row.py b/scalewiz/components/test_evaluation_row.py index 545966d..51c6aba 100644 --- a/scalewiz/components/test_evaluation_row.py +++ b/scalewiz/components/test_evaluation_row.py @@ -21,12 +21,9 @@ def __init__( parent: EvaluationTestsFrame, test: Test, project: Project, - test_name_len: int = 0, ) -> None: super().__init__(parent) self.test = test - # the immediate parent will be a frame "tests_frame" in the EvaluationFrame - # self.master refers to the EvaluationFrame itself self.project = project cols: List[tk.Widget] = [] @@ -71,8 +68,8 @@ def __init__( ttk.Label( self, textvariable=self.test.observed_baseline, - width=parent.baseline_len, anchor="center", + width=5 ) ) # col 5 - max psi @@ -80,8 +77,8 @@ def __init__( ttk.Label( self, textvariable=self.test.max_psi, - width=parent.max_psi_len, anchor="center", + width=7 ) ) # col 6 - clarity @@ -89,7 +86,6 @@ def __init__( ttk.Label( self, textvariable=self.test.clarity, - width=parent.clarity_len, anchor="center", ) ) @@ -120,7 +116,7 @@ def __init__( for i, col in enumerate(cols): if i == 0: # left align the name col col.grid(row=0, column=i, padx=(3, 1), pady=1, sticky="w") - if i == 7: # make the notes col stretch + elif i == 7: # make the notes col stretch # self.grid_columnconfigure(7, weight=1) col.grid(row=0, column=i, padx=1, pady=1, sticky="ew") else: # defaults for the rest @@ -130,21 +126,6 @@ def __init__( padx=1, pady=1, ) + # self.grid_columnconfigure(i, weight=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.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.master.score) - return True diff --git a/scalewiz/helpers/score.py b/scalewiz/helpers/score.py new file mode 100644 index 0000000..59ab473 --- /dev/null +++ b/scalewiz/helpers/score.py @@ -0,0 +1,121 @@ +"""Functions for scoring Tests within a Project. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING +from time import time + +if TYPE_CHECKING: + from scalewiz.models.project import Project + from tkinter.scrolledtext import ScrolledText + from typing import List + +def score(project: Project, log_widget: ScrolledText, *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() + log: List[str] = [] + # 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: + return + + 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 = 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.append(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}") + + # todo something about this + # self.plot() + log.insert(0, f"Evaluating results for {project.name.get()}...") + to_log(log, log_widget) + +def to_log(log: list[str], log_widget: ScrolledText) -> None: + """Adds the passed log message to the Text widget in the Calculations frame.""" + 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") \ No newline at end of file From 06f28ffaf5157df99e368052f2b1415b8eda2210 Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Mon, 24 May 2021 16:23:29 -0500 Subject: [PATCH 14/49] cleaning --- scalewiz/__init__.py | 2 + scalewiz/components/evaluation_data_view.py | 87 ++++++------- scalewiz/components/evaluation_window.py | 19 ++- scalewiz/components/menu_bar.py | 2 - scalewiz/components/project_report.py | 1 - scalewiz/components/test_evaluation_row.py | 131 -------------------- scalewiz/helpers/configuration.py | 3 - scalewiz/helpers/score.py | 25 ++-- todo | 12 +- 9 files changed, 60 insertions(+), 222 deletions(-) delete mode 100644 scalewiz/components/test_evaluation_row.py diff --git a/scalewiz/__init__.py b/scalewiz/__init__.py index 07940fa..53e5019 100644 --- a/scalewiz/__init__.py +++ b/scalewiz/__init__.py @@ -1 +1,3 @@ """The parent module for the scalewiz package.""" + +# could define logger and config singletons here diff --git a/scalewiz/components/evaluation_data_view.py b/scalewiz/components/evaluation_data_view.py index 26d4160..43f00b6 100644 --- a/scalewiz/components/evaluation_data_view.py +++ b/scalewiz/components/evaluation_data_view.py @@ -1,20 +1,20 @@ """A table view to be displayed in the Evaluation Window.""" from __future__ import annotations -from scalewiz.helpers.score import score import tkinter as tk from logging import Logger, getLogger -from tkinter import ttk, messagebox +from tkinter import messagebox, ttk from tkinter.font import Font from typing import TYPE_CHECKING -from scalewiz.components.test_evaluation_row import TestResultRow +from scalewiz.helpers.score import score if TYPE_CHECKING: + from typing import List + from scalewiz.models.project import Project from scalewiz.models.test import Test - from typing import List LOGGER: Logger = getLogger("scalewiz") @@ -29,7 +29,7 @@ def __init__( project: Project, ) -> None: super().__init__(parent) - + self.eval_window = parent.master self.project = project self.trials: List[Test] = [] @@ -37,24 +37,23 @@ def __init__( self.bold_font: Font = Font(family="Arial", weight="bold", size=10) self.build() - def build(self) -> None: for child in self.winfo_children(): child.destroy() self.sort_tests() - - self.apply_col_headers() # row 0 + + 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') + 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 + 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 + 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.apply_test_row(trial, i + len_blanks + 4) # skips rows for headers def apply_col_headers(self) -> None: labels = [] @@ -63,47 +62,42 @@ def apply_col_headers(self) -> None: self, text="Name", font=self.bold_font, - anchor='w', + anchor="w", ) ) labels.append( - tk.Label(self, text="Label", font=self.bold_font, width=20, anchor='w') + tk.Label(self, text="Label", font=self.bold_font, width=20, anchor="w") ) labels.append( tk.Label( self, text="Minutes", font=self.bold_font, - anchor='center', + 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="Pump", font=self.bold_font, anchor="center")) labels.append( - tk.Label(self, text="Notes", font=self.bold_font, anchor='w') + tk.Label(self, text="Baseline PSI", font=self.bold_font, anchor="center") ) labels.append( - tk.Label(self, text="Score", font=self.bold_font, anchor='w') + tk.Label(self, text="Max PSI", font=self.bold_font, anchor="center") ) labels.append( - tk.Label(self, text="On Report", font=self.bold_font, anchor='w') + tk.Label(self, text="Water Clarity", font=self.bold_font, anchor="center") ) - labels.append(tk.Label(self, text=" ", font=self.bold_font, anchor='w')) + 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")) + 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=0, column=i, padx=0, sticky='w') + lbl.grid(row=0, column=i, padx=0, sticky="w") else: lbl.grid(row=0, column=i, padx=3, sticky="ew") - def apply_test_row(self, test, row) -> None: """Creates a row for the test and grids it.""" cols: List[tk.Widget] = [] @@ -117,7 +111,7 @@ def apply_test_row(self, test, row) -> None: textvariable=test.label, validate="focusout", validatecommand=vcmd, - width=30 + width=30, ) ) # col 2 - duration @@ -146,20 +140,12 @@ def apply_test_row(self, test, row) -> None: # col 4 - obs baseline cols.append( ttk.Label( - self, - textvariable=test.observed_baseline, - anchor="center", - width=5 + 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 - ) + ttk.Label(self, textvariable=test.max_psi, anchor="center", width=7) ) # col 6 - clarity cols.append( @@ -172,9 +158,7 @@ def apply_test_row(self, test, row) -> None: # col 7 - notes cols.append(ttk.Entry(self, textvariable=test.notes, width=30)) # col 8 - result - cols.append( - ttk.Label(self, textvariable=test.result, width=5, anchor="center") - ) + cols.append(ttk.Label(self, textvariable=test.result, width=5, anchor="center")) # col 9 - include on report cols.append( ttk.Checkbutton( @@ -184,7 +168,7 @@ def apply_test_row(self, test, row) -> None: ) ) # col 10 - delete - delete = lambda: self.remove_from_project(test) + delete = lambda: self.remove_from_project(test) # noqa: E731 cols.append( ttk.Button( self, @@ -197,11 +181,11 @@ def apply_test_row(self, test, row) -> None: 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 == 7: # make the notes col stretch + col.grid(row=row, column=i, padx=1, pady=1, sticky="ew") elif i == 10: - col.grid(row=row, column=i, padx=(5, 0), pady=1, sticky='e') - else: + 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: @@ -217,7 +201,6 @@ def sort_tests(self) -> None: 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 = ( @@ -235,4 +218,4 @@ 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(0, score(self.project, self.eval_window.log_text)) - return True \ No newline at end of file + return True diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index 214b74d..3b439ea 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -1,14 +1,12 @@ """Evaluation window for a Project.""" from __future__ import annotations -from scalewiz.helpers.score import score -import time import tkinter as tk from logging import getLogger from pathlib import Path from tkinter import ttk -from tkinter.scrolledtext import ScrolledText +from tkinter.scrolledtext import ScrolledText from typing import TYPE_CHECKING import matplotlib as mpl @@ -18,6 +16,7 @@ from scalewiz.components.evaluation_data_view import EvaluationDataView from scalewiz.helpers.export_csv import export_csv +from scalewiz.helpers.score import score from scalewiz.helpers.set_icon import set_icon from scalewiz.models.project import Project @@ -98,9 +97,7 @@ def build(self, reload: bool = False) -> None: # evaluation stuff ---------------------------------------------------- log_frame = ttk.Frame(self) log_frame.grid_columnconfigure(0, weight=1) - self.log_text = ScrolledText( - log_frame, background="white", state="disabled" - ) + self.log_text = ScrolledText(log_frame, background="white", state="disabled") self.log_text.grid(sticky="ew") self.tab_control.add(log_frame, text=" Calculations ") @@ -120,9 +117,9 @@ def build(self, reload: bool = False) -> None: def plot(self) -> None: """Destroys the old plot frame if it exists, then makes a new one.""" - + # todo update to OOP-style matplotlib calls - + # close all pyplots to prevent memory leak plt.close("all") # get rid of our old plot tab @@ -133,7 +130,7 @@ def plot(self) -> None: 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='none', expand=False) + self.canvas.get_tk_widget().pack(fill="none", expand=False) with plt.style.context("bmh"): mpl.rcParams["axes.prop_cycle"] = mpl.cycler(color=COLORS) self.axis.grid(color="darkgrey", alpha=0.65, linestyle="-") @@ -167,7 +164,7 @@ def plot(self) -> None: 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='best') + self.axis.legend(loc="best") self.axis.margins(0) plt.tight_layout() @@ -201,5 +198,3 @@ def save(self) -> None: self.editor_project.dump_json() self.handler.rebuild_views() self.handler.load_project(self.editor_project.path.get()) - - diff --git a/scalewiz/components/menu_bar.py b/scalewiz/components/menu_bar.py index a10ddfa..c011457 100644 --- a/scalewiz/components/menu_bar.py +++ b/scalewiz/components/menu_bar.py @@ -12,8 +12,6 @@ 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") diff --git a/scalewiz/components/project_report.py b/scalewiz/components/project_report.py index 956378a..c3d6f09 100644 --- a/scalewiz/components/project_report.py +++ b/scalewiz/components/project_report.py @@ -28,7 +28,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/test_evaluation_row.py b/scalewiz/components/test_evaluation_row.py deleted file mode 100644 index 51c6aba..0000000 --- a/scalewiz/components/test_evaluation_row.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Component for displaying a Test in a gridlike fashion.""" -from __future__ import annotations - -import tkinter as tk -from tkinter import messagebox, ttk -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import List - - from scalewiz.components.evaluation_tests_frame import EvaluationTestsFrame - 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: EvaluationTestsFrame, - test: Test, - project: Project, - ) -> None: - super().__init__(parent) - self.test = test - self.project = project - - cols: List[tk.Widget] = [] - # col 0 - name - cols.append(ttk.Label(self, textvariable=self.test.name, width=parent.name_len)) - # col 1 - label - cols.append( - ttk.Entry( - self, - 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, - text=f"{duration:.2f}", - width=parent.minutes_len, - anchor="center", - ) - ) - # col 3 - pump to score - to_score = ttk.Combobox( - self, - 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, - textvariable=self.test.observed_baseline, - anchor="center", - width=5 - ) - ) - # col 5 - max psi - cols.append( - ttk.Label( - self, - textvariable=self.test.max_psi, - anchor="center", - width=7 - ) - ) - # col 6 - clarity - cols.append( - ttk.Label( - self, - textvariable=self.test.clarity, - anchor="center", - ) - ) - # col 7 - notes - cols.append(ttk.Entry(self, textvariable=self.test.notes)) - # col 8 - result - cols.append( - ttk.Label(self, textvariable=self.test.result, width=5, anchor="center") - ) - # col 9 - include on report - cols.append( - ttk.Checkbutton( - self, - variable=self.test.include_on_report, - command=self.update_score, - ) - ) - # col 10 - delete - cols.append( - ttk.Button( - self, - 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=0, column=i, padx=(3, 1), pady=1, sticky="w") - elif i == 7: # make the notes col stretch - # self.grid_columnconfigure(7, weight=1) - col.grid(row=0, column=i, padx=1, pady=1, sticky="ew") - else: # defaults for the rest - col.grid( - row=0, - column=i, - padx=1, - pady=1, - ) - # self.grid_columnconfigure(i, weight=1) - - diff --git a/scalewiz/helpers/configuration.py b/scalewiz/helpers/configuration.py index 9aeca9e..79fd903 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 @@ -34,7 +32,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: diff --git a/scalewiz/helpers/score.py b/scalewiz/helpers/score.py index 59ab473..bc8d14d 100644 --- a/scalewiz/helpers/score.py +++ b/scalewiz/helpers/score.py @@ -1,22 +1,22 @@ -"""Functions for scoring Tests within a Project. +"""Functions for scoring Tests within a Project. """ from __future__ import annotations from typing import TYPE_CHECKING -from time import time if TYPE_CHECKING: - from scalewiz.models.project import Project from tkinter.scrolledtext import ScrolledText from typing import List + from scalewiz.models.project import Project + + def score(project: Project, log_widget: ScrolledText, *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() log: List[str] = [] # scoring props limit_minutes = project.limit_minutes.get() @@ -26,9 +26,7 @@ def score(project: Project, log_widget: ScrolledText, *args) -> None: 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: {project.baseline.get()} * {max_readings}") log.append(f"Baseline area: {baseline_area}") log.append("-" * 80) log.append("") @@ -64,9 +62,7 @@ def score(project: Project, log_widget: ScrolledText, *args) -> None: # 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 - ) + 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" ) @@ -97,20 +93,17 @@ def score(project: Project, log_widget: ScrolledText, *args) -> None: 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("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}") - # todo something about this - # self.plot() log.insert(0, f"Evaluating results for {project.name.get()}...") to_log(log, log_widget) + def to_log(log: list[str], log_widget: ScrolledText) -> None: """Adds the passed log message to the Text widget in the Calculations frame.""" if log_widget.winfo_exists(): @@ -118,4 +111,4 @@ def to_log(log: list[str], log_widget: ScrolledText) -> None: log_widget.delete(1.0, "end") for msg in log: log_widget.insert("end", "".join((msg, "\n"))) - log_widget.configure(state="disabled") \ No newline at end of file + log_widget.configure(state="disabled") diff --git a/todo b/todo index 511d498..93c447b 100644 --- a/todo +++ b/todo @@ -14,15 +14,15 @@ done refactoring ----------- +- eval window only needs plot calls fixed to OOP style now + done ~~~~ -- (mostly done) the way the EvaluationWindow is rendered makes my itchy and must change - - TestHandlerView refactor is pretty much done - - the LivePlot seems to not get rebuilt after a test ends - - we still need to disable all the entries while a test is running + - [fixed?] the LivePlot seems to not get rebuilt after a test ends + - [fixed?] we still need to disable all the entries while a test is running - we have a dep. on Pandas for one little call in export_csv -- could be worked around @@ -33,9 +33,11 @@ updates / new features - '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 -- refactor scoring out of eval window -- deserves its own helper func low prio -------- - port over the old chlorides / ppm calculators +- check for config missing keys ? +- check the start button disabling +- color cycle for config / projects From 5a44ab4744d3d3d4ac641ec9f961928192b41dac Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Mon, 24 May 2021 20:18:39 -0500 Subject: [PATCH 15/49] clean --- scalewiz/helpers/score.py | 16 +++++++++++----- todo | 5 +++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/scalewiz/helpers/score.py b/scalewiz/helpers/score.py index bc8d14d..5521a62 100644 --- a/scalewiz/helpers/score.py +++ b/scalewiz/helpers/score.py @@ -2,6 +2,7 @@ """ from __future__ import annotations +import tkinter as tk from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -11,13 +12,14 @@ from scalewiz.models.project import Project -def score(project: Project, log_widget: ScrolledText, *args) -> None: +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 are passed in by tkinter + # 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() @@ -36,7 +38,7 @@ def score(project: Project, log_widget: ScrolledText, *args) -> None: for test in project.tests: if test.is_blank.get() and test.include_on_report.get(): blanks.append(test) - if len(blanks) < 1: + if len(blanks) < 1: # this is bad enough to stop us, could check earlier ..? return areas_over_blanks = [] @@ -100,8 +102,12 @@ def score(project: Project, log_widget: ScrolledText, *args) -> None: log.append(f"Result: {result} \n") trial.result.set(f"{result:.2f}") - log.insert(0, f"Evaluating results for {project.name.get()}...") - to_log(log, log_widget) + log.insert( + 0, + ) + + if isinstance(log_widget, tk.Text): + to_log(log, log_widget) def to_log(log: list[str], log_widget: ScrolledText) -> None: diff --git a/todo b/todo index 93c447b..939f14f 100644 --- a/todo +++ b/todo @@ -1,6 +1,7 @@ bugs ---- +- no bugs! i think done ~~~~ @@ -9,7 +10,7 @@ done - this may be a recently introduced bug from matplotlib itself - may need to open an issue upstream if it isn't my fault related: - - current calls to matplotlib api (LivePlot.plot, EvaluationFrame.plot) are messy + - current calls to matplotlib api (EvaluationWindow.plot) are messy refactoring ----------- @@ -19,7 +20,6 @@ refactoring done ~~~~ - - TestHandlerView refactor is pretty much done - [fixed?] the LivePlot seems to not get rebuilt after a test ends - [fixed?] we still need to disable all the entries while a test is running @@ -41,3 +41,4 @@ low prio - check for config missing keys ? - check the start button disabling - color cycle for config / projects +- the score function could be hooked up to poll from a logging queue, perhaps overkill From 324c74c13a7568d509e58bd9851d98190621efd8 Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Tue, 25 May 2021 11:45:05 -0500 Subject: [PATCH 16/49] cleaning / add export dialog --- CHANGELOG.rst | 22 ++++--- scalewiz/components/evaluation_data_view.py | 27 ++++----- scalewiz/components/evaluation_window.py | 66 ++++++++++++--------- scalewiz/helpers/export_csv.py | 11 +++- scalewiz/helpers/score.py | 2 +- 5 files changed, 73 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f0dec6a..98785b5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,24 +4,27 @@ 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] +[unreleased v0.5.7] -------- Changed ~~~~~~~ +- overhaul the :code:`TestHandlerView` to be better oragnized and less bad +- overhaul the :code:`EvaluationWindow` to be better oragnized and less bad - updated :code:`EvaluationFrame` to handle the :code:`Reading` class - updated the :code:`Test` object model to handle the :code:`Reading` class -- overhaul the :code:`TestHandlerView` to be better oragnized and less bad -- replace all :code:`os.path` operations with fancy :code:`pathlib.Path` operations +- ensure exported plot dimensions are always uniform - minor performance buff to the :code:`LivePlot` component -- minor performance buffs; using sets/tuples over lists where appropriate/able -- misc. code cleanup +- minor performance buffs generally +- update all :code:`os.path` operations to fancy :code:`pathlib.Path` operations +- update all :code:`matplotlib` code to use the object oriented API +- lots of misc. code cleanup [v0.5.6] @@ -175,6 +178,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/scalewiz/components/evaluation_data_view.py b/scalewiz/components/evaluation_data_view.py index 43f00b6..db46a90 100644 --- a/scalewiz/components/evaluation_data_view.py +++ b/scalewiz/components/evaluation_data_view.py @@ -23,13 +23,8 @@ class EvaluationDataView(ttk.Frame): """A widget for selecting devices.""" - def __init__( - self, - parent: ttk.Frame, - project: Project, - ) -> None: + def __init__(self, parent: ttk.Frame, project: Project) -> None: super().__init__(parent) - self.eval_window = parent.master self.project = project self.trials: List[Test] = [] @@ -38,6 +33,7 @@ def __init__( self.build() def build(self) -> None: + """Build the UI.""" for child in self.winfo_children(): child.destroy() self.sort_tests() @@ -55,7 +51,8 @@ def build(self) -> None: for i, trial in enumerate(self.trials): self.apply_test_row(trial, i + len_blanks + 4) # skips rows for headers - def apply_col_headers(self) -> None: + def apply_col_headers(self, row: int = 0) -> None: + """Insert header labels on the passed row.""" labels = [] labels.append( tk.Label( @@ -89,16 +86,17 @@ def apply_col_headers(self) -> None: 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=0, column=i, padx=0, sticky="w") + lbl.grid(row=row, column=i, padx=0, sticky="w") else: - lbl.grid(row=0, column=i, padx=3, sticky="ew") + lbl.grid(row=row, column=i, padx=3, sticky="ew") - def apply_test_row(self, test, row) -> None: + 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) @@ -168,11 +166,10 @@ def apply_test_row(self, test, row) -> None: ) ) # col 10 - delete - delete = lambda: self.remove_from_project(test) # noqa: E731 cols.append( ttk.Button( self, - command=delete, + command=lambda: self.remove_from_project(test), # noqa: E731 text="Delete", width=7, ) @@ -189,9 +186,7 @@ def apply_test_row(self, test, row) -> None: col.grid(row=row, column=i, padx=1, pady=1) def sort_tests(self) -> None: - """ - Sort through the editor_project, populating the lists of blanks and trials. - """ + """Sort through the project, populating the lists of blanks and trials.""" self.blanks.clear() self.trials.clear() @@ -215,7 +210,7 @@ def remove_from_project(self, test: Test) -> None: self.build() def update_score(self, *args) -> True: - """Method to call score from a validation callback. Doesn't check anything.""" + """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)) return True diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index 3b439ea..664dc64 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -5,13 +5,14 @@ import tkinter as tk from logging import getLogger from pathlib import Path -from tkinter import ttk +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.figure import Figure, SubplotParams from matplotlib.ticker import MultipleLocator from scalewiz.components.evaluation_data_view import EvaluationDataView @@ -21,9 +22,7 @@ from scalewiz.models.project import Project if TYPE_CHECKING: - from typing import List - from scalewiz.models.test import Test from scalewiz.models.test_handler import TestHandler @@ -55,21 +54,11 @@ def __init__(self, handler: TestHandler) -> None: # matplotlib uses these later self.fig, self.axis, self.canvas = None, None, None self.plot_frame: ttk.Frame = None # this gets destroyed in plot() - self.trials: List[Test] = set() - self.blanks: List[Test] = set() - self.winfo_toplevel().title( - f"{self.handler.name} {self.handler.project.name.get()}" - ) + 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 Path(self.handler.project.path.get()).is_file: @@ -101,34 +90,40 @@ def build(self, reload: bool = False) -> None: self.log_text.grid(sticky="ew") self.tab_control.add(log_frame, text=" Calculations ") + # 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 - ) - ttk.Button( + save_btn = ttk.Button(button_frame, text="Save", command=self.save, width=10) + save_btn.grid(row=0, column=0, padx=5) + export_btn = ttk.Button( button_frame, text="Export", command=lambda: export_csv(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 score(self.editor_project, self.log_text) def plot(self) -> None: """Destroys the old plot frame if it exists, then makes a new one.""" - - # todo update to OOP-style matplotlib calls - # close all pyplots to prevent memory leak - plt.close("all") + if isinstance(self.fig, Figure): + plt.close(self.fig) # get rid of our old plot tab if isinstance(self.plot_frame, ttk.Frame): self.plot_frame.destroy() + self.plot_frame = ttk.Frame(self) - self.fig, self.axis = plt.subplots(figsize=(7.5, 4), dpi=100) + self.fig, self.axis = plt.subplots( + figsize=(7.5, 4), + dpi=100, + constrained_layout=True, + subplotpars=SubplotParams(wspace=0, hspace=0), + ) self.fig.patch.set_facecolor("#FAFAFA") - plt.subplots_adjust(wspace=0, hspace=0) + # plt.subplots_adjust(wspace=0, hspace=0) self.canvas = FigureCanvasTkAgg(self.fig, master=self.plot_frame) self.canvas.get_tk_widget().pack(fill="none", expand=False) with plt.style.context("bmh"): @@ -137,7 +132,7 @@ def plot(self) -> None: self.axis.set_facecolor("w") self.axis.clear() - # plot everything + # plot blanks for blank in self.editor_project.tests: if blank.is_blank.get() and blank.include_on_report.get(): elapsed = [] @@ -149,7 +144,7 @@ def plot(self) -> None: label=blank.label.get(), linestyle=("-."), ) - + # then plot trials for trial in self.editor_project.tests: if trial.include_on_report.get() and not trial.is_blank.get(): elapsed = [] @@ -166,7 +161,6 @@ def plot(self) -> None: self.axis.set_xlim((0, self.editor_project.limit_minutes.get())) self.axis.legend(loc="best") self.axis.margins(0) - plt.tight_layout() # finally, add to parent control self.tab_control.add(self.plot_frame, text=" Plot ") @@ -195,6 +189,20 @@ def save(self) -> None: with log_output.open("w") as file: file.write(self.log_text.get("1.0", "end-1c")) + # refresh self.editor_project.dump_json() - self.handler.rebuild_views() self.handler.load_project(self.editor_project.path.get()) + self.handler.rebuild_views() + + def export(self) -> None: + result, file = export_csv(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." + ), + ) diff --git a/scalewiz/helpers/export_csv.py b/scalewiz/helpers/export_csv.py index 6603b68..108c291 100644 --- a/scalewiz/helpers/export_csv.py +++ b/scalewiz/helpers/export_csv.py @@ -4,15 +4,19 @@ import logging import time from pathlib import Path +from typing import TYPE_CHECKING from pandas import DataFrame from scalewiz.models.project import Project +if TYPE_CHECKING: + from typing import Tuple + LOGGER = logging.getLogger("scalewiz") -def export_csv(project: Project) -> None: +def export_csv(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()) @@ -87,3 +91,8 @@ def export_csv(project: Project) -> None: 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/score.py b/scalewiz/helpers/score.py index 5521a62..0287df7 100644 --- a/scalewiz/helpers/score.py +++ b/scalewiz/helpers/score.py @@ -111,7 +111,7 @@ def score(project: Project, log_widget: ScrolledText = None, *args) -> None: def to_log(log: list[str], log_widget: ScrolledText) -> None: - """Adds the passed log message to the Text widget in the Calculations frame.""" + """Adds the passed log message to the passed Text widget.""" if log_widget.winfo_exists(): log_widget.configure(state="normal") log_widget.delete(1.0, "end") From f214284539daf7865ffe0f92750eced6fe661225 Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Tue, 25 May 2021 15:03:18 -0500 Subject: [PATCH 17/49] cleaning --- scalewiz/components/evaluation_data_view.py | 6 ++++-- scalewiz/components/evaluation_window.py | 6 +++--- scalewiz/helpers/export_csv.py | 7 +++---- scalewiz/helpers/score.py | 4 ---- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/scalewiz/components/evaluation_data_view.py b/scalewiz/components/evaluation_data_view.py index db46a90..36cdb2c 100644 --- a/scalewiz/components/evaluation_data_view.py +++ b/scalewiz/components/evaluation_data_view.py @@ -13,10 +13,10 @@ 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") @@ -25,7 +25,7 @@ class EvaluationDataView(ttk.Frame): def __init__(self, parent: ttk.Frame, project: Project) -> None: super().__init__(parent) - self.eval_window = parent.master + self.eval_window: EvaluationWindow = parent.master self.project = project self.trials: List[Test] = [] self.blanks: List[Test] = [] @@ -50,6 +50,7 @@ def build(self) -> None: 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.""" @@ -213,4 +214,5 @@ 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)) + self.after(0, self.eval_window.plot) return True diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index 664dc64..cec8252 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -52,7 +52,8 @@ def __init__(self, handler: TestHandler) -> None: 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.fig, self.axis, self.canvas = None, None, None # matplotlib stuff + self.log_text: ScrolledText = None self.plot_frame: ttk.Frame = None # this gets destroyed in plot() self.title(f"{self.handler.name} {self.handler.project.name.get()}") self.resizable(0, 0) @@ -129,8 +130,7 @@ def plot(self) -> None: 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() + self.axis.set_facecolor("w") # white # plot blanks for blank in self.editor_project.tests: diff --git a/scalewiz/helpers/export_csv.py b/scalewiz/helpers/export_csv.py index 108c291..def9988 100644 --- a/scalewiz/helpers/export_csv.py +++ b/scalewiz/helpers/export_csv.py @@ -1,18 +1,17 @@ """A function for exporting a representation of a Project as CSV.""" +from __future__ import annotations + import json import logging import time from pathlib import Path -from typing import TYPE_CHECKING +from typing import Tuple from pandas import DataFrame from scalewiz.models.project import Project -if TYPE_CHECKING: - from typing import Tuple - LOGGER = logging.getLogger("scalewiz") diff --git a/scalewiz/helpers/score.py b/scalewiz/helpers/score.py index 0287df7..62c47bd 100644 --- a/scalewiz/helpers/score.py +++ b/scalewiz/helpers/score.py @@ -102,10 +102,6 @@ def score(project: Project, log_widget: ScrolledText = None, *args) -> None: log.append(f"Result: {result} \n") trial.result.set(f"{result:.2f}") - log.insert( - 0, - ) - if isinstance(log_widget, tk.Text): to_log(log, log_widget) From 98ebd3ec28fcd43b1eb18c23b977829bdd5c801b Mon Sep 17 00:00:00 2001 From: teauxfu Date: Wed, 26 May 2021 10:28:33 -0500 Subject: [PATCH 18/49] yaaaas bihh!!! --- scalewiz/components/evaluation_data_view.py | 4 ++-- scalewiz/components/evaluation_window.py | 22 ++++++++++----------- scalewiz/components/test_controls.py | 3 --- scalewiz/components/test_handler_view.py | 3 ++- scalewiz/components/test_info_widget.py | 4 ++++ scalewiz/models/project.py | 2 +- scalewiz/models/test_handler.py | 3 ++- 7 files changed, 22 insertions(+), 19 deletions(-) diff --git a/scalewiz/components/evaluation_data_view.py b/scalewiz/components/evaluation_data_view.py index 36cdb2c..0ed1e7d 100644 --- a/scalewiz/components/evaluation_data_view.py +++ b/scalewiz/components/evaluation_data_view.py @@ -110,7 +110,7 @@ def apply_test_row(self, test: Test, row: int) -> None: textvariable=test.label, validate="focusout", validatecommand=vcmd, - width=30, + width=25, ) ) # col 2 - duration @@ -155,7 +155,7 @@ def apply_test_row(self, test: Test, row: int) -> None: ) ) # col 7 - notes - cols.append(ttk.Entry(self, textvariable=test.notes, width=30)) + 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 diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index cec8252..4913be2 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -75,22 +75,23 @@ def build(self, reload: bool = False) -> None: self.grid_columnconfigure(0, weight=1) # we will build a few tabs in this - self.tab_control = ttk.Notebook(self) + self.tab_control = ttk.Notebook(self, name="tab_control") self.tab_control.grid(row=0, column=0) 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 = ScrolledText(log_frame, background="white", state="disabled") - self.log_text.grid(sticky="ew") - self.tab_control.add(log_frame, text=" Calculations ") - + self.log_frame = ttk.Frame(self.tab_control, name="log_frame") + self.log_frame.grid_columnconfigure(0, weight=1) + self.log_text = ScrolledText( + self.log_frame, background="white", state="disabled", name="log_text" + ) + 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) @@ -116,11 +117,10 @@ def plot(self) -> None: if isinstance(self.plot_frame, ttk.Frame): self.plot_frame.destroy() - self.plot_frame = ttk.Frame(self) + self.plot_frame = ttk.Frame(self.tab_control, name="plot_frame") self.fig, self.axis = plt.subplots( figsize=(7.5, 4), dpi=100, - constrained_layout=True, subplotpars=SubplotParams(wspace=0, hspace=0), ) self.fig.patch.set_facecolor("#FAFAFA") @@ -164,7 +164,7 @@ def plot(self) -> None: # finally, add to parent control self.tab_control.add(self.plot_frame, text=" Plot ") - self.tab_control.insert(1, self.plot_frame) + self.tab_control.insert("end", self.plot_frame) def save(self) -> None: """Saves to file the project, most recent plot, and calculations log.""" diff --git a/scalewiz/components/test_controls.py b/scalewiz/components/test_controls.py index 40e8501..e32d337 100644 --- a/scalewiz/components/test_controls.py +++ b/scalewiz/components/test_controls.py @@ -29,15 +29,12 @@ def build(self) -> None: # row 0 col 0 start_btn = ttk.Button(self) if self.handler.is_done and not self.handler.is_running: - LOGGER.warn("building enabled new") start_btn.configure(text="New", command=self.handler.new_test) elif self.handler.is_running and not self.handler.is_done: - LOGGER.warn("building disabled new") start_btn.configure( text="New", command=self.handler.new_test, state="disabled" ) else: - LOGGER.warn("building enabled start") start_btn.configure(text="Start", command=self.handler.start_test) start_btn.grid(row=0, column=0, sticky="ew") # row 0 col 1 diff --git a/scalewiz/components/test_handler_view.py b/scalewiz/components/test_handler_view.py index 8b6ac6a..5232115 100644 --- a/scalewiz/components/test_handler_view.py +++ b/scalewiz/components/test_handler_view.py @@ -27,11 +27,12 @@ def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: super().__init__(parent) self.parent: ttk.Frame = parent self.handler: TestHandler = handler + self.plot: LivePlot = None self.build() def build(self, *args) -> None: """Builds the UI, destroying any currently existing widgets.""" - if hasattr(self, "plot"): # explicityly close to prevent memory leak + if isinstance(self.plot, LivePlot): # explicityly close to prevent memory leak self.after(0, plt.close, self.plot.fig) for child in self.winfo_children(): child.destroy() diff --git a/scalewiz/components/test_info_widget.py b/scalewiz/components/test_info_widget.py index 681b5c9..758a843 100644 --- a/scalewiz/components/test_info_widget.py +++ b/scalewiz/components/test_info_widget.py @@ -72,6 +72,10 @@ def build(self) -> None: # 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" + else: + state = "disabled" notes_ent = ttk.Entry( test_frm, textvariable=self.handler.test.notes, state=state ) diff --git a/scalewiz/models/project.py b/scalewiz/models/project.py index a0e3776..b3e3617 100644 --- a/scalewiz/models/project.py +++ b/scalewiz/models/project.py @@ -159,7 +159,7 @@ def load_json(self, path: str) -> None: 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.get("info").get("path"): LOGGER.warning( "Opened a Project whose actual path didn't match its path property" ) diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index abaecfd..6c361ea 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -293,11 +293,12 @@ def rebuild_views(self) -> None: for widget in self.editors: if widget.winfo_exists(): self.logger.debug("Rebuilding %s", widget) - widget.build(reload=True) + widget.after(0, widget.build, True) else: # clean up as we go self.editors.remove(widget) if isinstance(self.view, TestHandlerView): self.view.after(0, self.view.build) + self.logger.info("Rebuilt all view widgets") def update_log_handler(self, issues: List[str]) -> None: From 570d14f500a78b165e72a8a2884deb7a58d2aec8 Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Wed, 26 May 2021 10:44:36 -0500 Subject: [PATCH 19/49] rename components --- .../components/{test_handler_view.py => handler_view.py} | 8 ++++---- .../{test_controls.py => handler_view_controls.py} | 0 .../{devices_comboboxes.py => handler_view_devices.py} | 0 .../{test_info_widget.py => handler_view_info.py} | 0 .../components/{live_plot.py => handler_view_plot.py} | 0 .../components/{project_window.py => project_editor.py} | 0 .../{project_info.py => project_editor_info.py} | 0 .../{project_params.py => project_editor_params.py} | 0 .../{project_report.py => project_editor_report.py} | 0 scalewiz/components/scalewiz.py | 4 ++-- .../components/{log_window.py => scalewiz_log_window.py} | 0 .../components/{main_frame.py => scalewiz_main_frame.py} | 0 scalewiz/components/{menu_bar.py => scalewiz_menu_bar.py} | 0 .../{rinse_window.py => scalewiz_rinse_window.py} | 0 14 files changed, 6 insertions(+), 6 deletions(-) rename scalewiz/components/{test_handler_view.py => handler_view.py} (91%) rename scalewiz/components/{test_controls.py => handler_view_controls.py} (100%) rename scalewiz/components/{devices_comboboxes.py => handler_view_devices.py} (100%) rename scalewiz/components/{test_info_widget.py => handler_view_info.py} (100%) rename scalewiz/components/{live_plot.py => handler_view_plot.py} (100%) rename scalewiz/components/{project_window.py => project_editor.py} (100%) rename scalewiz/components/{project_info.py => project_editor_info.py} (100%) rename scalewiz/components/{project_params.py => project_editor_params.py} (100%) rename scalewiz/components/{project_report.py => project_editor_report.py} (100%) rename scalewiz/components/{log_window.py => scalewiz_log_window.py} (100%) rename scalewiz/components/{main_frame.py => scalewiz_main_frame.py} (100%) rename scalewiz/components/{menu_bar.py => scalewiz_menu_bar.py} (100%) rename scalewiz/components/{rinse_window.py => scalewiz_rinse_window.py} (100%) diff --git a/scalewiz/components/test_handler_view.py b/scalewiz/components/handler_view.py similarity index 91% rename from scalewiz/components/test_handler_view.py rename to scalewiz/components/handler_view.py index 5232115..9e7fe5f 100644 --- a/scalewiz/components/test_handler_view.py +++ b/scalewiz/components/handler_view.py @@ -8,10 +8,10 @@ from matplotlib import pyplot as plt -from scalewiz.components.devices_comboboxes import DeviceBoxes -from scalewiz.components.live_plot import LivePlot -from scalewiz.components.test_controls import TestControls -from scalewiz.components.test_info_widget import TestInfo +from scalewiz.components.handler_view_controls import TestControls +from scalewiz.components.handler_view_devices import DeviceBoxes +from scalewiz.components.handler_view_info import TestInfo +from scalewiz.components.handler_view_plot import LivePlot if TYPE_CHECKING: diff --git a/scalewiz/components/test_controls.py b/scalewiz/components/handler_view_controls.py similarity index 100% rename from scalewiz/components/test_controls.py rename to scalewiz/components/handler_view_controls.py diff --git a/scalewiz/components/devices_comboboxes.py b/scalewiz/components/handler_view_devices.py similarity index 100% rename from scalewiz/components/devices_comboboxes.py rename to scalewiz/components/handler_view_devices.py diff --git a/scalewiz/components/test_info_widget.py b/scalewiz/components/handler_view_info.py similarity index 100% rename from scalewiz/components/test_info_widget.py rename to scalewiz/components/handler_view_info.py diff --git a/scalewiz/components/live_plot.py b/scalewiz/components/handler_view_plot.py similarity index 100% rename from scalewiz/components/live_plot.py rename to scalewiz/components/handler_view_plot.py diff --git a/scalewiz/components/project_window.py b/scalewiz/components/project_editor.py similarity index 100% rename from scalewiz/components/project_window.py rename to scalewiz/components/project_editor.py diff --git a/scalewiz/components/project_info.py b/scalewiz/components/project_editor_info.py similarity index 100% rename from scalewiz/components/project_info.py rename to scalewiz/components/project_editor_info.py diff --git a/scalewiz/components/project_params.py b/scalewiz/components/project_editor_params.py similarity index 100% rename from scalewiz/components/project_params.py rename to scalewiz/components/project_editor_params.py diff --git a/scalewiz/components/project_report.py b/scalewiz/components/project_editor_report.py similarity index 100% rename from scalewiz/components/project_report.py rename to scalewiz/components/project_editor_report.py diff --git a/scalewiz/components/scalewiz.py b/scalewiz/components/scalewiz.py index 4830449..b0e7fb0 100644 --- a/scalewiz/components/scalewiz.py +++ b/scalewiz/components/scalewiz.py @@ -7,8 +7,8 @@ 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 diff --git a/scalewiz/components/log_window.py b/scalewiz/components/scalewiz_log_window.py similarity index 100% rename from scalewiz/components/log_window.py rename to scalewiz/components/scalewiz_log_window.py diff --git a/scalewiz/components/main_frame.py b/scalewiz/components/scalewiz_main_frame.py similarity index 100% rename from scalewiz/components/main_frame.py rename to scalewiz/components/scalewiz_main_frame.py diff --git a/scalewiz/components/menu_bar.py b/scalewiz/components/scalewiz_menu_bar.py similarity index 100% rename from scalewiz/components/menu_bar.py rename to scalewiz/components/scalewiz_menu_bar.py diff --git a/scalewiz/components/rinse_window.py b/scalewiz/components/scalewiz_rinse_window.py similarity index 100% rename from scalewiz/components/rinse_window.py rename to scalewiz/components/scalewiz_rinse_window.py From 01951a347da8c789544ccb3190db6a9d42639e08 Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Wed, 26 May 2021 12:36:44 -0500 Subject: [PATCH 20/49] new plot widget --- scalewiz/components/evaluation_data_view.py | 26 ++-- scalewiz/components/evaluation_plot_view.py | 139 ++++++++++++++++++++ scalewiz/components/evaluation_window.py | 83 ++---------- scalewiz/components/handler_view.py | 2 +- scalewiz/components/project_editor.py | 8 +- scalewiz/components/scalewiz_main_frame.py | 4 +- scalewiz/components/scalewiz_menu_bar.py | 4 +- scalewiz/models/test_handler.py | 7 +- 8 files changed, 175 insertions(+), 98 deletions(-) create mode 100644 scalewiz/components/evaluation_plot_view.py diff --git a/scalewiz/components/evaluation_data_view.py b/scalewiz/components/evaluation_data_view.py index 0ed1e7d..d4a0637 100644 --- a/scalewiz/components/evaluation_data_view.py +++ b/scalewiz/components/evaluation_data_view.py @@ -35,7 +35,7 @@ def __init__(self, parent: ttk.Frame, project: Project) -> None: def build(self) -> None: """Build the UI.""" for child in self.winfo_children(): - child.destroy() + self.after(0, child.destroy) self.sort_tests() self.apply_col_headers() # row 0 @@ -63,9 +63,7 @@ def apply_col_headers(self, row: int = 0) -> None: anchor="w", ) ) - labels.append( - tk.Label(self, text="Label", font=self.bold_font, width=20, anchor="w") - ) + labels.append( tk.Label( self, @@ -103,16 +101,16 @@ def apply_test_row(self, test: Test, row: int) -> None: vcmd = self.register(self.update_score) # col 0 - name cols.append(ttk.Label(self, textvariable=test.name)) - # col 1 - label - cols.append( - ttk.Entry( - self, - textvariable=test.label, - validate="focusout", - validatecommand=vcmd, - width=25, - ) - ) + # # col 1 - label + # cols.append( + # ttk.Entry( + # self, + # textvariable=test.label, + # validate="focusout", + # validatecommand=vcmd, + # width=25, + # ) + # ) # col 2 - duration duration = round( len(test.readings) * self.project.interval_seconds.get() / 60, 2 diff --git a/scalewiz/components/evaluation_plot_view.py b/scalewiz/components/evaluation_plot_view.py new file mode 100644 index 0000000..55606d2 --- /dev/null +++ b/scalewiz/components/evaluation_plot_view.py @@ -0,0 +1,139 @@ +"""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 as mpl +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") + +COLORS = ( + "orange", + "blue", + "red", + "mediumseagreen", + "darkgoldenrod", + "indigo", + "mediumvioletred", + "darkcyan", + "maroon", + "darkslategrey", +) + + +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.""" + + # close all pyplots to prevent memory leak + if isinstance(self.fig, Figure): + self.after(0, plt.close, self.fig) + # get rid of our old plot tab + + for child in self.winfo_children(): + self.after(0, child.destroy) + + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + self.grid_columnconfigure(1, weight=1) + + self.plot_frame = ttk.Frame(self, name="plot_frame") + self.fig, self.axis = plt.subplots( + figsize=(7.5, 4), + dpi=100, + subplotpars=SubplotParams(wspace=0, hspace=0, 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"): + mpl.rcParams["axes.prop_cycle"] = mpl.cycler(color=COLORS) + self.axis.grid(color="darkgrey", alpha=0.65, linestyle="-") + self.axis.set_facecolor("w") # white + + # plot blanks + for blank in self.project.tests: + if blank.is_blank.get() and 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=("-."), + ) + # then plot trials + for trial in self.project.tests: + if trial.include_on_report.get() and not trial.is_blank.get(): + elapsed = [] + for reading in trial.readings: + elapsed.append(reading.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.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") + + 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) + + for i, test in enumerate(self.project.tests): + if test.include_on_report.get(): + 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="ns") + + def update_plot(self) -> True: + """Rebuilds the plot.""" + # running into a weird race condition when rebuilding... + # this is a workaround + self.after(0, self.build) + self.after(100, self.build) + return True diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index 4913be2..f2add06 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -9,13 +9,10 @@ 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.figure import Figure, SubplotParams -from matplotlib.ticker import MultipleLocator from scalewiz.components.evaluation_data_view import EvaluationDataView +from scalewiz.components.evaluation_plot_view import EvaluationPlotView from scalewiz.helpers.export_csv import export_csv from scalewiz.helpers.score import score from scalewiz.helpers.set_icon import set_icon @@ -26,19 +23,6 @@ from scalewiz.models.test_handler import TestHandler -COLORS = ( - "orange", - "blue", - "red", - "mediumseagreen", - "darkgoldenrod", - "indigo", - "mediumvioletred", - "darkcyan", - "maroon", - "darkslategrey", -) - LOGGER = getLogger("scalewiz") @@ -54,7 +38,7 @@ def __init__(self, handler: TestHandler) -> None: # matplotlib uses these later self.fig, self.axis, self.canvas = None, None, None # matplotlib stuff self.log_text: ScrolledText = None - self.plot_frame: ttk.Frame = None # this gets destroyed in plot() + 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) @@ -71,7 +55,7 @@ def build(self, reload: bool = False) -> None: self.editor_project.load_json(self.handler.project.path.get()) for child in self.winfo_children(): - child.destroy() + self.after(0, child.destroy) self.grid_columnconfigure(0, weight=1) # we will build a few tabs in this @@ -110,61 +94,12 @@ def build(self, reload: bool = False) -> None: def plot(self) -> None: """Destroys the old plot frame if it exists, then makes a new one.""" - # close all pyplots to prevent memory leak - if isinstance(self.fig, Figure): - plt.close(self.fig) - # get rid of our old plot tab - if isinstance(self.plot_frame, ttk.Frame): - self.plot_frame.destroy() - - self.plot_frame = ttk.Frame(self.tab_control, name="plot_frame") - self.fig, self.axis = plt.subplots( - figsize=(7.5, 4), - dpi=100, - subplotpars=SubplotParams(wspace=0, hspace=0), - ) - 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="none", expand=False) - 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") # white - - # plot blanks - for blank in self.editor_project.tests: - if blank.is_blank.get() and 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=("-."), - ) - # then plot trials - for trial in self.editor_project.tests: - if trial.include_on_report.get() and not trial.is_blank.get(): - elapsed = [] - for reading in trial.readings: - elapsed.append(reading.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="best") - self.axis.margins(0) - - # finally, add to parent control - self.tab_control.add(self.plot_frame, text=" Plot ") - self.tab_control.insert("end", self.plot_frame) + if isinstance(self.plot_view, EvaluationPlotView): + plt.close(self.plot_view.fig) + self.plot_view.destroy() + + 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.""" diff --git a/scalewiz/components/handler_view.py b/scalewiz/components/handler_view.py index 9e7fe5f..8ece6a8 100644 --- a/scalewiz/components/handler_view.py +++ b/scalewiz/components/handler_view.py @@ -35,7 +35,7 @@ def build(self, *args) -> None: if isinstance(self.plot, LivePlot): # explicityly close to prevent memory leak self.after(0, plt.close, self.plot.fig) for child in self.winfo_children(): - child.destroy() + self.after(0, child.destroy) self.grid_columnconfigure(0, weight=1) # row 0 ------------------------------------------------------------------------ dev_ent = DeviceBoxes(self, self.handler) diff --git a/scalewiz/components/project_editor.py b/scalewiz/components/project_editor.py index 9fde492..2b7dfaf 100644 --- a/scalewiz/components/project_editor.py +++ b/scalewiz/components/project_editor.py @@ -7,9 +7,9 @@ from tkinter import filedialog, 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 @@ -46,7 +46,7 @@ def build(self, reload: bool = False) -> None: self.editor_project.load_json(self.handler.project.path.get()) 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) diff --git a/scalewiz/components/scalewiz_main_frame.py b/scalewiz/components/scalewiz_main_frame.py index 69a6b90..c896eee 100644 --- a/scalewiz/components/scalewiz_main_frame.py +++ b/scalewiz/components/scalewiz_main_frame.py @@ -4,8 +4,8 @@ import logging from tkinter import ttk -from scalewiz.components.menu_bar import MenuBar -from scalewiz.components.test_handler_view import TestHandlerView +from scalewiz.components.handler_view import TestHandlerView +from scalewiz.components.scalewiz_menu_bar import MenuBar from scalewiz.helpers.configuration import get_config from scalewiz.models.test_handler import TestHandler diff --git a/scalewiz/components/scalewiz_menu_bar.py b/scalewiz/components/scalewiz_menu_bar.py index c011457..210d5bb 100644 --- a/scalewiz/components/scalewiz_menu_bar.py +++ b/scalewiz/components/scalewiz_menu_bar.py @@ -8,8 +8,8 @@ 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.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") diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index 6c361ea..3ab1b57 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -15,7 +15,8 @@ from py_hplc import NextGenPump -from scalewiz.components.test_handler_view import TestHandlerView +from scalewiz.components.evaluation_data_view import LOGGER +from scalewiz.components.handler_view import TestHandlerView from scalewiz.models.project import Project from scalewiz.models.test import Reading, Test @@ -309,6 +310,10 @@ def update_log_handler(self, issues: List[str]) -> None: logs_dir = parent_dir.joinpath("logs").resolve() if not logs_dir.is_dir: logs_dir.mkdir() + if logs_dir.is_dir: + LOGGER.info("Made a new logs directory at %s", logs_dir) + else: + LOGGER.warn("Failed to make a new logs dir at %s", logs_dir) log_path = Path(logs_dir).joinpath(log_file).resolve() self.log_handler = FileHandler(log_path) except Exception as err: # bad path chars from user can bug here From 22e1beeddc8a9acfd98ac8709e4d27e509dcb406 Mon Sep 17 00:00:00 2001 From: teauxfu Date: Wed, 26 May 2021 14:46:08 -0500 Subject: [PATCH 21/49] oops --- scalewiz/components/evaluation_window.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index f2add06..b6d04d0 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -36,7 +36,6 @@ def __init__(self, handler: TestHandler) -> None: 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 # matplotlib stuff 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()}") @@ -111,7 +110,7 @@ def save(self) -> None: ) parent_dir = Path(self.editor_project.path.get()).parent plot_output = Path(parent_dir, plot_output).resolve() - self.fig.savefig(plot_output) + self.plot_view.fig.savefig(plot_output) self.editor_project.plot.set(str(plot_output)) # update log log_output = ( From cad3b99745be61e237b7d1a29f273fd7cf0ee960 Mon Sep 17 00:00:00 2001 From: teauxfu Date: Wed, 26 May 2021 15:03:39 -0500 Subject: [PATCH 22/49] ok --- scalewiz/components/evaluation_window.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index b6d04d0..5620531 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -102,6 +102,12 @@ def plot(self) -> None: def save(self) -> None: """Saves to file the project, most recent plot, and calculations log.""" + if self.handler.is_running: + messagebox.showwarning( + "Can't save right now", "Can't save while a Test is running" + ) + return + # update image plot_output = ( f"{self.editor_project.numbers.get().replace(' ', '')} " From f96613ec4e93d3523d0a84485a6a212cdd9c7905 Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Wed, 26 May 2021 15:52:30 -0500 Subject: [PATCH 23/49] - update docs/changelog - fallback to defaults when loading project - more robust log generation in handler --- CHANGELOG.rst | 17 ++++++--- doc/index.rst | 39 +++++++++++---------- scalewiz/components/evaluation_data_view.py | 17 +++------ scalewiz/components/evaluation_window.py | 3 +- scalewiz/models/project.py | 27 +++++++------- scalewiz/models/test_handler.py | 3 +- todo | 25 ++++--------- 7 files changed, 63 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 98785b5..12a2fe9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,21 +10,30 @@ Versioning `_. [unreleased v0.5.7] --------- +-------------------- Changed ~~~~~~~ +User experience concerns + - overhaul the :code:`TestHandlerView` to be better oragnized and less bad - overhaul the :code:`EvaluationWindow` to be better oragnized and less bad -- updated :code:`EvaluationFrame` to handle the :code:`Reading` class +- setting labels for each :code:`Test` is now handled in the :code:`EvaluationWindow`s' "Plot" tab +- updated docs to reflect the above + +Coding concerns + - updated the :code:`Test` object model to handle the :code:`Reading` class -- ensure exported plot dimensions are always uniform +- updated :code:`score` function to handle the :code:`Reading` class +- updated the :code:`Project` object model to be more backwards compatible +- updated the :code:`TestHandler` to be more robust when generating log files +- ensured exported plot dimensions are always uniform - minor performance buff to the :code:`LivePlot` component - minor performance buffs generally - update all :code:`os.path` operations to fancy :code:`pathlib.Path` operations - update all :code:`matplotlib` code to use the object oriented API -- lots of misc. code cleanup +- lots of misc. code cleanup / reorganizing [v0.5.6] diff --git a/doc/index.rst b/doc/index.rst index 1c7dc17..1f1223c 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -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 ~~~~~~ @@ -130,9 +133,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 +154,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 @@ -195,7 +202,3 @@ tab will appear on the main menu, and can be used normally. 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 diff --git a/scalewiz/components/evaluation_data_view.py b/scalewiz/components/evaluation_data_view.py index d4a0637..3db7d34 100644 --- a/scalewiz/components/evaluation_data_view.py +++ b/scalewiz/components/evaluation_data_view.py @@ -8,6 +8,7 @@ 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: @@ -101,16 +102,7 @@ def apply_test_row(self, test: Test, row: int) -> None: vcmd = self.register(self.update_score) # col 0 - name cols.append(ttk.Label(self, textvariable=test.name)) - # # col 1 - label - # cols.append( - # ttk.Entry( - # self, - # textvariable=test.label, - # validate="focusout", - # validatecommand=vcmd, - # width=25, - # ) - # ) + # col 2 - duration duration = round( len(test.readings) * self.project.interval_seconds.get() / 60, 2 @@ -179,7 +171,7 @@ def apply_test_row(self, test: Test, row: int) -> None: 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: + 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) @@ -212,5 +204,6 @@ 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)) - self.after(0, self.eval_window.plot) + 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_window.py b/scalewiz/components/evaluation_window.py index 5620531..21193b9 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -104,7 +104,8 @@ def save(self) -> None: """Saves to file the project, most recent plot, and calculations log.""" if self.handler.is_running: messagebox.showwarning( - "Can't save right now", "Can't save while a Test is running" + "Can't save to this Project right now", + "Can't save while a Test in this Project is running", ) return diff --git a/scalewiz/models/project.py b/scalewiz/models/project.py index b3e3617..66c9ac0 100644 --- a/scalewiz/models/project.py +++ b/scalewiz/models/project.py @@ -165,7 +165,7 @@ def load_json(self, path: str) -> None: ) obj["info"]["path"] = str(path) - info = obj.get("info") + info: dict = obj.get("info") self.customer.set(info.get("customer")) self.submitted_by.set(info.get("submittedBy")) self.client.set(info.get("productionCo")) @@ -180,18 +180,19 @@ def load_json(self, path: str) -> None: 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")) + defaults = get_config()["defaults"] + params: dict = obj.get("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.plot.set(obj.get("plot")) self.output_format.set(obj.get("outputFormat")) diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index 3ab1b57..e3d0ca9 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -305,7 +305,8 @@ def rebuild_views(self) -> None: def update_log_handler(self, issues: List[str]) -> None: """Sets up the logging FileHandler to the passed path.""" try: - log_file = f"{round(time())}_{self.test.name.get()}_{date.today()}.txt" + 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: diff --git a/todo b/todo index 939f14f..f6cc0eb 100644 --- a/todo +++ b/todo @@ -1,29 +1,17 @@ -bugs +todo ---- -- no bugs! i think +- new screenshots of eval window / plot for docs +- implement a singleton pattern for dealing with the handler/editor_project desync ? -done -~~~~ +bugs +---- -- the LivePlot currently seems rather unreliable - - this may be a recently introduced bug from matplotlib itself - - may need to open an issue upstream if it isn't my fault - related: - - current calls to matplotlib api (EvaluationWindow.plot) are messy +- eval window save only actually rebuilds every other time ? refactoring ----------- -- eval window only needs plot calls fixed to OOP style now - -done -~~~~ - -- TestHandlerView refactor is pretty much done - - [fixed?] the LivePlot seems to not get rebuilt after a test ends - - [fixed?] we still need to disable all the entries while a test is running - - we have a dep. on Pandas for one little call in export_csv -- could be worked around updates / new features @@ -39,6 +27,5 @@ low prio - port over the old chlorides / ppm calculators - check for config missing keys ? -- check the start button disabling - color cycle for config / projects - the score function could be hooked up to poll from a logging queue, perhaps overkill From f7527dde51a2829334bc9572364374b77c084d4b Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Wed, 26 May 2021 23:02:53 -0500 Subject: [PATCH 24/49] cleaning up path operations and annotations trying out cached config --- scalewiz/__init__.py | 4 + scalewiz/components/evaluation_window.py | 4 +- scalewiz/components/handler_view.py | 7 +- scalewiz/components/handler_view_controls.py | 7 +- ...vices.py => handler_view_devices_entry.py} | 0 ...iew_info.py => handler_view_info_entry.py} | 2 +- scalewiz/components/project_editor.py | 4 +- scalewiz/components/scalewiz_main_frame.py | 33 +-- scalewiz/components/scalewiz_menu_bar.py | 42 ++-- scalewiz/components/scalewiz_rinse_window.py | 7 +- scalewiz/helpers/configuration.py | 3 + scalewiz/helpers/export_csv.py | 2 +- scalewiz/helpers/score.py | 8 +- scalewiz/helpers/set_icon.py | 2 +- scalewiz/models/project.py | 16 +- scalewiz/models/test_handler.py | 201 ++++++++---------- 16 files changed, 167 insertions(+), 175 deletions(-) rename scalewiz/components/{handler_view_devices.py => handler_view_devices_entry.py} (100%) rename scalewiz/components/{handler_view_info.py => handler_view_info_entry.py} (99%) diff --git a/scalewiz/__init__.py b/scalewiz/__init__.py index 53e5019..281fcd8 100644 --- a/scalewiz/__init__.py +++ b/scalewiz/__init__.py @@ -1,3 +1,7 @@ """The parent module for the scalewiz package.""" +from scalewiz.helpers.configuration import get_config + +CONFIG: dict = get_config() + # could define logger and config singletons here diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index 21193b9..4893bdf 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -33,7 +33,7 @@ def __init__(self, handler: TestHandler) -> None: super().__init__() self.handler = handler self.editor_project = Project() - if Path(self.handler.project.path.get()).is_file: + if Path(self.handler.project.path.get()).is_file(): self.editor_project.load_json(self.handler.project.path.get()) # matplotlib uses these later self.log_text: ScrolledText = None @@ -45,7 +45,7 @@ def __init__(self, handler: TestHandler) -> None: def build(self, reload: bool = False) -> None: """Destroys all child widgets, then builds the UI.""" - if reload and Path(self.handler.project.path.get()).is_file: + 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() diff --git a/scalewiz/components/handler_view.py b/scalewiz/components/handler_view.py index 8ece6a8..ea62147 100644 --- a/scalewiz/components/handler_view.py +++ b/scalewiz/components/handler_view.py @@ -9,8 +9,8 @@ from matplotlib import pyplot as plt from scalewiz.components.handler_view_controls import TestControls -from scalewiz.components.handler_view_devices import DeviceBoxes -from scalewiz.components.handler_view_info import TestInfo +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: @@ -27,6 +27,7 @@ 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() @@ -51,7 +52,7 @@ def build(self, *args) -> None: frm.grid(row=1, column=0, sticky="new") # row 2 ------------------------------------------------------------------------ - test_info = TestInfo(self, self.handler) + test_info = TestInfoEntry(self, self.handler) test_info.grid(row=2, column=0, sticky="new") # row 3------------------------------------------------------------------------- diff --git a/scalewiz/components/handler_view_controls.py b/scalewiz/components/handler_view_controls.py index e32d337..4e02054 100644 --- a/scalewiz/components/handler_view_controls.py +++ b/scalewiz/components/handler_view_controls.py @@ -21,6 +21,7 @@ class TestControls(ttk.Frame): 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: @@ -64,13 +65,11 @@ def poll_log_queue(self) -> None: pass else: self.display(record) - interval = round(self.handler.project.interval_seconds.get() * 1000) - self.after(interval, self.poll_log_queue) + 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", msg) - self.log_text.insert("end", "\n") + 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.py b/scalewiz/components/handler_view_devices_entry.py similarity index 100% rename from scalewiz/components/handler_view_devices.py rename to scalewiz/components/handler_view_devices_entry.py diff --git a/scalewiz/components/handler_view_info.py b/scalewiz/components/handler_view_info_entry.py similarity index 99% rename from scalewiz/components/handler_view_info.py rename to scalewiz/components/handler_view_info_entry.py index 758a843..da1b18e 100644 --- a/scalewiz/components/handler_view_info.py +++ b/scalewiz/components/handler_view_info_entry.py @@ -15,7 +15,7 @@ LOGGER: Logger = getLogger("scalewiz") -class TestInfo(ttk.Frame): +class TestInfoEntry(ttk.Frame): """A widget for inputting Test information.""" def __init__(self, parent: tk.Widget, handler: TestHandler) -> None: diff --git a/scalewiz/components/project_editor.py b/scalewiz/components/project_editor.py index 2b7dfaf..ad7b9bb 100644 --- a/scalewiz/components/project_editor.py +++ b/scalewiz/components/project_editor.py @@ -28,7 +28,7 @@ def __init__(self, handler: TestHandler) -> None: super().__init__() self.handler: TestHandler = handler self.editor_project: Project = Project() - if Path(handler.project.path.get()).is_file: + if Path(handler.project.path.get()).is_file(): self.editor_project.load_json(handler.project.path.get()) self.title(f"{self.handler.name}") @@ -102,7 +102,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/scalewiz_main_frame.py b/scalewiz/components/scalewiz_main_frame.py index c896eee..363b148 100644 --- a/scalewiz/components/scalewiz_main_frame.py +++ b/scalewiz/components/scalewiz_main_frame.py @@ -1,12 +1,11 @@ -"""Main frame widget for the application.""" +"""Main frame widget for the application. Manages a Notebook of TestHandlerViews.""" -# util 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.helpers.configuration import get_config from scalewiz.models.test_handler import TestHandler LOGGER = logging.getLogger("scalewiz") @@ -16,38 +15,40 @@ class MainFrame(ttk.Frame): """Main Frame for the application.""" def __init__(self, parent: ttk.Frame) -> None: - ttk.Frame.__init__(self, parent) - self.parent = parent + 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(self) + 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} " + 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) + 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: - config = get_config() - handler.load_project(config.get("recents").get("project")) + 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 = self.nametowidget(tab) - if widget.handler.is_running and not widget.handler.is_done: + 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, diff --git a/scalewiz/components/scalewiz_menu_bar.py b/scalewiz/components/scalewiz_menu_bar.py index 210d5bb..b74021c 100644 --- a/scalewiz/components/scalewiz_menu_bar.py +++ b/scalewiz/components/scalewiz_menu_bar.py @@ -6,6 +6,7 @@ import tkinter as tk from pathlib import Path from tkinter.messagebox import showinfo +from typing import TYPE_CHECKING from scalewiz.components.evaluation_window import EvaluationWindow from scalewiz.components.project_editor import ProjectWindow @@ -14,16 +15,19 @@ LOGGER = logging.getLogger("scalewiz") +if TYPE_CHECKING: + from scalewiz.components.handler_view import TestHandlerView + 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 + self.parent = parent menubar = tk.Menu() - menubar.add_command(label="Add System", command=self.main_frame.add_handler) + 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) @@ -35,48 +39,47 @@ def __init__(self, parent: tk.Frame) -> None: 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.main_frame.parent.log_window.deiconify + 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 - # 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) + current_tab = self.parent.tab_control.select() + widget = self.parent.nametowidget(current_tab) window = ProjectWindow(widget.handler) - widget.handler.editors.append(window) + 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.main_frame.tab_control.select() - widget = self.main_frame.nametowidget(current_tab) + current_tab = self.parent.tab_control.select() + widget: TestHandlerView = self.parent.nametowidget(current_tab) window = EvaluationWindow(widget.handler) - widget.handler.editors.append(window) + 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.main_frame.tab_control.tabs(): - widget = self.main_frame.nametowidget(tab) + 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.main_frame.tab_control.select() - widget = self.main_frame.nametowidget(current_tab) - widget.handler.load_project(loaded=tuple(currently_loaded)) + 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.main_frame.tab_control.select() - widget = self.main_frame.nametowidget(current_tab) + 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) @@ -94,8 +97,7 @@ def about(self) -> None: def _debug(self) -> None: """Used for debugging.""" LOGGER.warn("DEBUGGING") - current_tab = self.main_frame.tab_control.select() - widget = self.main_frame.nametowidget(current_tab) + current_tab = self.parent.tab_control.select() + widget: TestHandlerView = self.parent.nametowidget(current_tab) widget.handler.rebuild_views() - # widget.handler.update_log_handler() widget.bell() diff --git a/scalewiz/components/scalewiz_rinse_window.py b/scalewiz/components/scalewiz_rinse_window.py index ecc9e53..4fd2292 100644 --- a/scalewiz/components/scalewiz_rinse_window.py +++ b/scalewiz/components/scalewiz_rinse_window.py @@ -16,10 +16,9 @@ class RinseWindow(tk.Toplevel): """Toplevel control that starts and stops the pumps on a timer.""" def __init__(self, handler: TestHandler) -> None: - tk.Toplevel.__init__(self) - self.winfo_toplevel().protocol("WM_DELETE_WINDOW", self.close) + super().__init__() + self.protocol("WM_DELETE_WINDOW", self.close) self.handler = handler - self.pool = ThreadPoolExecutor(max_workers=1) self.stop = False set_icon(self) @@ -44,7 +43,7 @@ def __init__(self, handler: TestHandler) -> None: def request_rinse(self) -> None: """Try to start a rinse cycle if a test isn't running.""" if not self.handler.is_running or self.handler.is_done: - self.pool.submit(self.rinse) + ThreadPoolExecutor(max_workers=1).submit(self.rinse) def rinse(self) -> None: """Run the pumps and disable the button for the duration of a timer.""" diff --git a/scalewiz/helpers/configuration.py b/scalewiz/helpers/configuration.py index 79fd903..1e2d2cf 100644 --- a/scalewiz/helpers/configuration.py +++ b/scalewiz/helpers/configuration.py @@ -10,6 +10,8 @@ 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")) @@ -149,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_csv.py index def9988..29df9c5 100644 --- a/scalewiz/helpers/export_csv.py +++ b/scalewiz/helpers/export_csv.py @@ -91,7 +91,7 @@ def export_csv(project: Project) -> Tuple[int, Path]: round(time.time() - start_time, 3), ) - if out.is_file: + if out.is_file(): return 0, out else: return 1, out diff --git a/scalewiz/helpers/score.py b/scalewiz/helpers/score.py index 62c47bd..0f958f0 100644 --- a/scalewiz/helpers/score.py +++ b/scalewiz/helpers/score.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from tkinter.scrolledtext import ScrolledText - from typing import List + from typing import List, Set from scalewiz.models.project import Project @@ -41,7 +41,7 @@ def score(project: Project, log_widget: ScrolledText = None, *args) -> None: if len(blanks) < 1: # this is bad enough to stop us, could check earlier ..? return - areas_over_blanks = [] + 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()}") @@ -59,7 +59,7 @@ def score(project: Project, log_widget: ScrolledText = None, *args) -> None: ) log.append(f"Area over blank: {area}") log.append("") - areas_over_blanks.append(area) + areas_over_blanks.add(area) # get protectable area avg_blank_area = round(sum(areas_over_blanks) / len(areas_over_blanks)) @@ -107,7 +107,7 @@ def score(project: Project, log_widget: ScrolledText = None, *args) -> None: def to_log(log: list[str], log_widget: ScrolledText) -> None: - """Adds the passed log message to the passed Text widget.""" + """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") diff --git a/scalewiz/helpers/set_icon.py b/scalewiz/helpers/set_icon.py index d025c21..3816197 100644 --- a/scalewiz/helpers/set_icon.py +++ b/scalewiz/helpers/set_icon.py @@ -16,7 +16,7 @@ def set_icon(widget: tk.Widget) -> None: # set the Toplevel's icon try: # this makes me nervous, but whatever icon_path = Path(get_resource(r"../components/icon.ico")).resolve() - if icon_path.is_file: + if icon_path.is_file(): widget.winfo_toplevel().wm_iconbitmap(icon_path) # for windows, set the taskbar icon if "nt" in os.name: diff --git a/scalewiz/models/project.py b/scalewiz/models/project.py index 66c9ac0..8cbeb22 100644 --- a/scalewiz/models/project.py +++ b/scalewiz/models/project.py @@ -8,7 +8,8 @@ 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 @@ -59,8 +60,7 @@ 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: @@ -77,7 +77,7 @@ def set_defaults(self) -> None: # this must never be <= 0 if self.interval_seconds.get() <= 0: self.interval_seconds.set(1) - self.analyst.set(config.get("recents").get("analyst")) + self.analyst.set(CONFIG.get("recents").get("analyst")) def add_traces(self) -> None: """Adds tkVar traces where needed. Must be cleaned up with remove_traces.""" @@ -153,13 +153,13 @@ def dump_json(self, path: str = None) -> None: def load_json(self, path: str) -> None: """Return a Project from a passed path to a JSON dump.""" path = Path(path).resolve() - if path.is_file: + if path.is_file(): LOGGER.info("Loading from %s", path) with path.open("r") as file: obj = json.load(file) # we expect the data files to be shared over Dropbox, etc. - if str(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" ) @@ -177,10 +177,10 @@ def load_json(self, path: str) -> None: 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.path.set(str(Path(info.get("path")).resolve())) self.notes.set(info.get("notes")) - defaults = get_config()["defaults"] + defaults = CONFIG["defaults"] params: dict = obj.get("params") self.bicarbs.set(params.get("bicarbonates", 0)) self.bicarbs_increased.set(params.get("bicarbsIncreased", False)) diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index e3d0ca9..58fb0f5 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -22,8 +22,7 @@ if TYPE_CHECKING: from logging import Logger - from tkinter import ttk - from typing import List, Tuple + from typing import List, Set class TestHandler: @@ -37,9 +36,7 @@ def __init__(self, name: str = "Nemo") -> None: self.view: TestHandlerView = None 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: Queue[Reading] = Queue() self.max_readings: int = None # max # of readings to collect self.max_psi_1: int = None self.max_psi_2: int = None @@ -51,11 +48,11 @@ def __init__(self, name: str = "Nemo") -> None: self.stop_requested: Event = Event() self.progress = tk.IntVar() self.elapsed_min: float = float() # used for evaluations - self.pump1: NextGenPump = None self.pump2: NextGenPump = None # UI concerns + self.views: List[tk.Widget] = [] # list of views displaying the project self.is_running: bool = bool() self.is_done: bool = bool() self.new_test() @@ -73,51 +70,10 @@ def can_run(self) -> bool: and not self.stop_requested.is_set() ) - def load_project( - self, path: str = None, loaded: Tuple[Path] = [], new_test: bool = True - ) -> None: - """Opens a file dialog then loads the selected Project file. - - `loaded` gets built from scratch every time it is passed in -- no need to update - """ - if path is None: - path = Path( - filedialog.askopenfilename( - initialdir='C:"', - title="Select project file:", - filetypes=[("JSON files", "*.json")], - ) - ).resolve() - else: - path = Path(path) - - # check that the dialog succeeded, the file exists, and isn't already loaded - if path != "" and 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: - # 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() - self.project = Project() - self.project.load_json(path) - if new_test: - self.new_test() - self.logger.info("Loaded %s", self.project.name.get()) - 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: - return - issues = [] - if not Path(self.project.path.get()).is_file: + if not Path(self.project.path.get()).is_file(): msg = "Select an existing project file first" issues.append(msg) @@ -146,7 +102,8 @@ def start_test(self) -> None: self.is_done = False self.is_running = True self.rebuild_views() - self.pool.submit(self.take_readings) + + ThreadPoolExecutor(max_workers=1).submit(self.take_readings) def take_readings(self) -> None: """Get ready to take readings, then start doing it on a second thread.""" @@ -156,7 +113,6 @@ def take_readings(self) -> None: self.pump1.run() self.pump2.run() rinse_start = monotonic() - sleep(step) for i in range(100): if self.can_run: self.progress.set(i) @@ -164,11 +120,9 @@ def take_readings(self) -> None: else: self.stop_test() break - self.log_queue.put("") # add newline for clarity # we use these in the loop interval = self.project.interval_seconds.get() test_start_time = monotonic() - sleep(interval) # readings loop ---------------------------------------------------------------- while self.can_run: minutes_elapsed = round((monotonic() - test_start_time) / 60, 2) @@ -204,11 +158,55 @@ def take_readings(self) -> None: 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 + # logging stuff / methods that affect UI + 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() + del self.test + self.test = Test() + with self.readings.mutex: + self.readings.queue.clear() + 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 setup_pumps(self, issues: List[str] = None) -> None: + """Set up the pumps with some default values. + Appends errors to the passed list + """ + if issues is None: + issues = [] + + if self.dev1.get() in ("", "None found"): + issues.append("Select a port for pump 1") + + if self.dev2.get() in ("", "None found"): + issues.append("Select a port for pump 2") + + if self.dev1.get() == self.dev2.get(): + issues.append("Select two unique ports") + else: + self.pump1 = NextGenPump(self.dev1.get(), self.logger) + self.pump2 = NextGenPump(self.dev2.get(), self.logger) + + 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 flowrates to %s", pump.flowrate) + def request_stop(self) -> None: """Requests that the Test stop.""" + # 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 if self.is_running: # the readings loop thread checks this flag on each iteration self.stop_requested.set() @@ -245,60 +243,14 @@ def save_test(self) -> None: self.load_project(path=self.project.path.get(), new_test=False) self.rebuild_views() - def setup_pumps(self, issues: List[str] = None) -> None: - """Set up the pumps with some default values. - Appends errors to the passed list - """ - if issues is None: - issues = [] - - if self.dev1.get() in ("", "None found"): - issues.append("Select a port for pump 1") - - if self.dev2.get() in ("", "None found"): - issues.append("Select a port for pump 2") - - if self.dev1.get() == self.dev2.get(): - issues.append("Select two unique ports") - else: - self.pump1 = NextGenPump(self.dev1.get(), self.logger) - self.pump2 = NextGenPump(self.dev2.get(), self.logger) - - 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 flowrates to %s", pump.flowrate) - - # logging stuff / methods that affect UI - 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() - del self.test - self.test = Test() - with self.readings.mutex: - self.readings.queue.clear() - 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 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.after(0, widget.build, True) else: # clean up as we go - self.editors.remove(widget) - if isinstance(self.view, TestHandlerView): - self.view.after(0, self.view.build) + self.views.remove(widget) self.logger.info("Rebuilt all view widgets") @@ -309,9 +261,9 @@ def update_log_handler(self, issues: List[str]) -> None: 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: + if not logs_dir.is_dir(): logs_dir.mkdir() - if logs_dir.is_dir: + if logs_dir.is_dir(): LOGGER.info("Made a new logs directory at %s", logs_dir) else: LOGGER.warn("Failed to make a new logs dir at %s", logs_dir) @@ -334,6 +286,37 @@ def update_log_handler(self, issues: List[str]) -> None: 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 + def load_project( + self, path: str = None, loaded: Set[Path] = [], new_test: bool = True + ) -> None: + """Opens a file dialog then loads the selected Project file. + + `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 path is not None: + 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: + # 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() + self.project = Project() + self.project.load_json(path) + if new_test: + self.new_test() + self.logger.info("Loaded %s", self.project.name.get()) From 25d36723b5308c1fa90e8564abc4f58798d8c70f Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Thu, 27 May 2021 13:12:00 -0500 Subject: [PATCH 25/49] oops --- scalewiz/__init__.py | 2 -- scalewiz/models/project.py | 16 +++++++++------- scalewiz/models/test_handler.py | 19 +++++++++---------- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/scalewiz/__init__.py b/scalewiz/__init__.py index 281fcd8..3196d73 100644 --- a/scalewiz/__init__.py +++ b/scalewiz/__init__.py @@ -3,5 +3,3 @@ from scalewiz.helpers.configuration import get_config CONFIG: dict = get_config() - -# could define logger and config singletons here diff --git a/scalewiz/models/project.py b/scalewiz/models/project.py index 8cbeb22..3582e17 100644 --- a/scalewiz/models/project.py +++ b/scalewiz/models/project.py @@ -89,7 +89,7 @@ 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 = [test for test in self.tests if test.is_blank.get()] trials = [test for test in self.tests if not test.is_blank.get()] @@ -143,12 +143,14 @@ def dump_json(self, path: str = None) -> None: "outputFormat": self.output_format.get(), "plot": str(Path(self.plot.get()).resolve()), } - - 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", 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.""" diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index 58fb0f5..00f70cc 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -15,7 +15,6 @@ from py_hplc import NextGenPump -from scalewiz.components.evaluation_data_view import LOGGER from scalewiz.components.handler_view import TestHandlerView from scalewiz.models.project import Project from scalewiz.models.test import Reading, Test @@ -62,10 +61,10 @@ 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() + self.max_psi_1 < self.project.limit_psi.get() + or self.max_psi_2 < self.project.limit_psi.get() ) - and self.elapsed_min <= self.project.limit_minutes.get() + and self.elapsed_min < self.project.limit_minutes.get() and self.readings.qsize() < self.max_readings and not self.stop_requested.is_set() ) @@ -140,7 +139,7 @@ def take_readings(self) -> None: minutes_elapsed, psi1, psi2, average ) self.log_queue.put(msg) - self.logger.info(msg) + self.logger.debug(msg) self.readings.put(reading) self.elapsed_min = minutes_elapsed @@ -238,7 +237,11 @@ def save_test(self) -> None: "saved %s readings to %s", len(self.test.readings), self.test.name.get() ) self.project.tests.append(self.test) - self.project.dump_json() + try: + self.project.dump_json() + except Exception as err: + self.logger.exception(err) + # refresh data / UI self.load_project(path=self.project.path.get(), new_test=False) self.rebuild_views() @@ -263,10 +266,6 @@ def update_log_handler(self, issues: List[str]) -> None: logs_dir = parent_dir.joinpath("logs").resolve() if not logs_dir.is_dir(): logs_dir.mkdir() - if logs_dir.is_dir(): - LOGGER.info("Made a new logs directory at %s", logs_dir) - else: - LOGGER.warn("Failed to make a new logs dir at %s", logs_dir) log_path = Path(logs_dir).joinpath(log_file).resolve() self.log_handler = FileHandler(log_path) except Exception as err: # bad path chars from user can bug here From 3395f90586ef26977d3011344cfae012bb30cae2 Mon Sep 17 00:00:00 2001 From: teauxfu Date: Thu, 27 May 2021 13:15:12 -0500 Subject: [PATCH 26/49] ok --- scalewiz/components/evaluation_plot_view.py | 9 +++-- scalewiz/components/handler_view.py | 2 +- scalewiz/components/handler_view_plot.py | 43 ++++++++++----------- 3 files changed, 26 insertions(+), 28 deletions(-) diff --git a/scalewiz/components/evaluation_plot_view.py b/scalewiz/components/evaluation_plot_view.py index 55606d2..91ddc64 100644 --- a/scalewiz/components/evaluation_plot_view.py +++ b/scalewiz/components/evaluation_plot_view.py @@ -50,14 +50,15 @@ def __init__(self, parent: ttk.Notebook, project: Project) -> None: def build(self) -> None: """Builds the UI.""" - - # close all pyplots to prevent memory leak if isinstance(self.fig, Figure): self.after(0, plt.close, self.fig) - # get rid of our old plot tab for child in self.winfo_children(): - self.after(0, child.destroy) + LOGGER.warn("looking @ %s", child) + if child.winfo_exists(): + LOGGER.warn("destroying %s", child) + self.after(0, child.destroy) + LOGGER.warn("%s exists %s", child, child.winfo_exists()) self.grid_rowconfigure(0, weight=1) self.grid_columnconfigure(0, weight=1) diff --git a/scalewiz/components/handler_view.py b/scalewiz/components/handler_view.py index ea62147..a2f3b24 100644 --- a/scalewiz/components/handler_view.py +++ b/scalewiz/components/handler_view.py @@ -33,7 +33,7 @@ def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: def build(self, *args) -> None: """Builds the UI, destroying any currently existing widgets.""" - if isinstance(self.plot, LivePlot): # explicityly close to prevent memory leak + 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) diff --git a/scalewiz/components/handler_view_plot.py b/scalewiz/components/handler_view_plot.py index f4e72b2..a1732e0 100644 --- a/scalewiz/components/handler_view_plot.py +++ b/scalewiz/components/handler_view_plot.py @@ -44,29 +44,26 @@ def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: def animate(self, interval: float) -> None: """Animates the live plot if a test isn't running. - The interval argument is used by matplotlib internally + The interval argument is used by matplotlib internally. """ # we can just skip this if the test isn't running if self.handler.is_running and not self.handler.is_done: - if self.handler.readings.qsize() > 0: - LOGGER.debug("%s: Drawing a new plot ...", self.handler.name) - pump1 = [] - pump2 = [] - elapsed = [] # we will share this series as an axis - readings = tuple(self.handler.readings.queue) - 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") + pump1 = [] + pump2 = [] + elapsed = [] # we will share this series as an axis + for reading in tuple(self.handler.readings.queue): + 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") From e4ae76483838880a90263a064af1658f7abd245d Mon Sep 17 00:00:00 2001 From: teauxfu Date: Thu, 27 May 2021 15:53:37 -0500 Subject: [PATCH 27/49] ok --- scalewiz/components/evaluation_plot_view.py | 4 +++ scalewiz/components/evaluation_window.py | 3 ++- scalewiz/components/handler_view.py | 2 +- .../components/handler_view_devices_entry.py | 4 --- .../components/handler_view_info_entry.py | 2 +- scalewiz/components/handler_view_plot.py | 8 +++++- scalewiz/components/project_editor.py | 15 ++++++----- scalewiz/models/test_handler.py | 25 +++++++++++-------- 8 files changed, 39 insertions(+), 24 deletions(-) diff --git a/scalewiz/components/evaluation_plot_view.py b/scalewiz/components/evaluation_plot_view.py index 91ddc64..ff3dde1 100644 --- a/scalewiz/components/evaluation_plot_view.py +++ b/scalewiz/components/evaluation_plot_view.py @@ -50,6 +50,10 @@ def __init__(self, parent: ttk.Notebook, project: Project) -> None: def build(self) -> None: """Builds the UI.""" + if not self.winfo_exists(): + LOGGER.warn("im not real ???/") + return # ?????????/ + if isinstance(self.fig, Figure): self.after(0, plt.close, self.fig) diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index 4893bdf..c62e7d3 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -54,7 +54,8 @@ def build(self, reload: bool = False) -> None: self.editor_project.load_json(self.handler.project.path.get()) for child in self.winfo_children(): - self.after(0, child.destroy) + if child.winfo_exists(): + child.destroy() self.grid_columnconfigure(0, weight=1) # we will build a few tabs in this diff --git a/scalewiz/components/handler_view.py b/scalewiz/components/handler_view.py index a2f3b24..d985335 100644 --- a/scalewiz/components/handler_view.py +++ b/scalewiz/components/handler_view.py @@ -31,7 +31,7 @@ def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: self.plot: LivePlot = None self.build() - def build(self, *args) -> None: + def build(self, **kwargs) -> None: # noqa """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) diff --git a/scalewiz/components/handler_view_devices_entry.py b/scalewiz/components/handler_view_devices_entry.py index 037abae..cc51ff9 100644 --- a/scalewiz/components/handler_view_devices_entry.py +++ b/scalewiz/components/handler_view_devices_entry.py @@ -73,10 +73,6 @@ def update_devices_list(self, *args) -> None: 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.parent.handler.name, self.devices_list diff --git a/scalewiz/components/handler_view_info_entry.py b/scalewiz/components/handler_view_info_entry.py index da1b18e..d13a74d 100644 --- a/scalewiz/components/handler_view_info_entry.py +++ b/scalewiz/components/handler_view_info_entry.py @@ -74,7 +74,7 @@ def build(self) -> None: notes_lbl.grid(row=1, column=0, sticky="ew") if self.handler.is_running or not self.handler.is_done: state = "normal" - else: + elif self.handler.is_done: state = "disabled" notes_ent = ttk.Entry( test_frm, textvariable=self.handler.test.notes, state=state diff --git a/scalewiz/components/handler_view_plot.py b/scalewiz/components/handler_view_plot.py index a1732e0..3982c48 100644 --- a/scalewiz/components/handler_view_plot.py +++ b/scalewiz/components/handler_view_plot.py @@ -24,6 +24,12 @@ 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, @@ -37,7 +43,7 @@ def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: 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(handler.project.interval_seconds.get() * 1000) # -> ms + 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 diff --git a/scalewiz/components/project_editor.py b/scalewiz/components/project_editor.py index ad7b9bb..05ff248 100644 --- a/scalewiz/components/project_editor.py +++ b/scalewiz/components/project_editor.py @@ -4,7 +4,7 @@ import tkinter as tk from pathlib import Path -from tkinter import filedialog, ttk +from tkinter import filedialog, messagebox, ttk from typing import TYPE_CHECKING from scalewiz.components.project_editor_info import ProjectInfo @@ -82,12 +82,15 @@ 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() + 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.""" diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index 00f70cc..e514f57 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -49,6 +49,7 @@ def __init__(self, name: str = "Nemo") -> None: self.elapsed_min: float = float() # used for evaluations self.pump1: NextGenPump = None self.pump2: NextGenPump = None + self.pool = ThreadPoolExecutor(max_workers=1) # UI concerns self.views: List[tk.Widget] = [] # list of views displaying the project @@ -102,7 +103,7 @@ def start_test(self) -> None: self.is_running = True self.rebuild_views() - ThreadPoolExecutor(max_workers=1).submit(self.take_readings) + self.pool.submit(self.take_readings) def take_readings(self) -> None: """Get ready to take readings, then start doing it on a second thread.""" @@ -117,7 +118,7 @@ def take_readings(self) -> None: self.progress.set(i) sleep(step - ((monotonic() - rinse_start) % step)) else: - self.stop_test() + self.stop_test(save=False) break # we use these in the loop interval = self.project.interval_seconds.get() @@ -154,8 +155,8 @@ def take_readings(self) -> None: # TYSM https://stackoverflow.com/a/25251804 sleep(interval - ((monotonic() - test_start_time) % interval)) # end of readings loop --------------------------------------------------------- - self.stop_test() - self.save_test() + self.logger.warn("about to request saving") + self.stop_test(save=True) # logging stuff / methods that affect UI def new_test(self) -> None: @@ -211,7 +212,7 @@ def request_stop(self) -> None: self.stop_requested.set() self.logger.info("Received a stop request") - def stop_test(self) -> None: + def stop_test(self, save: bool = True) -> None: """Stops the pumps, closes their ports.""" for pump in (self.pump1, self.pump2): if pump.is_open: @@ -224,16 +225,20 @@ def stop_test(self) -> None: self.is_done = True self.is_running = False - self.logger.info("Test for %s has been stopped", self.test.name.get()) + self.logger.warn("Test for %s has been stopped", self.test.name.get()) for _ in range(3): - self.view.bell() + self.views[0].bell() + if save: + self.logger.warn("TRYING TO SAVE") + self.save_test() self.rebuild_views() def save_test(self) -> None: """Saves the test to the Project file in JSON format.""" + self.logger.warn("TRYING TO SAVE") for reading in tuple(self.readings.queue): self.test.readings.append(reading) - self.logger.info( + self.logger.warn( "saved %s readings to %s", len(self.test.readings), self.test.name.get() ) self.project.tests.append(self.test) @@ -244,14 +249,14 @@ def save_test(self) -> None: # refresh data / UI self.load_project(path=self.project.path.get(), new_test=False) - self.rebuild_views() + # self.rebuild_views() def rebuild_views(self) -> None: """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.after(0, widget.build, True) + widget.build(reload=True) else: # clean up as we go self.views.remove(widget) From 09e18813ffb88c48601e7236bf6e69229959eb34 Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Thu, 27 May 2021 16:27:41 -0500 Subject: [PATCH 28/49] prep for using root mainloop --- scalewiz/components/handler_view.py | 2 +- scalewiz/components/scalewiz_main_frame.py | 4 +++- scalewiz/models/test_handler.py | 5 ++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/scalewiz/components/handler_view.py b/scalewiz/components/handler_view.py index d985335..b398d8d 100644 --- a/scalewiz/components/handler_view.py +++ b/scalewiz/components/handler_view.py @@ -31,7 +31,7 @@ def __init__(self, parent: ttk.Frame, handler: TestHandler) -> None: self.plot: LivePlot = None self.build() - def build(self, **kwargs) -> None: # noqa + 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) diff --git a/scalewiz/components/scalewiz_main_frame.py b/scalewiz/components/scalewiz_main_frame.py index 363b148..553d577 100644 --- a/scalewiz/components/scalewiz_main_frame.py +++ b/scalewiz/components/scalewiz_main_frame.py @@ -29,7 +29,9 @@ def build(self) -> None: 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()) + handler = TestHandler( + name=system_name.strip(), root=self.winfo_toplevel().master + ) self.tab_control.add( TestHandlerView(self.tab_control, handler), sticky="nsew", text=system_name ) diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index e514f57..d513b92 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -15,7 +15,6 @@ from py_hplc import NextGenPump -from scalewiz.components.handler_view import TestHandlerView from scalewiz.models.project import Project from scalewiz.models.test import Reading, Test @@ -29,10 +28,10 @@ class TestHandler: # pylint: disable=too-many-instance-attributes - def __init__(self, name: str = "Nemo") -> None: + def __init__(self, root: tk.Tk, name: str = "Nemo") -> None: self.name = name + self.root: tk.Tk = root self.logger: Logger = getLogger(f"scalewiz.{name}") - self.view: TestHandlerView = None self.project: Project = Project() self.test: Test = None self.readings: Queue[Reading] = Queue() From 1baf6bf3bddb87e146eac6344151c01b856fb0cd Mon Sep 17 00:00:00 2001 From: teauxfu Date: Fri, 28 May 2021 14:08:06 -0500 Subject: [PATCH 29/49] cleaning --- scalewiz/__init__.py | 8 ++ scalewiz/__main__.py | 2 + scalewiz/components/evaluation_plot_view.py | 81 +++++++++++---------- scalewiz/components/evaluation_window.py | 16 ++-- scalewiz/components/project_editor.py | 4 +- scalewiz/components/scalewiz_main_frame.py | 4 +- scalewiz/models/project.py | 14 ++-- scalewiz/models/test.py | 12 +-- scalewiz/models/test_handler.py | 42 +++++++---- 9 files changed, 103 insertions(+), 80 deletions(-) diff --git a/scalewiz/__init__.py b/scalewiz/__init__.py index 3196d73..e8cdd3e 100644 --- a/scalewiz/__init__.py +++ b/scalewiz/__init__.py @@ -1,5 +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 0d9ae78..32d0849 100644 --- a/scalewiz/__main__.py +++ b/scalewiz/__main__.py @@ -2,12 +2,14 @@ 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 = root ScaleWiz(root).grid(sticky="nsew") root.mainloop() diff --git a/scalewiz/components/evaluation_plot_view.py b/scalewiz/components/evaluation_plot_view.py index ff3dde1..27c97e1 100644 --- a/scalewiz/components/evaluation_plot_view.py +++ b/scalewiz/components/evaluation_plot_view.py @@ -51,24 +51,48 @@ def __init__(self, parent: ttk.Notebook, project: Project) -> None: def build(self) -> None: """Builds the UI.""" if not self.winfo_exists(): - LOGGER.warn("im not real ???/") - return # ?????????/ + return if isinstance(self.fig, Figure): - self.after(0, plt.close, self.fig) + plt.close(self.fig) for child in self.winfo_children(): - LOGGER.warn("looking @ %s", child) if child.winfo_exists(): - LOGGER.warn("destroying %s", child) - self.after(0, child.destroy) - LOGGER.warn("%s exists %s", child, child.winfo_exists()) + child.destroy() self.grid_rowconfigure(0, weight=1) self.grid_columnconfigure(0, weight=1) self.grid_columnconfigure(1, weight=1) - self.plot_frame = ttk.Frame(self, name="plot_frame") + 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 test in tests_on_report: + label_ent = ttk.Entry( + label_frame, + textvariable=test.label, + validate="focusout", + validatecommand=vcmd, + width=25, + ) + label_ent.grid(row=test.index.get() + 1, column=0, sticky="ew", pady=2) + + label_frame.grid(row=0, column=1, sticky="ns") + + print("plottings") + + self.plot_frame = ttk.Frame(self) self.fig, self.axis = plt.subplots( figsize=(7.5, 4), dpi=100, @@ -83,26 +107,24 @@ def build(self) -> None: self.axis.set_facecolor("w") # white # plot blanks - for blank in self.project.tests: - if blank.is_blank.get() and blank.include_on_report.get(): + for test in tests_on_report: + if test.is_blank.get(): elapsed = [] - for reading in blank.readings: + for reading in test.readings: elapsed.append(reading.elapsedMin) self.axis.plot( elapsed, - blank.get_readings(), - label=blank.label.get(), + test.get_readings(), + label=test.label.get(), linestyle=("-."), ) # then plot trials - for trial in self.project.tests: - if trial.include_on_report.get() and not trial.is_blank.get(): + for test in tests_on_report: + if not test.is_blank.get(): elapsed = [] - for reading in trial.readings: + for reading in test.readings: elapsed.append(reading.elapsedMin) - self.axis.plot( - elapsed, trial.get_readings(), label=trial.label.get() - ) + self.axis.plot(elapsed, test.get_readings(), label=test.label.get()) self.axis.set_xlabel("Time (min)") self.axis.set_ylabel("Pressure (psi)") @@ -114,27 +136,6 @@ def build(self) -> None: self.plot_frame.grid(row=0, column=0, sticky="n") - 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) - - for i, test in enumerate(self.project.tests): - if test.include_on_report.get(): - 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="ns") - def update_plot(self) -> True: """Rebuilds the plot.""" # running into a weird race condition when rebuilding... diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index c62e7d3..0dbf5dd 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -45,21 +45,23 @@ def __init__(self, handler: TestHandler) -> None: def build(self, reload: bool = False) -> None: """Destroys all child widgets, then builds the UI.""" + 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() + del self.editor_project self.editor_project = Project() self.editor_project.load_json(self.handler.project.path.get()) for child in self.winfo_children(): if child.winfo_exists(): - child.destroy() + 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, name="tab_control") + self.tab_control = ttk.Notebook(self) self.tab_control.grid(row=0, column=0) data_view = EvaluationDataView(self.tab_control, self.editor_project) @@ -68,10 +70,10 @@ def build(self, reload: bool = False) -> None: # plot stuff ---------------------------------------------------------- # evaluation stuff ---------------------------------------------------- - self.log_frame = ttk.Frame(self.tab_control, name="log_frame") + self.log_frame = ttk.Frame(self.tab_control) self.log_frame.grid_columnconfigure(0, weight=1) self.log_text = ScrolledText( - self.log_frame, background="white", state="disabled", name="log_text" + self.log_frame, background="white", state="disabled" ) self.log_text.grid(sticky="nsew") self.tab_control.add(self.log_frame, text=" Calculations ") @@ -134,7 +136,7 @@ def save(self) -> None: # refresh self.editor_project.dump_json() self.handler.load_project(self.editor_project.path.get()) - self.handler.rebuild_views() + self.after(0, self.handler.rebuild_views) def export(self) -> None: result, file = export_csv(self.editor_project) diff --git a/scalewiz/components/project_editor.py b/scalewiz/components/project_editor.py index 05ff248..42bb72e 100644 --- a/scalewiz/components/project_editor.py +++ b/scalewiz/components/project_editor.py @@ -38,10 +38,8 @@ def __init__(self, handler: TestHandler) -> None: 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 + del self.editor_project self.editor_project = Project() self.editor_project.load_json(self.handler.project.path.get()) diff --git a/scalewiz/components/scalewiz_main_frame.py b/scalewiz/components/scalewiz_main_frame.py index 553d577..363b148 100644 --- a/scalewiz/components/scalewiz_main_frame.py +++ b/scalewiz/components/scalewiz_main_frame.py @@ -29,9 +29,7 @@ def build(self) -> None: 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(), root=self.winfo_toplevel().master - ) + handler = TestHandler(name=system_name.strip()) self.tab_control.add( TestHandlerView(self.tab_control, handler), sticky="nsew", text=system_name ) diff --git a/scalewiz/models/project.py b/scalewiz/models/project.py index 3582e17..b7194b1 100644 --- a/scalewiz/models/project.py +++ b/scalewiz/models/project.py @@ -92,22 +92,23 @@ def dump_json(self, path: str = None) -> None: path = Path(self.path.get()) 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]) + trials = [test for test in self.tests if not test.is_blank.get()] 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: + for test in blanks: + if label == test.label.get().lower(): tests.append(test) for label in trial_labels: - for test in self.tests: - if test.label.get().lower() == label: + for test in trials: + if label == test.label.get().lower(): tests.append(test) self.tests.clear() for test in tests: + test.index.set(tests.index(test)) self.tests.append(test) this = { @@ -204,6 +205,7 @@ def load_json(self, path: str) -> None: test = Test() test.load_json(entry) self.tests.append(test) + LOGGER.info("finished loading") def remove_traces(self) -> None: """Remove tkVar traces to allow the GC to do its thing.""" @@ -213,6 +215,8 @@ 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.""" diff --git a/scalewiz/models/test.py b/scalewiz/models/test.py index fca7c94..a9dfbc4 100644 --- a/scalewiz/models/test.py +++ b/scalewiz/models/test.py @@ -99,10 +99,10 @@ def load_json(self, obj: dict[str, Union[bool, float, int, str]]) -> None: for reading in readings: self.readings.append( Reading( - pump1=reading.get("pump 1"), - pump2=reading.get("pump 2"), - average=reading.get("average"), - elapsedMin=reading.get("elapsedMin"), + pump1=reading["pump 1"], + pump2=reading["pump 2"], + average=reading["average"], + elapsedMin=reading["elapsedMin"], ) ) self.update_obs_baseline() @@ -146,5 +146,5 @@ def remove_traces(self) -> None: for var in variables: try: var.trace_remove("write", var.trace_info()[0][1]) - except IndexError as err: # sometimes this spaghets on empty projects... - LOGGER.exception(err) # just pass and move on + except IndexError: # sometimes this spaghets on empty projects... + pass diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index d513b92..1a2788d 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -15,6 +15,7 @@ from py_hplc import NextGenPump +import scalewiz from scalewiz.models.project import Project from scalewiz.models.test import Reading, Test @@ -28,9 +29,9 @@ class TestHandler: # pylint: disable=too-many-instance-attributes - def __init__(self, root: tk.Tk, name: str = "Nemo") -> None: + def __init__(self, name: str = "Nemo") -> None: self.name = name - self.root: tk.Tk = root + self.root: tk.Tk = scalewiz.ROOT self.logger: Logger = getLogger(f"scalewiz.{name}") self.project: Project = Project() self.test: Test = None @@ -104,8 +105,8 @@ def start_test(self) -> None: self.pool.submit(self.take_readings) - def take_readings(self) -> None: - """Get ready to take readings, then start doing it on a second thread.""" + def uptake_cycle(self) -> None: + """Get ready to take readings.""" # run the uptake cycle --------------------------------------------------------- uptake = self.project.uptake_seconds.get() step = uptake / 100 # we will sleep for 100 steps @@ -120,11 +121,16 @@ def take_readings(self) -> None: self.stop_test(save=False) break # we use these in the loop - interval = self.project.interval_seconds.get() - test_start_time = monotonic() + self.take_readings() + + def take_readings(self, start_time: float = None, interval: float = None) -> None: + if start_time is None: + start_time = monotonic() + if interval is None: + interval = self.project.interval_seconds.get() # readings loop ---------------------------------------------------------------- - while self.can_run: - minutes_elapsed = round((monotonic() - test_start_time) / 60, 2) + if self.can_run: + minutes_elapsed = round((monotonic() - start_time) / 60, 2) psi1 = self.pump1.pressure psi2 = self.pump2.pressure @@ -152,10 +158,16 @@ 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.logger.warn("about to request saving") - self.stop_test(save=True) + self.root.after( + interval - ((monotonic() - start_time) % interval), + self.take_readings, + start_time=start_time, + interval=interval, + ) + else: + # end of readings loop ----------------------------------------------------- + self.logger.warn("about to request saving") + self.stop_test(save=True) # logging stuff / methods that affect UI def new_test(self) -> None: @@ -255,7 +267,7 @@ def rebuild_views(self) -> None: for widget in self.views: if widget.winfo_exists(): self.logger.debug("Rebuilding %s", widget) - widget.build(reload=True) + self.root.after(0, lambda: widget.build(reload=True)) else: # clean up as we go self.views.remove(widget) @@ -314,10 +326,8 @@ def load_project( else: # 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() + del self.project self.project = Project() self.project.load_json(path) if new_test: From 4e481fcf250125f800690c3363437f3572eb5898 Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Fri, 28 May 2021 14:54:04 -0500 Subject: [PATCH 30/49] . --- scalewiz/models/test_handler.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index 1a2788d..d1e30cd 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -102,8 +102,7 @@ def start_test(self) -> None: self.is_done = False self.is_running = True self.rebuild_views() - - self.pool.submit(self.take_readings) + self.uptake_cycle() def uptake_cycle(self) -> None: """Get ready to take readings.""" From d08d5bf8c3b4c52839242887ec6ba0d181b6b551 Mon Sep 17 00:00:00 2001 From: teauxfu Date: Fri, 28 May 2021 15:35:20 -0500 Subject: [PATCH 31/49] almost! --- scalewiz/components/evaluation_plot_view.py | 4 +-- scalewiz/models/project.py | 1 - scalewiz/models/test_handler.py | 37 +++++++++++++-------- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/scalewiz/components/evaluation_plot_view.py b/scalewiz/components/evaluation_plot_view.py index 27c97e1..a21bd2e 100644 --- a/scalewiz/components/evaluation_plot_view.py +++ b/scalewiz/components/evaluation_plot_view.py @@ -78,7 +78,7 @@ def build(self) -> None: if test.include_on_report.get(): tests_on_report.append(test) - for test in tests_on_report: + for i, test in enumerate(tests_on_report): label_ent = ttk.Entry( label_frame, textvariable=test.label, @@ -86,7 +86,7 @@ def build(self) -> None: validatecommand=vcmd, width=25, ) - label_ent.grid(row=test.index.get() + 1, column=0, sticky="ew", pady=2) + label_ent.grid(row=i + 1, column=0, sticky="ew", pady=2) label_frame.grid(row=0, column=1, sticky="ns") diff --git a/scalewiz/models/project.py b/scalewiz/models/project.py index b7194b1..fa4a1a4 100644 --- a/scalewiz/models/project.py +++ b/scalewiz/models/project.py @@ -108,7 +108,6 @@ def dump_json(self, path: str = None) -> None: self.tests.clear() for test in tests: - test.index.set(tests.index(test)) self.tests.append(test) this = { diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index d1e30cd..2c550ab 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -9,7 +9,7 @@ from pathlib import Path from queue import Queue from threading import Event -from time import monotonic, sleep, time +from time import monotonic, time from tkinter import filedialog, messagebox from typing import TYPE_CHECKING @@ -107,26 +107,35 @@ def start_test(self) -> None: def uptake_cycle(self) -> None: """Get ready to take readings.""" # run the uptake cycle --------------------------------------------------------- - uptake = self.project.uptake_seconds.get() - step = uptake / 100 # we will sleep for 100 steps + uptake = self.project.uptake_seconds.get() * 1000 # ms + ms_step = round((uptake / 100)) # we will sleep for 100 steps self.pump1.run() self.pump2.run() - rinse_start = monotonic() - for i in range(100): + + def cycle(start, i, step) -> None: if self.can_run: - self.progress.set(i) - sleep(step - ((monotonic() - rinse_start) % step)) + if i < 100: + i += 1 + self.progress.set(i) + self.root.after( + round(step - ((monotonic() - start) % step)), + cycle, + start, + i, + step, + ) + else: + self.take_readings() else: self.stop_test(save=False) - break - # we use these in the loop - self.take_readings() + + cycle(monotonic(), 0, ms_step) def take_readings(self, start_time: float = None, interval: float = None) -> None: if start_time is None: start_time = monotonic() if interval is None: - interval = self.project.interval_seconds.get() + interval = self.project.interval_seconds.get() * 1000 # readings loop ---------------------------------------------------------------- if self.can_run: minutes_elapsed = round((monotonic() - start_time) / 60, 2) @@ -158,10 +167,10 @@ def take_readings(self, start_time: float = None, interval: float = None) -> Non # TYSM https://stackoverflow.com/a/25251804 self.root.after( - interval - ((monotonic() - start_time) % interval), + round(interval - ((monotonic() - start_time) % interval)), self.take_readings, - start_time=start_time, - interval=interval, + start_time, + interval, ) else: # end of readings loop ----------------------------------------------------- From 43f4d56dc895938c80d142f68f1883fab4f82434 Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Fri, 28 May 2021 19:25:05 -0500 Subject: [PATCH 32/49] update to single-threaded rinse and readings --- scalewiz/components/scalewiz_rinse_window.py | 52 +++++---- scalewiz/models/test_handler.py | 106 +++++++++---------- 2 files changed, 81 insertions(+), 77 deletions(-) diff --git a/scalewiz/components/scalewiz_rinse_window.py b/scalewiz/components/scalewiz_rinse_window.py index 4fd2292..ecd98d0 100644 --- a/scalewiz/components/scalewiz_rinse_window.py +++ b/scalewiz/components/scalewiz_rinse_window.py @@ -1,9 +1,8 @@ """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 monotonic from tkinter import ttk from scalewiz.helpers.set_icon import set_icon @@ -42,26 +41,37 @@ def __init__(self, handler: TestHandler) -> None: def request_rinse(self) -> None: """Try to start a rinse cycle if a test isn't running.""" - if not self.handler.is_running or self.handler.is_done: - ThreadPoolExecutor(max_workers=1).submit(self.rinse) - - def rinse(self) -> None: + if self.handler.is_done or not self.handler.is_running: + self.handler.setup_pumps() + self.handler.pump1.run() + self.handler.pump2.run() + self.button.configure(state="disabled") + self.rinse(round(self.rinse_minutes.get() * 60 * 1000)) + + def rinse(self, duration_ms: int) -> None: """Run the pumps and disable the button for the duration of a timer.""" - self.handler.setup_pumps() - self.handler.pump1.run() - self.handler.pump2.run() - - self.button.configure(state="disabled") - duration = 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) - else: - break - self.bell() - self.end_rinse() - self.button.configure(state="normal") + step_ms = round((duration_ms / 100)) # we will sleep for 100 steps + self.pump1.run() + self.pump2.run() + + def cycle(start, i, step_ms) -> None: + if self.can_run: + if i < 100: + i += 1 + self.txt.set(f"{i+1}/{duration_ms/1000:.0f} s") + self.root.after( + round(step_ms - ((monotonic() - start) % step_ms)), + cycle, + start, + i, + step_ms, + ) + else: + self.bell() + self.handler.stop_test(rinsing=True) + self.button.configure(state="normal") + + cycle(monotonic(), 0, step_ms) def end_rinse(self) -> None: """Stop the pumps if they are running, then close their ports.""" diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index 2c550ab..d207347 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -8,7 +8,6 @@ from logging import DEBUG, FileHandler, Formatter, getLogger from pathlib import Path from queue import Queue -from threading import Event from time import monotonic, time from tkinter import filedialog, messagebox from typing import TYPE_CHECKING @@ -21,7 +20,7 @@ if TYPE_CHECKING: from logging import Logger - from typing import List, Set + from typing import List, Set, Union class TestHandler: @@ -44,7 +43,7 @@ def __init__(self, name: str = "Nemo") -> None: 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: float = float() # used for evaluations self.pump1: NextGenPump = None @@ -67,7 +66,7 @@ def can_run(self) -> bool: ) and self.elapsed_min < self.project.limit_minutes.get() and self.readings.qsize() < self.max_readings - and not self.stop_requested.is_set() + and not self.stop_requested ) def start_test(self) -> None: @@ -98,31 +97,30 @@ def start_test(self) -> None: for pump in (self.pump1, self.pump2): pump.close() else: - self.stop_requested.clear() + self.stop_requested = False self.is_done = False self.is_running = True self.rebuild_views() - self.uptake_cycle() + self.uptake_cycle(self.project.uptake_seconds.get() * 1000) - def uptake_cycle(self) -> None: + def uptake_cycle(self, duration_ms: int) -> None: """Get ready to take readings.""" # run the uptake cycle --------------------------------------------------------- - uptake = self.project.uptake_seconds.get() * 1000 # ms - ms_step = round((uptake / 100)) # we will sleep for 100 steps + ms_step = round((duration_ms / 100)) # we will sleep for 100 steps self.pump1.run() self.pump2.run() - def cycle(start, i, step) -> None: + def cycle(start, i, step_ms) -> None: if self.can_run: if i < 100: i += 1 self.progress.set(i) self.root.after( - round(step - ((monotonic() - start) % step)), + round(step_ms - ((monotonic() - start) % step_ms)), cycle, start, i, - step, + step_ms, ) else: self.take_readings() @@ -223,15 +221,11 @@ def setup_pumps(self, issues: List[str] = None) -> None: def request_stop(self) -> None: """Requests that the Test stop.""" - # 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 if self.is_running: - # the readings loop thread checks this flag on each iteration - self.stop_requested.set() self.logger.info("Received a stop request") + self.stop_requested = True - def stop_test(self, save: bool = True) -> 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: @@ -242,15 +236,16 @@ def stop_test(self, save: bool = True) -> None: pump.serial.name, ) - self.is_done = True - self.is_running = False - self.logger.warn("Test for %s has been stopped", self.test.name.get()) - for _ in range(3): - self.views[0].bell() + if not rinsing: + self.is_done = True + self.is_running = False + self.logger.warn("Test for %s has been stopped", self.test.name.get()) + for _ in range(3): + self.views[0].bell() if save: self.logger.warn("TRYING TO SAVE") self.save_test() - self.rebuild_views() + self.rebuild_views() def save_test(self) -> None: """Saves the test to the Project file in JSON format.""" @@ -275,42 +270,43 @@ def rebuild_views(self) -> None: for widget in self.views: if widget.winfo_exists(): self.logger.debug("Rebuilding %s", widget) - self.root.after(0, lambda: widget.build(reload=True)) - else: # clean up as we go + self.root.after_idle(widget.build, {"reload": True}) + else: + self.logger.debug( + "Removing dead widget %s", widget + ) # clean up as we go self.views.remove(widget) - self.logger.info("Rebuilt all view widgets") + self.logger.debug("Rebuilt all view widgets") def update_log_handler(self, issues: List[str]) -> None: """Sets up the logging FileHandler to the passed path.""" - try: - 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) - except Exception as err: # bad path chars from user can bug here - issues.append("Bad log file") - issues.append(str(err)) - return - else: - 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(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()) + 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(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 load_project( - self, path: str = None, loaded: Set[Path] = [], new_test: bool = True + self, + path: Union[str, Path] = None, + loaded: Set[Path] = [], + new_test: bool = True, ) -> None: """Opens a file dialog then loads the selected Project file. @@ -322,7 +318,7 @@ def load_project( title="Select project file:", filetypes=[("JSON files", "*.json")], ) - if path is not None: + if isinstance(path, str): path = Path(path).resolve() # check that the dialog succeeded, the file exists, and isn't already loaded @@ -332,8 +328,6 @@ def load_project( self.logger.warning(msg) messagebox.showwarning("Project already loaded", msg) else: - # traces are set in Project and Test __init__ methods - # we need to explicitly clean them up here self.project.remove_traces() del self.project self.project = Project() From bf16f0883bfba8af9eb333b4f3a0d8f4d2898bec Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Mon, 31 May 2021 22:22:42 -0500 Subject: [PATCH 33/49] cleaning --- CHANGELOG.rst | 3 ++- scalewiz/components/evaluation_window.py | 1 - scalewiz/components/handler_view_plot.py | 2 +- scalewiz/components/project_editor.py | 1 - scalewiz/models/test_handler.py | 25 ++++++++---------------- 5 files changed, 11 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 12a2fe9..8879bc3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -24,10 +24,11 @@ User experience concerns Coding concerns +- updated the :code:`TestHandler` to be single-threaded, collecting readings asynchronously +- updated the :code:`TestHandler` to be more robust when generating log files - updated the :code:`Test` object model to handle the :code:`Reading` class - updated :code:`score` function to handle the :code:`Reading` class - updated the :code:`Project` object model to be more backwards compatible -- updated the :code:`TestHandler` to be more robust when generating log files - ensured exported plot dimensions are always uniform - minor performance buff to the :code:`LivePlot` component - minor performance buffs generally diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index 0dbf5dd..44bbf0c 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -51,7 +51,6 @@ def build(self, reload: bool = False) -> None: if reload and Path(self.handler.project.path.get()).is_file(): # cleanup for the GC self.editor_project.remove_traces() - del self.editor_project self.editor_project = Project() self.editor_project.load_json(self.handler.project.path.get()) diff --git a/scalewiz/components/handler_view_plot.py b/scalewiz/components/handler_view_plot.py index 3982c48..7eccde2 100644 --- a/scalewiz/components/handler_view_plot.py +++ b/scalewiz/components/handler_view_plot.py @@ -57,7 +57,7 @@ def animate(self, interval: float) -> None: pump1 = [] pump2 = [] elapsed = [] # we will share this series as an axis - for reading in tuple(self.handler.readings.queue): + for reading in self.handler.readings: pump1.append(reading.pump1) pump2.append(reading.pump2) elapsed.append(reading.elapsedMin) diff --git a/scalewiz/components/project_editor.py b/scalewiz/components/project_editor.py index 42bb72e..dbdcc15 100644 --- a/scalewiz/components/project_editor.py +++ b/scalewiz/components/project_editor.py @@ -39,7 +39,6 @@ def build(self, reload: bool = False) -> None: """Destroys all child widgets, then builds the UI.""" if reload: self.editor_project.remove_traces() # clean up the old one for GC - del self.editor_project self.editor_project = Project() self.editor_project.load_json(self.handler.project.path.get()) diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index d207347..35b4a07 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -34,7 +34,7 @@ def __init__(self, name: str = "Nemo") -> None: self.logger: Logger = getLogger(f"scalewiz.{name}") self.project: Project = Project() self.test: Test = None - self.readings: Queue[Reading] = Queue() + self.readings: List[Reading] = [] self.max_readings: int = None # max # of readings to collect self.max_psi_1: int = None self.max_psi_2: int = None @@ -65,7 +65,7 @@ def can_run(self) -> bool: or self.max_psi_2 < self.project.limit_psi.get() ) and self.elapsed_min < self.project.limit_minutes.get() - and self.readings.qsize() < self.max_readings + and len(self.readings) < self.max_readings and not self.stop_requested ) @@ -153,7 +153,7 @@ def take_readings(self, start_time: float = None, interval: float = None) -> Non self.log_queue.put(msg) self.logger.debug(msg) - self.readings.put(reading) + self.readings.append(reading) self.elapsed_min = minutes_elapsed prog = round((self.readings.qsize() / self.max_readings) * 100) self.progress.set(prog) @@ -172,7 +172,6 @@ def take_readings(self, start_time: float = None, interval: float = None) -> Non ) else: # end of readings loop ----------------------------------------------------- - self.logger.warn("about to request saving") self.stop_test(save=True) # logging stuff / methods that affect UI @@ -181,10 +180,8 @@ def new_test(self) -> None: self.logger.info("Initializing a new test") if isinstance(self.test, Test): self.test.remove_traces() - del self.test self.test = Test() - with self.readings.mutex: - self.readings.queue.clear() + self.readings.clear() self.max_psi_1, self.max_psi_2 = 0, 0 self.is_running, self.is_done = False, False self.progress.set(0) @@ -243,27 +240,22 @@ def stop_test(self, save: bool = False, rinsing: bool = False) -> None: for _ in range(3): self.views[0].bell() if save: - self.logger.warn("TRYING TO SAVE") self.save_test() - self.rebuild_views() + + self.rebuild_views() def save_test(self) -> None: """Saves the test to the Project file in JSON format.""" self.logger.warn("TRYING TO SAVE") - for reading in tuple(self.readings.queue): - self.test.readings.append(reading) + self.test.readings.extend(self.readings) self.logger.warn( "saved %s readings to %s", len(self.test.readings), self.test.name.get() ) self.project.tests.append(self.test) - try: - self.project.dump_json() - except Exception as err: - self.logger.exception(err) + self.project.dump_json() # refresh data / UI self.load_project(path=self.project.path.get(), new_test=False) - # self.rebuild_views() def rebuild_views(self) -> None: """Rebuild all open Widgets that display or modify the Project file.""" @@ -329,7 +321,6 @@ def load_project( messagebox.showwarning("Project already loaded", msg) else: self.project.remove_traces() - del self.project self.project = Project() self.project.load_json(path) if new_test: From 4b9f44df6d7f61b034972bf0709d7d1810773726 Mon Sep 17 00:00:00 2001 From: teauxfu Date: Tue, 1 Jun 2021 12:24:23 -0500 Subject: [PATCH 34/49] cleaning / update screenshots --- img/evaluation(data).PNG | Bin 17468 -> 15541 bytes img/evaluation(plot).PNG | Bin 45993 -> 44616 bytes img/main_menu(blank).PNG | Bin 9273 -> 21789 bytes img/main_menu(concurrent).PNG | Bin 7303 -> 21979 bytes img/main_menu(loaded).PNG | Bin 8103 -> 0 bytes img/main_menu(project).PNG | Bin 7693 -> 18610 bytes img/main_menu(trial).PNG | Bin 10780 -> 22783 bytes img/main_menu(uptake).PNG | Bin 8626 -> 21785 bytes img/main_menu.PNG | Bin 6908 -> 21263 bytes img/project_editor(experiment).PNG | Bin 12780 -> 14798 bytes img/project_editor(report).PNG | Bin 6981 -> 6906 bytes img/project_editor.PNG | Bin 12348 -> 14485 bytes scalewiz/components/evaluation_plot_view.py | 13 ++--- scalewiz/components/handler_view_plot.py | 50 ++++++++++--------- scalewiz/components/project_editor_info.py | 8 ++- scalewiz/components/project_editor_params.py | 7 ++- scalewiz/components/project_editor_report.py | 8 ++- scalewiz/components/scalewiz_menu_bar.py | 3 +- scalewiz/helpers/render.py | 9 ---- scalewiz/models/project.py | 30 ++++++----- scalewiz/models/test.py | 22 ++++---- scalewiz/models/test_handler.py | 32 ++++++------ todo | 3 -- 23 files changed, 96 insertions(+), 89 deletions(-) delete mode 100644 img/main_menu(loaded).PNG delete mode 100644 scalewiz/helpers/render.py diff --git a/img/evaluation(data).PNG b/img/evaluation(data).PNG index 199939159cf5f2e39581a9f5a3f213169133c372..483edf69bebb25bcfcf6fa1d4f5dd1d703c7c8ca 100644 GIT binary patch literal 15541 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 On(HBt?<)@mUHd;{57&PH literal 17468 zcmeIZd03Kbw?A&R)5^4V?b34E?RMF2P|KWCv&qWT%A8UpbIJk90aOgA9I9Puqm&bt zIS)9aqL5mek&*+VAds1&Adn)UAn?QXyU+KW>pJgwf9L!C_q{H?faiMdbwBsIKWp9V zv(|dAKywjArDk z+T{;tzdcf&o-&*|-Eg}5+UE>I@am{e&`nPHjw_zMMrXdv+}e8Nt>u&I-rRIuNBx|G zfjzR_KH=XyAEQb?eE8tw4uM<}ZhpH8!Z6*r^Uxf3=2fMfLy(uPR8hJ4WYAxTl(Ue^ z$FU4hFclJDh+&-Gtt#cJuJ4HUuZnHrtVwoP-l+aqJ*j{QaQwAgIn4L2ul9Zb&w8c* z78R8*$G){+nU1FXyhgcv6|LX2RGm<`w4+|iB{l{q=3Nzarz2@zM(Z<-{ubO7-FDaz z3)-)xDkeWK5Qv}S(ys8UBrJPHf>c0QA`&hs7Sa`7QVLQ=VZf9^&-rXR;i~76RQbT! z;5g6vc58dsno|44Zr29;t~Rq=P0l-_isc5Rq(_acX7CQ-CzB71!O(I!s~birkw2r` z&1KnZh-y!@@>hwfZCI1OF`VrCjYc()+(Eb6-gyW`81u#U^7vrasy*%{{${70B6E&^ ztK2y(C59DAUsF>CBdzyKNs1*>n?&WAadIYFAs^tzZvOuDtt3<^MTgubp%HGfN!Pyl z&R1iHAfM%SGPqFi_zQfWxp20PgapE^Ex-o;_7`l|b0%ELVDnpM_;jbylkw}jd=qn3 zP=(*Bw#V+m&YV|jTXS6*z=Vs>QypVjR;|2_=&z!t3`F|rUE8W!IoHl!ODJYs_Rr+aybb*j2jiDDtbM_AtpVv#G+4ns|UpZ&$1r2tQl^Jl~alxO~Vz zwzg&S>U!(s_rzxSxro}|RYYQ(`Xgb(EW$$C-NWI=;UXqE;5m0QZClDUKQBYs?Gc-z z9*FFZk=@!AxXT0P2tg4qJn>Ps;)GsY`@#O0ReRb*BWAy0d+g3?lmWJ7*`UlpqVo`pVs{_^aW{aUXz57rZ0k&3K6zt@lb;b$QTf<$Mv zd44X|XM-$u82RN6IrH{~1v6$4o0_gz6r&`U?*M`0TsBCxi_0%sTRPkHMo4uja8WdTMrB!5hEkaQU-afNbg7=m5=7zZXEk;3G>o; zwa0F9LCz9UZmfME+JcszzMDzitr;(b(W^PIyAAis*OfVm1~#{kJ$dwULmlnBmAh7J z_$T^(%Xh)IA+3D5l?wxPd6+c^U47K=FGFf$_zS!247u;Iin3K&nmRSF24Nr zP>_*tr5T=J{=Lw_Bv=2Zy4AFZB2%Ci|_)R z@1mPeA5rlj{otdOrrjaR9`L{$Ptd+>QOk?X*+q+O zn{jOdg&a#XdR8FFm@#~ev+sa~^4wbdz_`cbB9SRHvK5xKwyYJR(_zh6BaD6rU3nci z@t3cd+2Vn-U+g(>{{Uda&6;oR=Yf;q-c@ULYk^Cehnoq9y~qRIR)uY$-R3$F_|g%{ zSM44?GGX*5Eb{~{RVCx6`q7pV*_%UcMn1;EYAD4M=(x!*k#ZTsWV(t*VLRf_c;#Tm zH(n{C>8d#W!#&ODd31OV>`HdODR`Qi*|Pi1IYr#vojcen&hcmKx+)06?*+#e{v?%X zo|WZU;+!9;R-T60$zb={*L>8P5@Muh-Xl|KA1E=2k2UFM!+D4EwRA{ z<4Dz(H)$Qh-(Tt9Z2f!2Egmbr_;s%y#%=8l-RMGcTT?3_=&tyJ|Jh^3m5YLjs&^*O z6`3%9;pZ5vV{WhyTY4_s(3?KfT?_trm|?6ZeXQ9AOEj}y14 zeuT_d!5|O_Mq%;nydhFbV;Wr+eatC;gWvw+73iB^>aA!!v+~YcTszcx zL;I!g9Va<{YodvkuDt2RC{ms)`pVgBI!!45JYPpe4Gicc2{IEuYWdx7jZu0c3^8gt zHDLH+6ZI4?NP7>;xD0##Sk4WFOsrLrxZ}frH`f+nkZ?T26Y`}eK@^4Sr_RmR%CLY7 z?N-nUyYnuG`?8>&gW)p2*TIa=JzR{w{ku)J?@{*_goxg zrsZ*babaZldizShK&`ar11aQrA{)y@;II_eIN&q2TzuLP9AHcTlqhK?$lg#~DHZmw zm!F?TG9){0cp460xF9?OrT>vQ*j;>_BYwe(mZm{mQCOxeeC~qqdMze@h;xZBydT#L z^McQx3HI(ZG_W1@<9DRnbyvphH*5-N^MlX6SjToUC4Ke*E8@7P13rMPT+kmLI6kKF zC}jtl4V?|xK;i%!0;O-8o`nqrr6>MlcXGN=2gZDu<2X#N)@tU#yy-llcJ4AzJ_g z;dC&D$rd%$>6uHd$;25h(^N7kU0AGFm`!37m3l?4b^c~zVhXfnZ;|HK(Wj4N?0U6* z5LtIq8vcTn9niyK7r@We@r+` z^!oyw30-4Tw{`wdrG4eHQhg=>;^+HoXR_jY)H}QYfqMMy{2Bzmy_z>YC-V98rL=*a z?MhR1i6-6s#AFJ>72(em4{Th}*Sd0@y#Ux5qx}(29dQ}+@e|m4UjKk*$b3+}dIrh- z=flr7o_P9b1mR2%>N{L+G&1>p6Z;znLtxO#OsbA7RKZZ*A|Y-=nVSEV_$`G%v!oNU z7>>I@XRtSE15?;QGt!|cj?vW}B1g9K?QWRBjWB-hm?sGk*$GNNPJS+DuXRF7_tqdn@DBWN`!ycdJ`MHuWfDInb z>x(&u2`+TajQRT#Uvt)?(K**qbQ|0|-d4@B+xGrph&I5}$3Q3?q!3DqJY_JiXk!WI z8}+0opKDDs|4i6`6Abq_NjsOSwHVCDlFJOaj(}8q!{Fs8&zv3dRYGBsKFQ7dV@A57 zB=2zS4z;WpYT6cfQ{oYCHDSFGM@KMz+!u8(0+)r)$bJv{)!l#)8iPw5aTe3A!Z1>9 z6RxvIF+`$2+*yzMY#wdgakQzIO$@Uy-(4UFU(Qk+j`wmt3(b<99d`88)Z1eSgw`1M z&en|mdQh)d6!WLn?d;~r*TO6Zv+bXsgzNe+*5jKbcUeLd#uf@ysPiwhFd0$Q6^Ssk z)T-AOz(lBVddU|qswGK38>Iv1hOkMedD;1Jp*UxRGGohRp5b}B4us&ED=@7zBiPGP zzwkm(FB8*%fU%wqaztL!@HGPHt;&}u7h_9NrZmfoAxGJ}^~-u9NzWNjLuGM#>+&U00%uG+ZLN1DXYvqH8c{I!RNVM?U8Mp z3GY_w=>mUbvZZUj7wLl?yPO|>jkBY52s`#ZmZpD-_kKopmTeYSelJ0-{&~05rmj+E z2+E5qkIeA}e2TERpw(53o)*wvlnpTE#;uiBCgJk@xP#mTklr^oBA;m?(lt;O2cAgO z#36FZ6cb|8R(aDj!)_c(2~V==F)eB3?QHlDuuKud<~Hn~I5z3)1*q#m>^ic9Q`#TV zm@rBs-qY$*BQZ~L@DZvhs4O(o4y?a0#<~WSE%r%ftrMq~Po0eL-Xf&)zLxc?yxfd& z;g`=F`Ya>Jd5|)yR8Ar@f6OZ7mNcj(eWzuwExlF-6=V!vSc-ug=wDhE?NVWYseN81 zIL+54@<`CdC!BjMOzdEy=u3joymo&`Vmi8Iw8sEf*eK5!#f(@pKInx(SsAC%LWjiSbreHH-c>%dfS_IBP{;FCBopFmP!w!rnqYtn5P2#w4emDIdIl&HS!F%Z0ShR|5&$2XtZ&Ibc zd`odGNkr@Mx#&1+EmU_V7+u8)DGf=dCksjlkw=WZ@l&~c{=B%HJe0^^>S9|}5_XBh z8`3PIh1M4QJzcp3a5y_!W*RgGEh+1~Btg|mm7rmF?hJ!qSv zSCc_I1;5qxD~|y`E;bAMChaR)&G_)P0O@FEB~yNIlIL1;qg(h+;At(hRnJ6q8oC~9 zJz@M(Xcojf>5FvYQ6luf-`L>4_p{MR3vhfg-l@T-jvvUd6V}G%Xg(L}5|hb!c*ZyN z#V6Lq9IINF77$PVQ|n?=b9M={S_b_ZGt5C$=D19fyL>l!NJXH(7psgTAhqe)qbQOb ziKO0ufJD{Ol{k8c&TUjg%r&dnyitA-VVkcSGI%KQ3rqE(OqZ2%B_R4&5Kz0h`GZE7 zuq9r)ussXirI&!`1SoUJzve zgcEO3?t&zJ1)A)l3^7pim!;C&5)Rc@xzt>pT0vUW%p!b$C9zqf99|g9`4kaXBN++F zI_N~nf@a(GrP;nDNJ{u%4ByC--;i4*?F2pCFiyG1sV~|k{uL0yjXh*cF|x=su5=@P z7)7xDam2e1qR25uV<%tjyQBGx2-JOgNBH4>Z{(w}LbaqZ{WQU`&tW7S(vPd6v%w?D zp`sPfXpmWN(~O^eOP6Q2?4MzqP$_=~q~|z+#%$T3%_L51V_mdBbiF*WIZn#`M7|Jh z9Bphitv(TJb4_GVHHHaoVvy0q5dNZ^x0Fk!4B@95#08$Dq;Oy~VUiucQ+ykDg)!C@ ziO>h_)49)yzK-v~$B>?tgnwN2^qYYSKM0lMwplqvw%yP62K_-GxT83}L6ZvORXm;6 z=}6#KZ+)=A4OBzp=K&f7;wE&cj&spK_=lVr3(N>|Ui}5{1}aE^(8VAX|wb$ZNC#0Vl zoG)J4Z_j&*nia|5Jq9)v%(14y-TVuYS%H2vGP_mKUM0F6xL8wn$CoHxo2?7s$i&b+ zYFi*-s`Zy1I{I;V<}^xb&9=SY3*HmPZ?*o@kByVgwIm)?nk9c;mRUCbaCKXF1mjg# zZp1$N+(|C7+jJK*HyDenmvjRa5^MS%iIw>lk2s2sc)Z(sik;xVe1Y)z#)+~C! zviR5|pUwp9tV9WO>^h*R|L$A$da93q5|v^-My1aHee{g`wR9^l&$DX);c4`w7W zWpRi3md9uxTGuOmW{_~}*{D?$2lmXR+)T>uu$5tfb;y;X`$(b@0j!kc_u-GDKLk(- zDVX9}^a8^V@DDieDrX#93z-`UK?CY|DZgSL2)^a!8i?0R=GPV`{}t3O-!iT0q-0-1 zi!`R(!{)44Pr()CPp{+ul$kB3%vAo2X|IwLHdiV(4ZV8_g4+_aXaNYc(YyRKq_8zZUGFCDI7Qt&2( z@rpNhdALh2nOw&1>ybip)n@3S{4QbESc{_tS;}ZO6v+!=gEq!qJN56rZZoVa>zU7I zn6kF!I)u;>F{GQ7b{gMoHj_v`2Bd!TM9TP7rW^|6gZ0TQsH`xHLiUWSR8INHE|P0i zRmM+Z6g;RcKQ#LZCyeV15MyvH8QUa5LpjN0K$1=8uWg9v93h{a#kp?J`(99oXxo;n zwV$G4O(6>>7i-{WG=QGe1gz+jl~j~vP<6+6NlXXW**oc2@T&+tADKmXpJI{^T(gdH ziPM`fVWu78U6B{UXyb~FFhREwR_u9yp`!ds|C_`Wbpn$E~fz7I>{y!71# zSS%s(max0udr`fM*cd8ad|85PMZ3$I*~U)nM3YvV^cy;9L(}sjf`=l0Kn4ov*0yhKXk>U?Q!K?b zA1DxF4<<01Io!^QDnLs*kL5DSnR_kRKA?}}0m829(`%|1-9W#MOy-H&6WCPx4*r7x zZZXG=9!t#~pRN|+IuBMB*swMe6b!exjUE;c^W_i28>d5)&gqL0l)*Fr9`fh}DyNe-h1C>87ny9DD-^fo0)$1X*U4cznwLV6sJ-Zgy3!k;Z za>@}N1;Q+sBB6x`+KG1Ex^*a0x+ov2w4vHVAwM7Ezk**K5$zOOQ0^p(tw$W^bWf84 z7B04&ibcfmFVd!`1v;&rP-{EIasFD?-mtjomdDWA7QF&m0XLyiRtZ_Ck0`Rp44>*f zn7~T|bU+j4buAZ~jVSbFvZxS1I0N=8%3^K{&kG$tBcjMf(PAbfRxgn0@b$@z^<`3n zkwq}axI#fUhp>7gm}t}pA}XW@`?jm!eGGPVTDK=}}SmI}pZ-mzQfQZnWUfpxv_lXuOM2HVm9z z0sJ@aQ&U-ZeFtV2Eu996ROUnEP5D(+#(W!k4R`x_esG%k=lNi<&U`~a;4|r_*u5{M zKRb&jgANN(LyA*_+i=`O2>f~om-_Cs&Pc;8J+TCTkLpg(Jw{t2Xjk;Eznx^>6rIqI zU`fx1a?M)t*$_w--o+w%75oA<#s%%x#G+kk#_^U~fsEjO*#mM2a&bvc`7_Oe#++oo zA=kfZT@_o_YA`^l7wOWb8(Ryd+Vi)rCEy|y4Rn2bMCb4Pi?spZXaq}~{dN8j*C>YhC{pPE<%+aPiNzgn2FQ?%tuF>PiTV!n%NE2^=d1O&IXqE2z{oOsfqaWsj z4K=+Vntxo&tZ5cH$Z(ON)`^ z>@(~F&UmPCJ4sX*oj^szz^|P9nG1Ps52`3nB14)FPO%C?Nmi|%vod|(juZ4wu^q#) z+3!FF;W;Q4stAAIrQVe}1-{RIlclAwctx+Nr^1~@CH1TqHmpE{rl@iTKc6kJZh5D_ zseK?ktSEf5*fKXxMEb+U7!(J{N~iWPxO=#6XONfs2_9CR^>0`$eUWbVHqkWY1~u3( z1641TykMavQ;Wd%URi82c9@=%M#S4U2yu;a>V!_uTf2sqtX!OZUrTkb^}wl2#nxh5 zvFdnhivYInv`z1>&YY zDfnkG!lQ>w8J%yJ=mYQ`NaRx)DDnX_5{>DA$97$#dVJ1t)6%Bo23$tIgvWB`dvQ4y z!_bN`ydx>f#x@SYoj;wmhi~IF_G`#%I4^@9vgp!>?7KTe9AZ|=;}q;b ziOx7LH+GK3w+j((n61&u8l$Jw$rO3o`$(6kgOJvLW9& z8;)7Dvodj4D?<<9CdNsR``?ITL$v(mMZ z3MVWo5tNR@F`lz5i@`4@39?03XymIZnD6S%N4N4jHxXWqcQ z)@L(*!X{JK6D7nf|B^DNn2 zGp}~#ojKhxyKBm$oU`tF?gO8q-^-&_a<*7LW3ACLl5BPax{qzr#=4yO7jePP;~ z-G7?owet6Zy8(azch6PnrCDOS^>XCk&wvPnt7LZ60K4WAMhI$^QpD?lJ7kW9GFH33 zcUk0hmj93Ac%usrQ}!cYTyVpip4xrR3yyTNUB`)dk2G(g$qO}GA1X&p_$!=Eco}hB zVFj#TzbJ=~zEq04RiX$s(`XEB`fFvMkHGDovJj zcEBGZHCCsLVSTXAc|XFnMpMJ}%XP}C2Y=9!*a$>g;cQwY~0fX?X_-#1sQsVTtC|cU5njCQT@YB%LAtpcQ2Woxcf-*FB2F4 zbP!q|(dxW!eroOfg^5e=P5CXc{=7Q|h(@n=wr#*$H=`2mOX(dk{w>!6o*gxCcX2-= z=)GT+NqXJR-e&sc57edgrCno9CVq7X&*r7?E!vx#z$#ELVMKdF=dlcYGh^y^EMm;+S6>PHc2&R#ts7yN|}wtau*v{#ip{@qvGn5w<&v*UBO zEu@x~jdql0ZgS__5T%O#EQl7L{=X*>1WU z7D;ZeeM9fc&tk*DvM6N$>Yg6SuowVWVrAfX{hotaLEWa|GfN#yTUZz8?|&~cF72X) zx0L3>M|xNPqs&8T2fFi|RcJ+wqQK&HqhnhgdSD=7|kxKi$NBGSFyGZz018`;jcnW7Y zcYJ>F&U66@+ov^VV{G6_s?_&1wgVY*P~2;hpC%TJBTa*;$6YbDzx+m{Jb{kZzBXXA z3x~Rh*&LCjV}ga1Z$mm_sk|cG-`&d3if0O*)^1{X!H&iF3)7KH0dxb!n1TFNu8~D~ z;f^_oFgAv9eUl?KaU&pbXwu(vIk}sNxrUc6u;@N{94O? zf4>Wabm_P{Uqj21*TH7vYkBv1t0ko{UGq`=dF%k>rR6E#_J;1F!F05q93u22NZh4v zgm04u;`g8p`Jla{Ou&bIZP$RDhduX3pt>Y`kuZ&0n}95qPUXov%$*Zmjz`u;V)w(r zp}BfVpsj3iU!lREY;ydZ%0tPh(T7w!RByP`R98CMourQ?7D^IYdH2~T;Y*z*3aUdB zJ$f|6uODrQ4sja7*|_ofC#TV9X5;1B!dF>a#N%Rt{FYJysp z;>c=rEz0>HH|cW3lyAS3NP3Z{$bPx>x)$DW!noQW zF~2`_$SD`#-JO>`B|FtbSMX3Vvv?K>IW4GA{5FaojPyIrS@I$*oH8yAFs_Dp(IY6W zO+&=~=}7v$fO8pD^f92v@jcE-p^<)jy5Lbh;2HJf_kj8hHShy<%p3sDcW4860*1O# z;#|24ft^CT^^287dGv-v%9OaT_zk>ja+rcR32nd+F?a~xLCcvfZdw9A6EjYVq1}%(ZhMHxvw$dT9-i0>1joU#>pVNmp z&KAbc(#?&Zx*_FTRbTiGAAr0itZrR_(Ri7Z_(Y>DbnTd#W?3Jsb$WNXe9BS%cG#Zw z(wf&wrPh*Oe2)mF$=*3}crPC(liP^A+bhho9eG~D#gv#cz<5^s?82fr7qVGm-0u8n z6n-F-@FEr4SArUjZ*6`v5cN6Rvh!MK`*$8^MqZ#^2(WZ68jl{-!kR^M{mWXknsOzh z15ws?OuBJK)^rmh-id+>T;cn@WQ~10%7$Xgu%nJRKrBs{y&l+v_m5pi(R?7 zKAu+6=)oTz3|&3{uDMEb$A0M*!Xw4FWJeVgDc5x7+wK$i?fVlw z;R|0Jgk!kxf->jpTm-L>27=KH{D1hgfjf}-uPATE(wPe(-(E4ZB1Ph<$>6V~#4HF@ z`cMSlFt0RxlL+}GtlX6GX>O7PvEkN?RBgY_CY5eDgp}L-ADyLLGg+S$#Pi-bM6+{! zQbqOIx?lT>T2l00YS*A#D_bG=uUFLIWCOHrEpoj-Lj*Dgi-SLaf4jt`PL zZ%+gg7|-Hik${OA;stajoOE*t3n*I1d(=R#+ATUF;1JTzZI+P(SaI# zjQqor?y9^gihnY<^~MOmBPZIHDE2y!6TTt4krx0UQ?*$N63nYskG9uPc6unt$1c zuZnHODT~+Vk6n>uI{`ef*OYU-mz>P zL@OJ)J-Txqn4E=qK~wZrh?#GM$Ae|e4iNAXC6!zqaH0V5Gd?Ge`n)7>`^PBY=++k+ z;Kk~SJ)uAyU$@3o=wgHop3VUwwwcXX&;-tv1zOqToU<4!d}Zv2z65@FZ>ZNfX}{79 zQ#?Dq7Nd(eX@df_5gALU zERzIx+k~bp@m#mN@XI1CSQ50RhE#?fV+Bf+C_Yo~{OcDrPVATZe0UhF5=yzRSlv5w z^Ru&I67|Z>yQ&{@=YzgPE6+-6Z4uw`kqma(RelZ2NVhAt)0W1J^;K=(9nwBE8h!Gf zG7y4%KQdele1L1P7tTx_UKM7qlFD+#r7xu1h&AZgIFiWF$UWI7N98J@xTRS)nbz3k zN#RKC5@C?m(rDgwW9jSrU^?$4M21r8Eq0fk$BcPL_~kU|*M?6w68GF_`B*3G4$lO2 zVQ;jyj)TvM#vAQLr1059;txH&tQuZ&YRifDo_adn;@mwGP={78(*mX`KHZmm>c?-v zc;5B((%7YS>G&TOSO4_148U+11%cBR>+@sl|yqjH3Nk)`G2)f%!s z{^PDKN$jY2qX$}`2b-qym#1eW{Olp{zT{;ausrpD8zfOH&tw#d5)yjh3ck0+}YdL-sG`;(svO8o|0UaOKmtFC(9(l279*Fy+eICv1pS)YZLQ z^kUi#Z?2pG;9RH4JI?iJjZ`a}UsdvcVr}uTXjV}+St>ajRI$gc8DT-E@_d$8w`WA} z2S0nU`(H5T|I!xqqhHnZ^J=X0S6|EzvZ{LQn)*vl|JpL~2Zh{Tp=EToGN;z1(r=`j zye<1;C7>PDUGMNwxrs(^0l)bRbJuRzakA;z)$_KRq?Fm$4==2QU$Sza{bXYdcp)9T zV_=j~1tV$yw;W3FTlr*daTTodpTKUqoh@b}k^daK6EX>f;CDa?^UePgLXC}+NoEtu zBOk}d}N;yZAX#xt7p#;(E#SzFwa4Dw`QkYTCj=@(l zzGb|x;T#xN=uK(FFLzDFv9uGGz79ZB8g49=6MDzB+-p0F$oISYmS(I&7aL*b&Ao#) z4TG$uh}y!}=hm0DvU>3Fm>aEPi*zbHZtD?4^LA)bh#zk;YHG1ax_)3YvP#K z=+|DPmT}}$ZW+bAvXo;X0}%%wFk54C8q?rX3Zq|s$U>pHUNUt-a?&&v-R$~D5K5Mt zpx#CHBQS*({%CLnTD>*mx&=J?jF*fHM*7Hv7-R*~4&o&f(ZHsGMLH^c@pJ;Q|ZRIgIe% zb4|v%WlZzTYLiT(OLUifJF7UoL?Pz*gI2fwFpA|vgn_LGVWY=$e{aPnAL~jNC7Urx zgAkt}Eb$7OvRzHF&}BbFYCyZ1L;2F#C?M(YH_A_TgN@`&2fG zv?>N@!C~Gyk2;!KUfT68zy_qu&>>u?+8hi>V-TffZ9&u33Z`l>JniMd~R0B-E4!+C1t62iczBS zIWpACgm#%hLgPC{64}h`vzUDx^fJ;M?AOpHB$DB!p<%~`nGKfI?fTvp~c;@9p0;=jZ#nzzSC#lxtKMFHIEYN-{mq(S@ zScPz@=OJgPq9YiKE(vnJ$G|$Ua2N0HE za;>2&^YT{IuC5C_=WfkXHyJpTneB_=W<6f4+SsryA!EHHT#*eU!2M$6FO{|4EJn;L zUSbx9EeX&w&(|7gR0s>RfLB4l*PNi~SS;_|!QI3UG5$eoUmeycY)W`lzO2zqky*6j ze&1?R@TbdAz3@uK=~EIXB;q1v1oS$ubz3c#a5xRga3iD_?%L*L0cnuEKYu9g&Uudq_4|#S((*^Mo)}z`!i)5WheWaxAzQ8wekvk7sB#7)yKCBF z3Y$`!T{~%d3p48=j}t?Agf$ip(G(%gxbT&6p$uc8SK*0@wdM_M?$SQstj&Pu&Dy<4)H;^F*_r=E?{GDemspk!7#zW?Fzp96$mTQ9YCHcX zdgjUh*F>*NU!vo`I85~2TZ>_!$C5wW3J+w8{r1$;&iZb2@@v@&Mx}Q=bH@ljH_{!2DK;bnY))J0& z4r>uB+OFt#+uDB(NCDv{!h7unGne%}q;QPbFg4$}F!am0qH&pv?oOX}N!Qv^z`a#$ zuk#u~!uzxX+6|cNOrmC~tigJ#2MW*?p|Bo2jqL8(z^c)s^do4TEP7*0Q)e}vaEy=+ z-8EobD7hBA^ht#4M&SQ&lYY`YZiP{5&*GYN^LP-w=ukim_C{FwXy&lu0J}YG;VJX` z>iR^@H%K|{xiUbB%3E*WMQX|en6G)IR!OnB6E%@c?(0Nt`xY$Tk}TV`sv8Om^A7Bb zGFRms7*%}99RF-pzh7=;krRm<(D@#$_Iyk|$n6l}A>i=Q6xl#YG-xS!|IYW#cMntH%h;0{r*eNWtLr6l^_LSZ_rG;)-TQ=Q%p(Op_mO!y_jDy)T zvT-CfkAi0>CKAWEYcesrr`69&RzpT0&wlne&UE4*$L`ll(s`v>iP@mo{3Va+Xc1Hn zJSXZ4%doH;3G=o#DID;p1tgQJKF%u4zVNVbn~)lo zg#CL}kL}vK=upp;i`h~)jz)W+R~*mkpzbqlKg-cpPsqI9!ktTD+0V^v*ZCqfZXZj5 z4s_w<6zOIe;=_!xQRnSTmm+`2jP`{Rsg$eC7Mxp6K0l7E3mK!6y-)rLZ|Kk(7KUmgZq^2Y4n}4-}H7qoi;&$3GY%7C>rTXcFvU8 zw9Bm*Q;wWGpo`kwHTmm|;Q&yTlsp?ZO8eywxpHX^MJSh^Ut$peR?0wQj%dtkh5Oz4 zfNEVdw(y@Th0Q5TOcPJb*lhxA&pdHsAleh$LFyK%VX)^f_}6<7UV!|rTDPiFFE?YF z9n!fWVcy}>KLQOTZLCu1RvP>)csJaL{hB#07PLw0x$AFzOp5DPQHQ3SCZ4P)g`er+ zDaZJ%Pd1EtHFf+AlG%8JO0|(l?@`L`+->=UwY{K$M9o5Y!ct4aDfF4e7>=zL z8?n#`6YXz#lsf+ImT7ETBqDwqUhR$Lq=$E~7Y;UWGS~;r8Z$e<0gh@~4B;h$xQ`n! zJ;aE(+!)G1Dj|~SMR1-J_l8T2bsc2~`&%0CaC*f}^?N3Xiz`+yL%A&TAIqJ9>$nZ=!XKCahZjNq7e%bU+2+58{BQaBzcu!6js33@I{yaD zzX9`a!2FM$F8?KOm!|Kr<>ZB5{`#Bv96i6ByHn}9UROosu2FtVoA^?dDYXeuezi>g zpSm{!C*-gV*X`_x*HhR3Y9b7)stFY_a#>@})+ZHVuUA*t=v* ztkAeHYrbEZ!S%KC-*U6uXruC7#|`CGV6PtT{iyc6Zznc;toX3$wPx)X$ zu4&#Vlqow<+VTEoSmREmER?Hq7N@(^^eWi)zsInT4aPCyUF)Q&wH;Ky8k08xe)St*j zm^gvm;*>u`$)ed$88FtXmMWNI8_;X@m!rz=6mLWIvXS$fS4PYWjTeu3snJX@TG#AL z>|n#7&Bzj4ke5wSmEzP1695G$*#dnN$&t?1!6a2a8Q!eB z%2#H8OioTIR+9P2j8K*F1_oL~Ita15$v$ALjHJNKO8e#wb7}d^_gY z9yY$yw%G0l%DYHZs_B6-w#jKS)@#ki0t}HZLQKOSXITq2PK<{ z<66IBw6|31XGy5Zy`FRg!`;Acifvj!EUx4PoK%*)cPjVO2wk=A=ume@Sh=RP?5%_y zs@q82_{&gPlIQl~uaG1;KGH40AF|s-lWn~&Pbrbd`EET7yu_cb+0|ut^FN`0>I;k##uabW5Y2Sh&>O&y7+VnV=rU-ny0I`>Pk2MC$*q}hyz}CN|v}#DJn~)AqUa4_UeXQPcSVA3u0Df(kN~H%}yy35PC~R zE_h|ATTzd!5jI!jZCFC+Ywlqb-3u@{?X|k`#xj1Ck-3A;qXww|;i;9O+_tCe?xa1V zyT>4ngxC@8UgL78@U!?^(3;{4wvkC8g8R1(U7M}ZrL>ak)ER>E{kXqvfm7!%jQ(Yd zhqynP?54T|SH92u!$!)~F92`MuHHAJ;}Iim)=~J$u5R`~iWSksk30Uu1W&+~6`X%< zg38YQ>VIW|zi_ylJ-n6H*%-0Bx*cn(2);R!-x`9Nd~;S_-}bN0X|+4c#hgZsCf)6y wQkTD4n*Gz(y}uk2jj-*p=J=A8&S0=Kufz diff --git a/img/evaluation(plot).PNG b/img/evaluation(plot).PNG index 2ad008d6a17819e3015b3edd3406f1885217813b..a338a9b11abd303a3a6ddef59480eb9b5aed0879 100644 GIT binary patch literal 44616 zcma%D1z1#D*G7>LBvcS-6a^(j=^O+EOuEyeVHi3GM9M-81ZfeK5>Sa5kVZmji2-Ja zp+gvIXqbPGfN}NS|KoY?RmM4I@3Z&XYrX4T?>cui)KqDxSg1%yNN6scKc_`PLW&?E z*|kJT4nDb>oT>u;*yW<7sz{R6$Tkjs*<+<}MS+CmeK_^{H8Sw~Uf6j<7ZMWMO5%UJ zY8+E8HdjUycYc@sgv&H4piSv3=fdpX`tX*j z6IFQ2!&cl?dg!jLkIIrIgKMm7#yw6`g2(^<)2|s#`pDf7h&PQ}?63~+F&cQ==0~`! zwCLyea^Rbe`!lhTvr*cyFZ&l{o-2l9SPpaNtE7rW7CAmEqp2q=hlD@UP9sLJMEgr@`tHGZfQkQ-i4y4_wA5NJ#nxPdmrP z9t}1oJT|H}7?HLkt~ZzD99@7dLz_^4+F9#8`iwkbkG0sJ%*PJcCz9LKwXc_`hBkgp z`ifG43o(|3eK@PCeV+yDaysxsMDXAm8Wl~tTMP_+$Km7ntyUi@rQ8r>B`?9y-C#Eu zq%kbqc`9XMU&5~1U4_$u!RYs?Af1aEMNyP*hmG_Intg^!g4r(NnDnOikt@Nf?Ds6- zmSZA)2kWhEDJwGjaMU$RD2KNJOx)GrT1u5W4b&-14CJ&~0sRYR{A$?u?+#Pt7QF*j z@un8rVIZzzKc5bslY}bR!nK9NS?ZY4?zgt1)Hga%n?qCxze!Dl+s=(2s6z8KM(H)! z6>~(6P$KeiY!7j`+(x#!c}5bFTl_mFdyfGcx{vwgrje>$7)m9+)d!f#trwcfj~5@k zNkLDP+0g}-XsV>pk8>)9;ia$Agsza=-H@wdgl2Lhu*;ja@oGoJ!<)grl;!Q1d@wCS zqSuo<&kl9U92vHCN5yM2ZbkOw!fWSW7t2w|UDBAROY$O@Ix_99JL4E%t|mnBZdP{eOpT5N=0&CYF1_HvFu8- z$oT~50i9$XOgb!j-gM&&xG6izM(zT*l#2(peY&aGpR3%GF2_EmV!Q9RY^lzVE2EOmELsO|N z=>QmkW4&yJvwN9}zVnOllF2ZU2QIo`nR-@xyLS$ssSz zZ68(^79@Z%QiYS;VJtea+0zlWXX~3#u27*vd>T;PZp2YcJQOw95McN$1!i9IwCJ&MzG!pk*LhrtN6X~Y@9G}uz z;qaBqHg51SZ7S_G-C*-g50q%f$xh!y!zk2hAj9_EVcbV(5NT;?$`zD|OY9R`x}9}< z-N0YwK2hqaxh2&QA6wzQPN>%?u2?60WMud$chbUEaM??JiM?3U_$T0+xYw$PrcgV1^Ej0>Wv?=4<+Xgq#%hDl)CxcgO17@#hw z42w&h?E#s)@Mg9}yy0AtI3Fr?>xxX5J5AwN;k6lN6kjk2U3K5^F#4b%;HPWP-&i$C|U$J`@|Df=du>=YYwU`!`yC2ZQ@SuU7Y6f2(KJ)1j}4+f3f`-MUmb<4Y|Zf$DM zp<`jI{REoz$oU0%TvNSi)3R^fNs-#(#{N5=$SSdIP_yU3{fbQUF&8RjGO~nZK#g<7l}_ymxn3^g~;6z zLngbBls``JCpoMhne-(C%{sqUjm#wVXDehczTcSxWIbf|78f@LAjxA))BYTLvBT)0+FCh9$S``Z;g57Z#j)f&ay@F#Dl!EFZ3{ zYV33+#mZN;sOY*~#VIquw+WX#vL={Sw;5lmrK_Mw4okRb&)%N<@ZMUm)x1d9l5vOb zbFjv^m$lsVb#3;cO9plJ3Z0?ahL3`TF@+a?_?2_!$YG7aTXLP&-%vgiT!ZrL?ItNg zZ^`BoDuBUd%mmBw(_%~e`yyo6y8E66PO+G;jpno4z)Rt#z>bGDPf?kdiMIkZ$Ru)1*88e~JDp?4x5JMEypPaf)<5WG4W?=qM3K_F%>H z&u+WOGC5#$(>)(itwbM3i=^9$CK#p1m6`j;NbV>Xo#5r%^6Yo?9&*T~Q(i#2U2NF; z1cWik{~^%)zj29+>EQTEPUFiQ7MfS`Y?IV4Q*neqa`7)-1jDA*b2Z-mbUXf(WN0_G z!(k_;keoe55A7lU`Hx3$!B%U|+J17l%+gXO|G?XeP&WFyVg8^XPNMs8CxjX+Zm_c! zBwS2n!N%qr^9QHuEPc3}sb1ee;mpHftO7eB zlH?Kf-jvP5$(aWQJrqXhrJLOw-Ugv=;Uev+KY)P_xM1BIp zrAcmKeZC1jp`U5=8WOJRmpM!>=e?kT|9)$(B3PN5GzTfkC-DSo#OBP{Nkl9JUT8$C zkHm8p^yE9l+LpRa>PfqfJxal9+He3_br z9_BGo*1|J9zC^3u zIZ(=dk8tmVO@8)i&w}1idMdL!{lSNP>C3727d79eI|^J+(UtmOV?PcDMaA2Aj_Y+WR&{p%K$SBZ@4=z_ z*5Gjhe9@5cL8e2bps~u87||qQi!T~^w(Y5oeI@y;x6dLOod|cI#m`|M99;P3acMlU zkGJ`em$C2bfRmBFC({3%3@t4wJp=!}&#=iu^iXAt|8ixRgG?ZpaeF1HLYdeOE$`>^-Aij;F?eXX-=ePsK9Q|mtUvRF4x?I-J;Dgv z4-Etn>fZ4q7!~>;HTsWSM?P7{ZpQTZNtG# z@P5eTu^@E+yI}Myg*M3=nZ>4yHEA8I4{r(_QUmbQ=>|*0kg>q|BP*9{`F||xd*|lW znvxrq$SDYfcY!>~U@j^3Hq%khBr?u(*clL&dOTYwg3!&6WH75_7j#FcldIUFDeeno z_CdxcPVYQ(qJfmP>BrGjX6T@w*&sj7<@ZBMnq=EYtO83`k@<1?~QZUR@$F>B%2Z|ewzz(4@vm+MPYKHN3|Zd zwiuN64_g6C`NWxXDKaAJ6ib$EFk=mBurg6N^# zfUO~VH#(yzUnqpidj1=(0k*aoA@57G`{^W{GHLsOYf2WuT#&=3?P%OuQ`Gg>^!*O; z39W@1`#q=^X_8H@7%v!2xa2m`mgKjw8jZ8^40={+h3hRIAGj~X!?E8F@Qv*yXzM{v zsQku)cDhd5fsNJ4Y)eD4!;>EA{QJO(2ey1d5;H*(D}$Lk@4(@qwP+shVEE=ZFOBU3 zo6j2OgJG-+-~EXRyt&Z&5i?Yx{_$h9U#s{ZF2Qp@KNk48;suOLljc-+-gxa}?(}+j z%-klz7y%V^?3Fbt_7;P0;88kRCQ_@LYq$oy^YUAR_b*v&u3WzS>dcMmRB_n}+uZjd z=%rqta4=g%m(fPC$$3(2{umb~B;x5UBT;FAhG?lT6V1`mOc8=N6KR3ZCQT{w{%4S&i znR>kIrNn4u%f*=ZeEi%8&6>n(nbhR82;U7`5Zf7x%!`MfT(aI=U$Klm`{wxkY*{6p zy!|Pc<)N7`YktXZ0n%6)OAe2zQ$Z3JrroN#rY6>nEC6QgN)J_1nugiMTfTK-i`Ts$ z_S$stVcVJP@j9;%V>1D+qC+}#3Wut0{)(tFHgkp#o#on%67E!}g2;W1`Cl3i4 zy=we67xB6y(>NAiR6b;Lu}Isr!1*{0CnWAmq~Il`kbUnndF6L@S$YXBg2{@JY21eh zv~*Y$qr6N+2tVD`(QMdS`75yz@yrvKO$#dX&CJ9nmPOvWm6xqy0Z$yS_tqe(|8Z+d zqF+q9UgkwRRFaBG@2FW#_P zI23K$p56jja`g2wFLZ=Q(!qzE>u%<=^Qn9ba)0mloX_A_l|oBW9Ax16MUwV z4)Us{f@ec#=nECjec^AgAWazf#GSt$)7Rv2CAVWf;@GBa?_Hxh8t;X%q=^lzM!i*k z@jlL1vtyD3xAbuLMK}tW9d{_)2E^geCO7*;Cg{?Uh;4XEPb=ifSrunp5UCyX1Fdhn12BfpuX znDf4V#;;T}S!G1H&oR*PSXH+F$}p3dk-fku8K>&(5=cIz#Gn|1V(}7v&Hk$&0r!kY zr9lp1{T)+E>iywokG)kw=KUT;4r?_4MH z*3;;;I`2rViaWrJy`F<%#1crI_M6L^yAb|_P0MKcb&km;F_9r0IM2cj;*cF90 zRZI71On@ZDGB(Tf<3Na3FvE9LwmJ7C2p!SSLAdek_c(SI=_X;eunQ!r{pSEwFL`|K zcc?zl+3Rm4T#4#x%QiQ^vNq87jd0>cTbqtpakMIIMLKamw@Pa0$3e}r!3Vbn!g&EG zfLNvqjQX#E(1tmdX<;$);4rw^;~1b%pFY)nE{7|cZxfU9b)lkd=n$`q6x2rbf(%4O zPVZOzO$-8u3zFJm-0a4lcEz^exb=kg)t>+%05%CN8Y2ZvLfPHt)ClMLpCNT^5#z*vv`@W2CmMKFFM+~xZaA{|pp ze|X@ZfU)0IiGu{Qufiedd%{4RaCl}t?4t_~67vyziz=Vup~cD{h-PQP4%YprNfh1# z3_y7s#y_w*I1$)^;}otGjF+6|-)Hu_+y6P}cZc>XgsJg3wmQV~l3=FpI`Qfc3!@G) z_83&=1n~?k(Jz^Xq;)Wz-j9UBbAKJyy&*=-*8d|6t5~yIp_QycvkFKEw#j?3XMOL8 z{c$}2)iJJIVTYh6}8Xz2XBCAI^$HuO|I9iB$a=F)&6Oi1BXhzRm2 zulgH+7Bu8|6BEPyD`_N}QReLc)i4RLQ7SRhG#*QuZ(IPfr7QndW`7~ed31b$ieh)tlz&BuTDi1-Ql4}mK;X38{ z*;9-rWuCk@kUZY``GQz}Ci7614!qGX@q%td42`glgTss}VUtU6@nll=`E1g?0ALpMz;c%(7ju7je&0nww9T{L&^hUxY~ zC;yQdr99TR+fI!f3fyc}rRyA1-EA8SPkU1{f<*7{Qe5 z&;EWwDjKl)H$OkoDe?6p!|4kZ3>}D(_b=(k; z2Yf-(rHc}gUAkGgT2G`wPSHzVjyh#3J+>qj00a3W&BI$N(6AYs3=bAleEW7vRhiCw z6(k;4#n40irA@bA$Oip_(O0jrvqR-e63o_}qM4yfoDFk-xDNo}V#Pd2;yGZtk3Sno z^!$S0u#1am#}r0>y%U~83t;!(r0ZsU?as5)Wle#twWxBogh}+CXEK2WP*nlDcv-rM z&$g5cHBJz=7Z8b;cGF6<5O6_e>SeOT`){t~+P3o5AtIqLyY~MNb*9AFUg=k&?3TEDOl|BW6-M(N3!{ODyQP4T-tglg#e((b2?*? zPWh`Ga^B6l`lhel`HGZmyK_$p8s*k@@JLBTcjwwDG4knXpqg@t(7yP0Xb&}+1=$9A zf_nFkXYR5&SL3qDE}})`RRDa_sNVahcLqqatJQnrsfY5h8+jYjIy*SG-?c#ymo4?q zef+Dk;7@3{%cJ}@86U&EUjoj|{%s|41YkQe@A2cS=)O(MQ!YsO`fN@6!f=8dDS?I< z()%2nUno4p2ZL}yToST}nAM8cF$cbBG$m*Ak?`-PL9ppjXsQy%YYRRFFqa>qGC9Kx zq?m~fJ{wmu0svY zH1HT9pbj1Lm&@py)%?Ps=3D_VbvyqT>H8Fm6&IX*^%sZ2QGlmI_+|IQhX|p49wGpUsFIb0@6jb9IHps zi2?sppO-eUGY!D%sh?3}X_LhDr}qX<+9&lhJjHh0(?;TJD5HHnl+8wBFU<&ZN*;Fb zTx|yM?uv~2H;uYL3Ob$=m+LAjAzdL&^z(P#z2AoPM1Cb)DC#*@d~)cdxy{vk$2sru zZF7(y%z}+jOulvCz=1Z?Ft5Z0g(v9cQ1s@;TE^4v!J6=t;ku}dn)w;$Yn6hf zBRB5MH_9}*FWvNikX9Qn|vgjx;`3CGWQb8O6q!?#G(nW6SzkvtY>2bAc0Ix7} z!{@7=@e}Rs$1hZWIMegIGDp+0itv%e0Ao48U`dTi1s5_N>{8Hp| zw_FIV8!;|xzOX#HBjje3Gm4~~K`>8j$7ADa zJMKs?tQhBMXI7T)$lIziQ{K1oM9Ka=rQeBh&*Zu(r-sbB?ALW_5|x4*%}=H`trMsa z5({zDcJxT%+5tjY7U`kzwk%WQE36}^>O84+4Y%Zk z>}03Hq_)|?ng(&xkM318uc=N>%V)`1537|JoLEm^z2#kOQFlm;JzD96x~kg6i(0$B=TlAT!>X%?C;Mxt z{sG7FC%8DuhnK<*pHb}DwLg3}UmTKvTznz6aVrS@*kP=rBu58XJhjmU_}IF3NRYu# zB9#kM$SSmY3uN=ewbQyB@(VP7otrD_J@i<$?aHf*QKzm%nR%bgvu>$yS2_vYyJ4_l zNv3??mB*x~LoMxyRi-oAkN^Y&?r6g?ZjTrTCPyFVrtB4?2Va-F0#0oU;@1Q zuq~&ez)xqegSlyF*g!4j`1MnmLbC6SGKwRYqp_=i1?!G+aovz<)kQjp&q)0H4Soad zKFu*P$FT0cs)ZBs+F)q^=H37Ml|^P1Q$bPVAjKH39A_Ov2crQC7?lC1eE*XL{3UzZ z^8@ZrDNL5=9)R+eO!EuG{cc=8*kBS8UVvoxe5V`j=!`ssp2B4EGP2*noPl{C`boi4 zBBrbFLjcW2p29ju(r@|OMFNp9nL+uIii*#_Zz)m?16dh*YHR)_f=~Azpl+0%oSba? zY;&=saSDOtEe#TVmw{_dx%iAPt!4v0Q%;fVPw-3EOb#iav3! zJ|RcsHnU)L{wpn^>zx&#WEM4DG_Pj#{(7*3+`8lDr-SOLO_u5jUL(xiA3d@&uZR*E zCCPnfHQxS&s*5k&CS^r13DI~C_6FGt(Y$Ici79Fz**m4bMt}X6oZ=U>4@gc?SpQif z>ogwk(R&8HKFCRU@xh|*E1-)qD~16f7tqdp%R~}4*L7SAZ=gW?cpU>z zrlI%-{1+bp2)=LIUteOvKiDX$nEHq-$$;GV=atVPlv8lN0%Ly#OjA7`u(5Kk#0&?( zDQl1r=zMf{@CHz%Vbz)faMj>l}psFUju%g6cue5T8}NXk^@#91gh%GCPqduUCtJ zkv$y+lw20(^&YyVVDE0ISZR50^fZ53dx< z4pkS0jg1c0W@Hs!4C8TW9(n9G2;U?cI4G(_{>02a&4t%KFhl9o31#~b+2KamKOD8P zK(H~#>!R6e)&`Wmnb&JWefofq`sy&&)=NUgH~9zmci&^=Pe*~0ukEp@z?3{1fAdYX z4q=`Mtb2R9Zn`hFA~-*vSmRTRmrg?^!WW}iy7D^aXDR)yIQHj98e{Q#AZLL9D;6u< z0J&n?63k%4n;WB-p%>=2KC7zRn`J7)s22vYT-Sr_0zCOXqjy&WSj0xgyRO8F>(+tN zgmz*jcXLM*;`X+8Jy7AW2S6_;f)nr&g^6HGT^G;Vu1#s#&bQ+hx-iH7^iJ20V1y6y zT-Hbq&MsL^PcL1a$Y8I$X|{(&m@pSr+W7_|GFTm^jk}_zrk9+!?%na$Fkd{ocrk{h zB`@+!5}+!q8g+(cbJ;FqwHKu|(sAB>M52^NEDqXJXdP zw?J9?g&8ZhgXQ9iWN76y;DJ*j!}>sdqBO?gflU`%eb@(p7#hV7Ow(03Sf51(UkA#V zt=dsd9z|wG&2wzzKq)bs>QW!r_(8>Q0W$tHqg?|L4f*g)DGeqZFx7<$f zTeZP=FK7B@=4du0_xjQR0?~D{<4VuFIwMOmaDsO9cI<0buV=YR4=!rUbfZN%&J`*j zps}?->0v*Z4K#*L;w$YduS#5M;v&gv-<$52XT2xAQ`qAKp(pF`DXjRcvH3OZ(C_gI zQ0Jiylm5sO18ma*fn%jyAu^D$Z?9)hp_^@@ZRY6JNrr*Bijy}13VIN$;N@lHoC=h% zh`IPT8>Q+?(>?8C1s%ftpelI81L;O}E9`SE;zA(H=(3%oNM5oM$-Wb=6Q?mPsPma< zgIYu*ugFcN8L5X;_m;afboh8G1^e*f9&Em?W*_*JjJK`hu3$(bIKQ|DNqnjv<5mx5Ve2F-lL`30q;iSEVFX=G?>qWP3oh>FKtQSq) zyDX~QmC_*a)#p>7?lk48bP|#!(!CDk61#ddzC6EeUxsR2Rlg+uhD*dZuCnIBX6uvv z(Bwc+MG_n93e2kIAb$oFt3{AYtZ1LPjKQKW*ThCYUTz?`%t7^+n+}b;2kP`M!5@xa z@pJp8-#Ev`yh}fMFmnH_N%FA9S%9KsoCeP2DX_`7znx&~fYn)PdDT?P?Do4!lGr{J zHfK&))kjNvs(#kKMy$Y0AFH*3gF4vvJj{dbdj;mTw=0RfTE4*U=WF+kJN8es!> zO7x)YDj94np`n2sb}qU9e)a@cZ_50pfBo|_t~Kl)Lo9|gqBmK>cMsT9-@SWx!Yf~P zVw`Y-i#EdCcQ%;nsqf1;QWGt$CtV?&VxMY``UdiBnv^^~AT+v!D{p!^EWQtT(yaL_ z&zD8^aXwJp6~-cVFH*oz5imiI29s|s8-#L(f0A$?Nb4Yzqq-xHyG?ycgS*sM>12Ym z$;)$l@u}QdfCndL5tOHWDofXj<{Og%!FIr_96`{ zGY=NEjJnIV--+@Ht*Z0wznU8j$OP$Fn+G$r9J0^(^s_EAGc#MV`i-!eqi+Z>eMdR! z0&KaPC3r5e4Qq}bdFx$aCWWo~EXeCsp&C1?2S9rp&>;4OKL_5mX5ySUg%)_MQa|`a zUukJfnuP;vJtZGvc%}-qY6j8;z85P9>}a2J^Psrwle(O`w|KVx<@t3dqo{yc_6_Xl z3JYYW4{+dLm6=Rm?Op^(AgvVl}VHwM$UnAU~*fec~;Z zDCH`Gy2>iUdGb!4<2k*&y6nc)nBmem)Tjd4UaU9;M@ zHQ~2Ry2n=8$=}>{T8Tgo`pA5GzE6RxDZdXLVOsRhr7e&{&Fdrp8CI{JBc?1s;#-Gv zARvTl{jI6l?yc-AioynpLNe+=5||wQET^W0^C=`5|FrbgksGe>EWGz6PIf}%G2fI@ ze@h;-yIfh>#+C~Wi77YZy*dgA6su{NA{~;#%UUC@hEEDwd#i0OPRxk`2S_69QCYF3u4drj!ulygL3@Bl@8*)>B^6n7M}6%Pb5DF*kci zLbl68vYpicIQoY2K1Z9kLy)6SEA_bS0e%4;3$_nRPxd3*zp z`LK!|os@scsxj7<)QR+Z(#n26e)8wQGFA=~g+|GZ;_*_Y(uC8Kr4L}n243Pob^u?- zhPLOi0T~;jJ=o6ZgM3lT@FL)*hEZP|mcyB$qn8t}hM*G%oEjW7cjQ_X*uSQx^!7;I zffv=~4Q2A3A9kBY?cdFgdTJi84!Shn9`(z&ZGA57K2+A^;6kA@`h7P6r9+wTOYIM- zv&p>iAm`!i?5t(ime8weQn-62j1>mD9`3G%gS_Oa<-vxjOQIRP8?qjEnV~g2(}1`% z`8}}zEJ0kQMrb_xA{2D=g^D6z#a6s>ePw(HnW4TZihd0qN+YMg&>a{Z&77SYUs+`! zos17(X}m9M8l3{4bzJmkpqJOW!%6mu8@YahE8%6O$JI~Cx2Slh<=?}aS9@MMSR&)! zIfrM9!m94kCDH#aLjpxSro zj~QvQXOX;-%nbEMM-2P{eaZSHohCanv#60~)NWqbMv@uEah$H4@~;*IdNolz)U_yJtR-c0?z*q31R_V#N`HFwoEuI=7 zYg9K3sOJ1R7BSoATf0cxCXq{`Iwk#pOV9nlZrt=wuUVXrR7D zwC4mAK{(pC!3Rxd`Qj8>*N3)6ZHxCdD)M~43Un=bY%(AswN`Sb@Nm{^Vi&WEEu|4c zB*e$+4<->3ol&^8MgIm66)3xAPEjJN7@$7i1trl^E^nmK6BeGKvOo?*)NL3ULL6U$ z?4H)^5l_9@xf@mjugeC09 zhm-vn9!l7V>Gt5ewus?954POTJTj>bG_Ey#$g@MG=Wj|xbmv%wcUjgxIx-QW#dbaH z!+L+%sqBL&OyROoQu4>UeybEMbb@ye`F-OTC^DH;I=pk5b{00PV9QG6e3#!*z1`=C z@yvZ@KJQ{v00DfcAOU2_>3*N??6>s-9MXvNvgTP?tvYRE@F$|jJ+>lMf`jan_S1t0 z58BKPeV#Bxcrs{UKW$LRSS(-NsT2S6EC7Y27OR+lcfLc$MCr;1YmIjaPlti3kr4~X z;qMZ$HYi^wt8#dKI^-Aq>eaDAAeSQw@h6xO#3F-*rDb>bRvHtI_V{Ae7?*@C#l1MQ z_oMZ6HEGG$egm|hYyAVTwOHKiYAq= zT@2I~Sf=OuyY=sQZOqAR!n3ibtLG9WUOr$Xh*Ua!bc|a4&x7;xQeHnh2-R5U6QWvk z{oOh9!2Tg4&I+#W_^`8yzeks6GQ$>q2k{vf+SZOrGsu74hY$)k0Of$VzwJ9vk@S*d z#TCC-<&f}{qQR>n%usXAyb67mxa;Mi8higDeW&@s2(PJ0jh~HShJJitI?PX__P$>! zb!SpLmi5O7P@JX+rh22O`hOh3YdDJ=NSRkArOj<%_sBREkN78J*v(rTV z|IbAjR`dEa2vFR|bvmG<5tsP@auhr%kfYsInjUD5lo|;b-5he3niP4A_wipAo?i4^z_x-3$ zU{3lIfOeMmI1jMYHD$o>|GOLgy-Q~DFcb`tv76stpEecLyI+7HsA*&r(=}4EHl6&p zhM6YT`ujp9&l7y-E*Sl;@;rlP*s247bybRu!7irsA!4!amn%;FmmL!uYXCvZkvF^_ zO;j0M5P8mpXQX^W*=EH?#o-YZ1**p0kN1<99>keqgbb%vB$~&fs;sijzDi?D|yPhCQ4Dwe&h6^O% zuUKeChKY((Bhdb1CjxqM@}@~CXgj~mZ$U|L1QjGEU}zlG>dkzNpx1N=>3G4rO;k&& z4RspfdEZ!Z;z&!bSMVA;6n}*6_r{*vXP(j$!`na_jPLyq0oKm z9_vL$EzlBV&rfg*QyccxFX@e3c~g-6&f57k-L(VTiPTQmL5_fl9CX1(CaTtP=YRl5 zzVGl0GsmtE%I)*Xw>)pUuP@K$f+~sF?#YD{FIMrB896W3V>dwF=3y`bx^*Z|$x~T_ z2uKu@%TlF20?cAzyt8{u5c(S38r6Ls{Z)FN9ry4ne7{=&ldq(NnJ)eMMw0> z6wXI5nGZA$o&FCxaB?a#w3a{{FZ3VdZ+GaJAhaPxrWI42iwk8h$R^Z2>sMdFi&2_o zEgZSLvr0Sek+^H1YPBs91Q)(jsF)HGYK`Y?)Wt4VFa76`KQk8eNu1#+tovN-LZ4x5 z>e{kc+dRq*oP@e(ke~}YWBq6i`ZckdbpL{8x`2#tsnjhrB zC(3PG8}S68&xvBx;v^pv3xGh+bSU1XQA!7pnCTixyLtQliS5-hb7{16^aqMrG{YaYKWQNC`ERkUZhbJI5 zCq@m~(HEJFD?R@k-rzY3dcIKRDcq_LpJ&fk5{i>?6JI-MBqGMw!l`_;_2(zY zEjR8S_bB^DQU=1r5G;b2uV}0|W_*JuOm#f(<4$@TDGAkQ*i|-#^q+c;igw}ym<~B3VAL)`9{^9<~xQNikKHCuV z=ZvM5i-G@s5KNK7VJ)Da*BT_55An)Xonu!LAw7D%NT(ZxgAZk_iJ=w(Lakm?Q!jk@ znSR@3l2Yhb(2n7l(NtfrlN8PAa#OGg2uijFCCJ!41r#Dwwx6HIj40Iqo7*}#3tJOdvHlS<9HW}$M zs)qJi_L+0F^y>k9aC?VQlTN15GjckPxQw_9>gxKeMx+V58xkv9bK=(*4y>O~t-8;Y zLF{w=+}pk|+PG)crO#+W#3>VmT1QAf20}tK(!Yn=bFvsS-`T2&=g(8k=vc)s2ODn+ zvmF50O*If#l!wr;ww)JnT=&e)1KNfKpe+8{u5Zqs5357r=e#$5=AQMfg+v*X=#xb! zCBXFx`JSl+O${l6uJ)Xi(1jx=zX#>5Zd)QMKQ@4k#Hkbkp#XlBD84*pT9}Fg4b4e& z!*%HH5SEe~Is30mAt8YIxE34v;4!+$X^?qTBvC@EeDN~w{uo}9$R~LbG;0W6GKmPjS4hH4m)_!v}wChx()Qk)Tr)i2i*bjCoRlpPQ0kNzZMRT|AyT-Y^`r{Jlb&p)VT$Keo@=e{RbY{*w0Wn9K>-bmOh(*xQqZPh(X2NSh{M0Em zQ@VQYd{Ab8?Kaub)$uqk?rp_4AbCtlcnoA8P2DB_TTCv}HpO>jU(3g5fcqM7To`*) zWr^IFZ?iE^?jM%stoUa~^!E%Ayjp@rQb$`Iqf9;GSuR*C<3ag)yi;6ns~4bMZKd|qUPN+Y zk_;p+qrzc>2of07vgVIG_?Kf^*XqBLRqI%ck1VWyhDFKtL`}x^9^8(Jwkbu9c|mA% z^Q4FJJX-hFnY3cF{g(b~b_3W&V%s>U|N0qysVGKN&lB!lJJ0EV`3X!0M{0hpUr+~~`9_n#8m zUBW&X)e%W_&@S=HyP-Tr)qV{>Y1NVP=8YDhEv$*kG%tW^Y%4#@97v`K7Y0(L@9)jX z5!mVu#T069y0x2m`&1aQoQ88x$vqk87mOnoRsZImszGw63bpHadjV9CwCiNA`?e@E zMN4E7wd&m`c#a+G?DYWBxK0vrKNZF101c-n%JbxXnuI-hADfXd^|iWrY< zt-nHV`3H?mT=rAol^n8p%YX~g_m2`XSL=f>u@ie!N&vU2%b5j3C#%xsnfY5b#-%mp zdo%YQna_n1`#P`KKVQDP@&FyrZI*n9vjLQ);>eWOHlcGzF#iS|K7o}EoB}U*yv|Q- zRUc2!$kNKL6cc3TMtBXr5ot`(djKj_X;q1uz;CBsHO^SODeW}yYS3plnN=^D=aDjM z8ehyaz#^rnK)i+`>bxQrK%J$wy(Bf)0UZgl zI73K%;gxF>{j5B$EUD+Ez^*|OImG5%ALA4do38u5wr>%o@m>pL% zx4{nUaaR02L!(2;UhxDjY9dbzGnZ-$%3cl*H}-C!@9Buv9D+`Hq}Rk`AN+N2lrfw# z^k3r7+(I9a#JZTe#xsCyN3*HCTw!>*IzHmah_hh93yv~qsbs)wPc{4?M* zR6@ifva`j%Q8AuKpkm`U^_j|ZZ4g5>OO3`U5vy#}r;ummVt`wgdx*PBw$&AxqrExc zxT<*;XL|Pj(_@$t`C&Tqz=}FuNRZ*bOqkh^p_xv8{VZtUc*A7w`?zF%?h<&T&+{8- z1JH5K23LVb&n2k(BIO7VXkZfOrrSscyh67t}v3}4Eezix-`&?0D&P(tF7KsSFq#dpwO%J~Mo!l@bH-HRXwr4UOyA+0{wcWuSn zZxM8kQ81bOV(9@0^s6tLTXhCweGvjcDQi7 zluI=#b;dc2uIjJ#lArj7QD!(y`#7d?QkCNC2WuB+mdsACPSgwQf4%evCjqYfggb^g zwN|cwSh}D(^!ko`cRo)on1ya)qC zxF!)9fD%AkiaKY_5<@UhT&IU6$P?S(@HRF!6Jq{;;1v*Q;N<1{EJ4xI`Sx9kbh0xf z&jgJli7!R~c1gPmZQ9vBbx*^JsZFtxissM%vGpTW#8>!6vR<6(`55Is)omdRn!ob) z$AR|`rQgLph@dAG&D|WswkS$IMr#L_SEjQ&;Ru~l{>!i{r!Kx9`|vt z>(M!%&-tA9`}KZ}=SWb`;^Z}IdtnluKRaf?(vB%t*C30JqBZ*YGBWk|pZz=Xd+`{pDsZvZ@( zD^U_Y+{4&+s!@C|K!8cHf!J5OX7_r925izpW0St7#mv&nJKE`0;ony`+xwp>;2o@jje|WH8k3QG1)u?p2T%Hn2Q&*>Z7# zT%28HPWC^#*jQv(4!3ijic_ng^>&{%Wcc!J&H}IK&u!l=L)|x6(|C36Fuy;Yh(m@d z#%5G4^Z9h)+j?L-RPqTs2b-S!9F;Di`d7itCfrf?tKtnVV_Gn;%X4#*@kNFIDe)xr ztXM#j#0k+ZLdQWE<#|;**J|)ZqAyGotevWMh>;Iqa3%_eXCTeAGqkvO?+>YPVO5dg zgFtc0gHVk^SNaiE3#QIM)_WZ-6EP8RPzq`0tVfphQwZtQle#S7^nAv$DbNu%(yS?u zum(&C^l-dSDa6L@?SL_ZMMF$Xj2v1zEY70092sJe=ucB-NhmwXsbxZk4A7jEYO-eUH_Ef^H#xe4|yc_Jnewy0hNPE+Yfe9NJ#s z52oeJHn#8uu1cQ`g-w1v4RK()D&OkdRP_1duhFcYl5%6>XE*G_e*z`M@ywJmKW5u^ zPghfhNC&jj{X?~mhX%}qVi{4A(g5wE*H(s8QR6pnGYTp>27u=4;}&}yI`*Q{5BnbT z6vPKWQ7zI1U5RbbWjH2@lc84r0ieCBJbVju2Q-q$-)`LX?!seWWHjWU4xq&2eS=?@ z1{e!=PaujbiOy@yjMy-X)*2%?_D`N_P*Jn`P~~a%iw`)8sNx`MIw@i+bJ|E`cc}6k zhVvyb33YlE6a;VP$-E!Jc;~Ial#Bt35K8>UC8;M4T8edjRUr+-o=gtcM1}*kKv<(4 z>FSR9t5aykJwhe@csW=<_)U0+S1%uP@u^=djI36R-PVKO5yE|9vGKiok!)JoW=G-7 z<4{>#O|w`NY|9G^E}i?pQ(OLgs0HWYte0qpds748wQnbfoKH&pA~QrJ^@4*DusM=H zS4|=SYZgD@d+V$B@{IHcEh%So&qHi2%zp;Bh~bgNqizwW$9Z@9M7De0`^6_$Kf%ua zl!8Iv4tsZeIV|G$0iOYK2<;rXT!m^uwwmnam7|hUHGC;qC}NwuR@AqX3C<@sPG7yv zLSDbV4!Ef(L>dw0pKT0e2vEYr-$rSZIE4{$ZKuZ6Iq){c6&WsLXL(v}7=siJUAmG< z`FWj@_>V#zcG~sp*R5t6R1`i~^u~dhK!$m@gwhI7!@lk2%KJ0X`D7C`OGJQjrx+ti zV;yD+`E!GGQJk20*EVF%bUf&G7k?%n+&6{w2c|XRM z)VprY9%D_F4+C6lfD;!7qmrTbmmxFK4S~|Z4=8_Ep}P@jKh+mM){|OQHG7SK))KhA_UtUCVCEeX)ZC zvK<_rU8HlDkQ)_0`>T?%+p#7ofY-%K;#a~&M0ISG|o&Va}Y=f>U;^m7MT?6Q# zFV)q`Q3FeWYI_knKfp#~Tr-_`-8EeJqPbWKU|zd0ijao}>d(H-UcfGdJ94e7Y=d*vk0hL{$}%}n*zsL+3g zT(zYg0Yb!&m8Uq)3|&EJa7p3#R1j3{yxn`#k4dEd9}^Ik4&*lFKXGYZ&~s3h$MY?B zt1C8p#qW;qm$8hm`__EU(nY_}G-H8k*MBrm)-ZUGR^`GdBxkn8sKIOKZ?)lr#9+gc2?)@UYmieHBcTr zNAyCW(l1ePaGk@G4YhvY$p?+uY3LY#2WbTc+`#~(&IjtG9((r%2M|+>$F3YTcbXkSTik!ut8YHa*7&BbT^bG758ycu3TTc1TIj;2(_XkGP7T zD%gQNT*YhhA2XJ5#vsr`1w_1z;t&7+nU`j{x}hm&oV?YT|Jb->V*b#(udZuP7Engz z>LTn7&${*l05e{1Wb(`VS`P%{p8iwafabgic^uR&hICYmkFIW}(S#Gt0==YQgs3Qe zOJnrZFX}rW7;OI?dnDu6SES5h;D+NdF?L37@oO@3kU32%XQ-`(dNY=} z4Hw0CD@~7gy0bND);e?RgbBx#Uy6^LK6>d_al~)&D`I8RGY^kMcmBX4>Vkq}*oTh4 zvx1610mNU5XXhgHR~0hhO>T&?IuI?uFXBMf;R6Apm#_V6osr=0e;hP{lkEC&Nq<>v z^JJSB%qwnNE1W=M-jie|W$J(U^Y~GPRZem|R{3=lE9MXr><9D$e-Ij=3s5L2v*a|| zyeCIyHZmDw{x2z_6(--G7PRbpmU3*`0!oezfq(mUXEEH*GHEkp?=JgcjKfqZA52_L z5*MXEG=J=<4|_3O$nW+&Z9EibwsAZKRln=)2vz%81Z^&qZR)D;NCH*VfcNlOtgGzD z@eS})sL(lF$))_facZ(VOE1Ei4*`i|^jgAU+WaBhdU(41)+VI&!&W+ z%|@&KNUOxo)h*L%DuONu&1ic*4&w_)XWXzm;23W}{uWamCZwRN8=JHy5OaB0dVcPS*r4|h zhx%E3&B1xek|^Y)*|72d5eJc{h+*`#$4FeF&LPw&iG<3ZLlM<&cxZuh^IhowA@Y2$MOs z-9eGXo+sBq{OjujDYockJaACM)gpZ~kadcLId+^u>Fu(p%)E;?uU~(kj|SSnxjP>> z%?NUGa;gaQ=}k>826Ac`vS+oN-}p*`Kutjtv+)C09k zJOC*^7xhJmopD1c6BCu1`Kb@RDhhnf$CcN?Md##o@K>jI*9Ws<$_RY-sn1%@GfKBg z5UG_Dbkzb8;VaA_v#_9@#zr5KTCxYDQI=Fb6E5qvFlCjB}08;dPH%~79xA|_Ku zt#$e=WLvZ2M4^qYMOqRy?l^ERa?`B|mkQxGjh&zBPrSM?9_0k!uf(Bw5?UW?JPt(L z;M=8kg~06%4-N&?NiwciudNFt4i6C`yngum+^JZavsF50SFOv_q)Du{lB}U1+CI&u zl-uz1fq|2Z@WkUB1#T^M^&~ak%)GlLJJLnhC^Qn5^U39Y!joPO2a~NCkvao9XbLb> zP&(81G4%Co10#i1Uei8+G@fWK87+-Z$S$GW1sS;cR~w~+CRge`Pf~9HW1@!-t50gF zv)&WxwPnAN8a5Z4@~3`rxyW?&<4kb)im_UlrqwdAqmg5dyt&Zem~}Kd-^@7n?;63)$S4+e8LstgKxBKfRw6>8HI`Ys^cGPQ!K)RgGC15%=w)z+Wk zMhH)tXxnnFC5-#ZMDPCKJO^gRasfO$hvpSG%G&iS{G(R=>touKFU>`mjE28D%!r$67C)}%LVL1PM0_P zfCs$^=*jRIdO|&jz-pi$5{x1zjqzCD3H8>YpA;BKpPQG8oa3?-mF+k) zR6ocwjnxD-m&MY7fCOQA@?1*Jf5*kuSVeNoQh>pQKENIjaRCrg`&$nm2gSVv>f|BL z+i`*CD+5o;74rozLK-gz>HH}cmXgyItEqhIsJ~3_JIk;A7Ga}U!W^4dJ6%@!9*nK) zIhnRwY%jJ;<7>_x_d~)aqJrM?+SieCh2%npBWooNU2~^R#2l>gG^ob2lJw3 zepdD_G^NSb4IBAgpy>ceOWvecDM`ILM%j$3M`lZ7xWTtKn-SfMA#1P#iGz9r9Y&Z6GfjZ-cYmz1!BiOssH zqvN07-5&GVhMz3*`OP@r1|Snw0GFtR&Pioj*G^Jhn;L3fJ+V~(;wZ+<(P6<=u?;_c znx~1Ir}0PC^2*L%ApW`m?{3m6kXg$PAk$r32&7|ZW_qx$>YIVHmK^%{VhO#5iJy^4 ziB;0K$3du~KxLn<{*`awJAt`V!B8bU=%{d?<{ z+i~{vlyQ^#vR|Cd)}^o36D|^v+m5qU@NQUOB^;b~(Sa$r&a0c-Y;Y*@sCK&pR5DW1 zpdt?`PvYtkN==CP^M}7{mdw@I_%=oJ$+L@AJ$zcr$>mX3juMJKZ?q}h%tG8;vwP{K z{>6BEj7-`bW#r}ex49CR5!UIqx;qkuM2$14&We@C6w zgq&1(!c}LAdN;o68m|8m1{pza!xc1mar=D(6>$A8c^nxWylyMP*1^gn!Z)vzeRneT zVsfu13)-l>_{o6U^;P;j?^jX7T$?<$@WNA9d>WHmW86$K*zSvd+OVSjSzG)$;?F~| zU+Tv@H((dclRps+eqWncZ3CSFEraP(|C5*3QBwK11nYnExxA<>r`C9V!D<$g7w%jN zMgp&|sC%X7;f-dpKhHgd0p#2x?k_wpn0HbN8N*V{Bang4)BfITKPc zc@>W_-}`-5^46x@VDX8A@(cQF985Q1`14*$Tz5xR%ZQ`6RpK|%Prt9%E-H&>8 zslM=FZK~(s{p!VKh+EW+Irr)VmGqhK{XS=r73xhY+~;F(HAARY<&8D=fj#Dq;}yc! z0y^81@ZxoWy^Rx|k3u@UG|l{b?H4a8P4~ywVak>d-&D_)X~?~Dah|ICx65%$%c%&# z>(lluyRy#j34hP25~m|Do+Gt+v2*iAJ^P<5lkGqJ`K2@6)%tFB*-lp#qdq(ueZfKI zY-$m-WwMB6qe~(&3Y?j8Ru+ci0c+IQy|nkz4gN*4h}|1b@}g>+i`$d&FR}Azryy7 zjPe&n@+dU%Zx*gBumjI89Hu~5p|>6de1lp*At^aSPc|Odc8DN8z@b&}S%`xCVtQIy z9NLFQUb`k+H23pl-m;ny*MxDCHN3CsBhMQ<&BuzKZ|{-r9d;|* zFf*~lH$H)D)*;Yd;M%bb_|ox~zf(v2SFVBoiY$laD-OBH$Eb4z=vyNcC3#@lsVc7~ z@4iC4&WRT8YP$LzuC>;tGEu!kly#-c2GccT)C0rY)!}PMnVc?i+wf}`G^>b81#$%% z#?*Dtv98zWFr@-RN8?c#H(exoDgjvv!60H501_Qf-~hdSeMCG*>8wgvV2D2$^TW->rGG#c6x@do9$MU9)z z&{!?w_(3SF-Dyf{CP&CyMlc zgXzfxpS!-?+xuz0?vzWdljM9_0Ijj(=luISTF_)Q(GvOST4!cyG;QG^^buD??P&iA zuj}k)6D>dvvk*J()`hwZ0|kUXSRzQm>S+Vn+;Ei3s;_(a6bp~~e53K3)-&aICKE(_ zhs}iqra1p~}HV^Hc-9`aFF{rH9(lQk0gUi_Pde#a;65yC=0gl%2W zU_Uo>O0czG(y$42SObf?oqWg9s6*9i>9Vn=!u_eD|A-8v(LG{ZMmLEF2?A(gs6|~< z6fxX@9hwl1^qMn;libl99mt8?y}ZE~{js!e!qw%_7GX~cXB)0CwFQnax|V0(J3e?} z@|<}!YEhB2hG1!;U3yi(V&U>%E=z6VH}XgDOfpB4X!|mYI;6+qTdK4& zF3+%VJ#`iP#aB=IkdJZAV}loPK&j_l+BM@~<0phFn#C$Pp5j^DW>bE?tAmnx!*v5M z_IgE`<%xOCl3C^{c7>3~-+0jQwR?zylESZ?-bmsW@Up#GWtJ{Ag9-~f%WnAK-Seh z9z0+7k>jq!o5^=7oDL9Vc;!j@INEyV#3E^0(OVmR!I0l$06~q6ChCC1a206`gJB&a z36Da3|NcD=_&UfWnoX-f7*Vtb9X^y%`ri&`LlFdi2_$XxkR`7tX2WOH66fA-KTctC zgWrlisdzM5yv*6u>>z=8#Q!hlyMLrIurfi_Uvr@^#8e*yC=hPe7DE7c4S>CrZpixZWRxlw&N2DQ`Qy%UX@f7VXUA} z+l9(xH|yMyjW#^=9zvkeOes2d1e|V~POr6wTN-_TSLd$Iqe%t#(Uz|@aV+zcf_1Qf zr%ZIzgopJOCPrBm949hl^jY%W(SL(eOOp?qvsUQNF~FPS?(YNYfkKOXlbwD4v9@Q;!{ z!2&L-pK34d>>tV2-}fg*53DBwGRgHz82b7PpNi~`CLU{fP$75V`B`!M7~FX_W1(sH`{ z>h=kb??exIn8uyO?V;tq+$QTS{!T8C8C2J-B}RF((9-*tC-=)m+!(ymMbw|@+dADp zM6B)HOgxJ|+Ck`;FDShN?lz*ha@YLR|M}&uAHo14xPq9uRfUU$Af+k7Ax+;aqtf?W zExe`JZwtbvJs^=!YAaXzQ3ng#r#UpDDuu}hDrqB4#}uM6&L=lH5i z0bKZdp{u62*h&ElT2(ri3dAUX8Hb?HoL6M|D8kt&FVDDDr zPSXrTIjj5Ili%Da5-}5sM+fVy`U?34cHq==_M&NrXY1l(ga$!od!XLnS*yp5zl=)P z6Ah$8h#YR06|zMNrps=>wNdsHsuKExxmo~kM9cJ>nDX{LSNm!`lY1-u3y8 zwsas;tDOzK(iHM{OX`T_?Egec1|_n6U!Yko`%$R;ca$!_bO}zc#GYLE-4X^*H1-u3 zL89zGVYCWebX5%v*#b@}cz94UCkKM3TvB_P(7vcDXps&83Op8UL|*A^-{)(VYox`U z&rFEqXqucdLZ{%yjP`~7<~ZJ4oOHa^q%~VE09xWrEhH5OL_Zx1MPdv<7&??A0GS8n zq+(Q2HIFHtbsP1lQCpVgZX){4=a2iWW+nKiYz*&9s&XLiK~V zdEe2f=iv0Qrg%0+^Oy7T8iw2HwmV1-+Fs1&h+XB0nXDC{2R&%giC6X7MLHPbm(TpL zPiRJQ=Py(ltEJt4((M?z4_IXN2#ajP_xxO@f;9Z8ALH%?2Qs8R$Awy>)G{9zgy#4M zqGV#I$zJH=9IA=TNk`v^So9;9Bg|%Bx|S zP~nSp80n}dA*=~HXq%$WM$sbC$6W;V=Ab-E7}im`nGJ)?Cs?h7=;WDO<-ApNa>{Gg z1 zSPUd@jN2PEu)v(Ga=JaeNhQI0a{9W1^N(R&k*T(pF{ey-mAg&?E|UT=5!VPD`@%#m zgJ1BxXfR3^vgvx|!06?x%N$eX$zS(Xfwf^Wz7DN_r|W`)ZaGiJ@_>z!t`}CsyyCJ0 zs@iCXz)z2RCU|q~f;OM8+?ZZXwoBz@U*J_;+V(jR_PCJWo~H9Op&!(4zKm?Zfh2qiEQLsCE=eT1;1Sc=>I@*qnBx zsV!a;g2zRpc2VQ=X~#R<WHhA-%VVOr&C@CBE;9`W3%5)>T5KY2Tl92lT zZ7p1Oz#Vcm$G+E?F3Dlm@AG|K-_?I`y3y1$Ubb{$%BuRk6a_scO0lEcgW2Sbbh=<| zJxZbB>lO-^ytd5bmu_8O_OhTlB3!AnUKcnEitsO=cQ9{!ig9rB+b<@+{A}y;F75?R93^eS7hnkSR9O^+c42YX7c8Gyu-M${Ps|Tm@IIXuvcnyMqPF zqcg>$g^nH9M8xtR2|CGn<~S=qZLfSwK=T?@KByQN_A{&tVs3bQmRUn$_oBw-6erCP zIu~2U{PE9P18rO;(T!p*SF|{Z*GIS6qbv1z3pExwIGXYwoG=Z0N3nAU2XnNIm$(XL zOcDEzx)=F3>Mv>*uWX~sH8?%9lAk3weBP++v|5g7yx?Kl0vCbhym`H;)2Fj~XUso# zxovJ&o!q>CtY`j+f$4(g4B=Beb%NAvyzvaJ0!CVHxX0}vr%^lx!pLQrkRH6EQgeJC3R8@xqOUDAq@yCt`?FODQQQ zvfi<9&OafB%^eA*ydR~sMD!q1P7$3~h$T3hK-Y*)Vs1pz(#=e+YpoGuhn+nqCj$3n z#p%rc@y(_B;m0}KGjq7O!c!f4Cy3@Ng`HeKTp5htMctWL>}s${N-eLfj;7tMCu^aW zc3!@Zi-K>Zqq#g=s@u5G)wabXIA0snQlJxBDf{q=8)IP~N-&7NtD_2cc7NfS%(p%n*yE z>Gh~I#y1Bme4q(e>s4fubl@1&QSWmT7*5;F96E6^&-J{g^zdXqyO;`D5W|+F-pZFR zBcfSPh+gEXc6u(Vy7GiP=_!FznGEBUTCF$YgPC!Cmm-n2 zptnrJ^#e?&x<0m>8i>){p8wdoQ*$W@PZeS%a6RnVdlxba|2)nWlRPFP+0H*d4l@_p z=aI89>=$TK>oEO@nvbsGx?XrPV(vot);lE;bO{dO#bqn0K}+;=kGH4z$?TX|ocrYd zo^!?aj+SQFRD#-}h-tjd5~KYmFIQGSUGr}}oh71EJ1G~xL|qa#{M_M*_M80j{m%gL z0p^!0ZzaO!?jMeb>l%D5Z20NopJM5?GCl(lb;RpZ61;EKueK(LhAi_IysY~Y7SP8M zqL6=g##qO7c_Jl&nYN^LJQ-XaiTrNLkVW? z&Vu_l(R&W8jE+2YwU2u4V-YYv)uBUFVzmq%x-cy-`(?+&sm?jF2A5|thDTQAQ$f9% zW7GSG)>gcSsU%u=)jTp|^V7AnA&Yo(CRz2F?%d_gHPv{zcA={sgH5G-$BAPLB|}1u zXNB3SS;OagBCPM9-#@;K5pCfiQ$KzhohK1H&p&C}OYPejU%f}hz3-*gGW9};LeKm* zdM)ir*4jD3)UJ!#rCTEedTF(UM}wE~v|CF@cCJ*gQha#!c}!>G*{<;GIyB)39UU2K zNo1qWOzRp>6q3c^Of}*kZo<@W$6-8eggLtQ)`&D?RLBJ z6d}{w&0PVf|4`iTX!zHUN3ro?0ralfa?8ET`#s;^ncbRK$gMfWo5|26FhaY?5qqP0 z<+U44%@O>K2X+fQHOKIaM-MMbwijAF=vJkkvaM06OPLpOQr%Lst#kQojY;Qom*L92 z2Z_?d2h1r5!j5ie%c@#x!m3)Q4`Hq#1Qy6J^7h`PM$I~^S5%he?Q zBhg8fsw4WT+Z$rlTLEG2A}_ZXbB?rq+);8Q%n6jbcOT>4{0O|gjaKh`*eC0}m$QW{ zl?zPa^5s}dy1Fx}w@pzmJM?aS9-Vve)V6vlc+rnI6X4W#P>YApvENwhN#G36GEwbw zos_+IqIuoE0vErn+%k*Z*hZrxAHoYMnU9V0COi3@4La*|Mw_AYjL5;6H?{{0qoj|F zKisX=Q6BM`O!0ON*-NKQ#aK>Dp>AiVBcHHKf>IiNJcPXDwyU;v8SZs)U#~6Je8y+&kOvdFnfgLsnM>~^Rbxcm(1)g)tC6@eopK~;aoGv|cWsmSm4^&4p@d)cQDc;G} zO)QoYl=x}89Ktt$B=Xx!&fQ4Yl(&7|JJ{tk_^~2gOBvO4s69EAzwLx|RMw$_q(_H3 zE_e;w_dG+}yQcTiTR(a!9$086RHQI9&+}|d_alxh+@U(?@VL>eXMT^tR2~XHR$9SO zKNqYK?ovJ6<;XW~Wf`tpfu)}6Psipt-|w( zoKv=VR5NITKKmH~yj1Oz-h-_Do9nLMPOjr>@klJN*6}x9!)_WS zA$;672#pGfZLGphS(xGF4t3MrEjSU7($+Kf1^YJ5)rp#q$sNDdQB<}+KI$b#TE6Is z%&WuvR6Gk;`kvfW%hr1mR7N;bf(q#!KaS2@s*cc;9f_hGFJvKtV>*4R%!mkK zJ6g1?oV{r-Vq3SK=cL6O?uAsPCFi-0I;t%EipF8uj+%wsCi>ZPvJ?GNw&aT2QS6Us zTvdz;&0by_bywfce0hBvlhBI4?3@3Dr2KNkfHcfV^nl+sT}W8)-?nl{l#QlSC1i=~ zO}xKP3z3l#N!H+(bB1*Y>aW|LJF~8IzLXJt3q*fJE@vQ6-Q+DG&V(?QBoy8+#kHY7 zcZvJnr=4sXIp>_0XRwGb?$EclTS*?dfH^Lk1y?fgVExSlP`_pujp&kb2c@T{4e*pjNTxdtF_F2Owx?M-3U*ByyN$`i28Q?k->D0p-S+^zC>I)qxYOvjer_`DGCR zYf4y^um$EF$+C0HPwkn%gx&wrVc$v4Lz<1d@l?~z9ocvQ?*90yK2VW#EMME61R7om zxMLpu&#uAxx=2P8ZI84b-0uQTARP$J?Jdv&>!59v!n7UHYJe{!IyN@Jhs&>%Nyw(M zZroQNAZ$Y$e#XN*-E#sJgLAt#Q6ca75R?JsU@BBUWnzEKwl?yL?`SAuUDqg?dLWn zT5JD-n5Md#C$aWvMeabka1-0+Fts(HuGi3=K3W zZ^$nzw{rqED9DAlY@6QBj+1IvSs?84Slo2QAsX`>Ag!{6Qy?@z^2nXh6inWAnb1dOBms33KEy53=k2V zQSHqX9P6&nZu^NKxn`;b^Pd+(;t0zF?8RgO8C8GrX{N?$GZ>bBhi3u;49U?TIfkI5 z>Z|8B7?!3y(gKAJV*r+j5tn$D;le|Av;p%}pQO$YvrYd=_u>4`hty7HrDVzct!8c< ztqz~TNDpM}7!gp#{f+`>-Fy`%0kh5r{EVc5hQvb}W<}f2-7Ve*ewaM4StP?wE`uaj6`$tdArL?lKm>t?dd@Su zO=6-zCuAs^3hOwSy=Bu8s4$>+>2cLi3w*UL&x6z#XYL3$ubMXMP$Ec_i{_T=DIT*>DfCqqnfu7_PCESPWKq`3o+dG+VD`!sXv#iqL{|5T{;HVsBge4DI$4U5LwZ!k5yU zMspR+6~7}4*}NpY6}I@w@+xm66SbXa!c+@5mAuMySI(S)KNdhDs6I_)VY|%rzK-+G z3s&m(uq7KXb;D&eo9t-eyU+z(oS|FpouV!3_MorU;FX+tS{dJAy7ym=^ZE=HBh`0B zQL|1b!fn4uG^gGzQT&pQ(O%;s=_Xt7r-RtHwGTtYIDSxDSn72}e$T_ro{j~v-(PX- zkv?iMAGcuCVh`yS6K`C7P$!fGE0QgjrAY3Yk-8*@F@9nXM(LNcm zIJg#1y6coE%L{ZcN!U#mfpNF_Q%8KF$@`atKt){FWxW;#V2Mg11N&k}<5neOe zkA2g)Ah?R`Dds3nqA5^*R#R*WVfysc_aZvK;&F^y%1cC|bO6A9iY!^0LU&--B+@zK zFX;K(^R_OOuvj=TGRmkB;N}_vDch5Dhzr=l`kCmZmPA|?h%O{jKPpYbu9%KrPp#l8 zyuIA}(hbDn!g8Jm9+GhwpXnFgUr`t&V>e!SUZ$o1R_8t#U!OtlC-%6uTH0nU+bXhC zPEm)AKB%C3xSjYb5KlBqrg0+KN`4;dOs5VLBHlSuo#^}spZj#`D<#*=?iDFS!!qgF z*qLgAUME(w)#L=|xI8~U{N4i=ab8U)n&VbQyE8#!%C}2%vKTmejT4_S_* z?>50{ZhFU_t`>v^8m`fZk)I5Vz<(u+bJ`JrccT!Es!s8t|2)pI*z5+3?8JEGr4j6{ zd>tY%mE&wart8&UMA{#Xt>|aq2g`*g&wzsfuTBu9{xbSx4kOc(ZlTLl-KXa|LWAt1 zEfBn0`);NlPir;qZW^(6yY9U+u5OFlWjKt%5-2#i+Lor@A!Y8P-{USLw)q{G@?3id zvz($hx=SxU3&Mmr?SDmVn|Dwk3UZ;?=X#hU_8?|0e%NjzbK|Kd2!T#gD+q;Pgtgst zT%!@>Sd|4`ma-EF--(o)9)NxJ(H(o~qHRi@lBoQ8IMp)1P4wNz8kQc}Val<%58*Xw zlO>I?x$n^|**hed^MAQj@|CPKTh|*OW(sl*^<`sM+yNmF|Cu-27Z{?ah5+0OPV>2UPI0y9jdZ|*okT2MMYGZ+w zb_Tg$Yvy`KqwB_|gXRczV>~{WvdY31z z708Xn5Sce^Li&HJKM#g4W^Z5nbEgAfHP-%|%>*-%wV!0N|KqO*)dD^e@`D&{4fFAO z1sORouV5J7(u-t&069J1*!!7UM))GEY1?;Ok&9k{dwOuP>s@+vc@WqP%EBNsCOTT6 zK&eQ0W`Q6hP~b34qk(aHg%aG@)|pO zeP3$=i4w^N1|y$xM#8uI$d}>#C3wtHQ`JI#B;hTjG1jPqVk>%++Lk)yq1Dm0)J>M}N)HxDHAO}EqxDwl({D`LA z`vwtr5%9!9HO)B;R7Q1!nGzFR=6ydT0879h;+2s|Yr~$&2j~)CJvU{o86O`C#a^Tdt5p>&P z7x0Lyj7x3?pL>EB$PW$>9kiJ`NHyWEq2RKE zz)9r|dX3o8&wwgcb2(|@BJ1~viDv>cLM zkrye0bpEuC8^|NeZI>llP-IC*e2&6MGy}&;k=9c|(}p|?tLd2Zp>ogRGY=r->ace| zzpGgT;SJSG`eaDjO%=0^+GO5A-H4td6yExI(lha+eg%_8;oOx95A1QHlv;^K&Jjhx zZEFw&Fq(1icISiX_EWk_fq(w_CNQj+L(9_WG*6y1uz!uAS}_!v*P!2nygOK6LG~^u zDF8Yz6%LRF%aO?7F5`P6-)(MqBTzz8o`$9QA&K<+50E|l;NreQtm|hG&_(D9_er9q3)5Q<=pf9YJIm` zIdIVd1<#ug8XzA`4(sSZ@{Y*bsjHg1A^LsG&7@jJ4r8re|a6a0^i(J_ESK*qhe!eV;=^xB;2ims4EaCAZVm43QSnXHPhi?((X4N(S<`Q~sSDXsIYYh7N=~}KV~{|Fd$=fPjBh-<&Ac=78Rvq} zg@hxOI+#tN7o1Jbwrex);%qnTk2Mp@X;Zbj5wUGXElgIQU$CFo5$n4!o;F$I>d#MU z+E92^$k6twT>rNdk4euJ5EpO~AfcD`sEZ)zXUl^Rc& z4_It`D~^*_DyToya^D#rlZtON(6~}MUH(@3t=owHREnJLJmGMgG?`+D z)1nh^a5PKJSGFSG!Cy~ZMN(37;*7Vr|JfGo0|zc^{iwq+kDB5_u{Rv~-|WZ_O0cl6 znrS55a9ngK7Qb7PRF~sj`MtgV<8=KcxAu&BtcPz#m&EMs42A6NC-? zIAm1Po|qb9>a);=UVBP?0X*gXm`2yFvs`6$c2Pbx+L$pk#Izmt?4teFPFyrEBa_I9&qF(Tird$ zU&g6LUQ(NMsCOwI68YCU1oRj4&PSxJ^DyrpH%pa6PF9SX_}6!1G}fMfWG6YjNm%3d zNm5K*f0!2r*chOQ*oy4;^__k9F(;L<`>w1bcF4y17(!~(>?Eh4crz|wKlHfH@nWZd zJvqcp4C&Ia>0p=t|E6f(41YyW{g0=zxVL-!%vf&id7k(9U)E5fqhX<;prD{rQNEx>L9r7< zL9v6lXE%7|K56$s@YfEwmeP5OObpu?_+^)=yt+IE#fL!J)jO2ncWRij4xEC5zJmN` zN2SeMBMOQfS(OX&*PIO|W2g!bwk&-)*%bZu`{$lxcP}XV^L&5tn$Uj^h%m}a58Kob;Jt#djwMr0Rv+Y$l3f;N!+PNNo z+T`JwLVWx_#Ey+uxbUASn`bq;h?n<(ag%>uIt_T6yYNl9o!DjiohGGhD-|F#S;h;eu`SD7y^vowlo&5!tum1Xffr8@tPP&`S(&FM0lfLyZ=U9OT{e6t(7rTx+J>R3wS9NwYIiz`c zM3#^FAmP|KdF4ghD$0iHuHc#)?@YcSg)4>^tJwXg*?I!n-wS!CmzkQ&@ljBGx*7|{ zcf#5r6FikCrH}2=&}&njuWkL)d%^QU=K9!n4Zp7QO%tpzT%yH1mZ_(t#T3gV$WeS+ z(u$fq2wKWvo)uW%LqYLimO=)jSbY+jLOdma&c)ESQBpj3r#R9#F>jPi6lC@@hdH95 zr{%Z~vgmW|ZJ=!px)W(xZf1{6J%tOW%M$+(TbNzt8bVMVWzxi1-FUi|9xsv+(v!ow zbjcromfPw`(oH;DPnm7yliLTqgBO-Wo!MUFgVx(ADEJwx#oiwt2tP?Xd=^T@Z!O8W zF@tD{(L40z@XH*JMjlLyP*7ZUIKlGX8y4!E5ub4o>0|p)m{}ylW(w}~=$~lLp>l_N zt_V@#tBFp~As=0_{U<62%}9Toe!xiqTN(3DzIEJXh4@>!+LEOR-Q=p3i*19xBh#iT z#FjV>(pRqBo&yEX67VqwyD2XFD>KxLeGoaDhg!MlfPJcpK})=% zca3@$OKv0VJ03Ua{?+)dEI1jKteYoCf)Xq1 zv0RAiHyG)2I0doRp`duXYP9zbi|Z^0;_PeR-kinxh+Nq$zcaa+)20yKM&nz&Y-0Mz zK4c6-yz%)^eyC|q>>ieDIn<>y{gOvuqAvTF0&F|E+1}neU#9CcCu-6qOs{Xcu);(^ zaWMSniBpaAAp|LLoMe}Btb5YxuX_lrkX+D%FpWzmYA&eA7vvs9$;!E>_%%)22fcsw zS)_XGRnJhcx50ApKuxxeCd;_I-id(4135iajzS|I1WrWGH1sAf670wPBR>N`s0bBy z`u)T}RWdy|e;V97()Ji!t`XbI%xPbH@dHlaz|*yThw1N)Ib`eb?(si15H8eD^{DW{ zD}(TQ*QO_(vWgxv`Bw`KjEe5|h-b$R$m)J7IN%LW%v8D($oLlG(xnMvnE2)-8q;9V z?StC(%tTaRUZr8u?@7P%We zZE6=U#Bxhb@1hEW*U=_9&2I%Sl~fo8YE*A&O7_P$am>iB;D^09GpYcZ!1IqZp6yt8k}*GC!?k@JI@Zq~pSuY%JzPnW$@+80lJh zNS8tMzAUy8peZYHSA+u5!etRfclWnd(1CcZ`Qty1OSnhf1`~Dp&StcyFnX}j9%%CA zj?K`0Zue%Or#Qm(GcEl7#)k)d2wv+1&L5HZfvTn+?~QC442ae{k1w{N)~_4;6F_&s z&+F`SFbpFLS6af)7B9C~>%!=X=WbkomoVT?e#?o#N1EO{C{z^;d? z!KKl%{j$`%DNfMs!b->YqWGUOhSvC4tL2-1_O{x+&zD49^=OK=Uv0AVlU_p9z=bOy`zW6b=2PsK3FtXH9A+B4bi2UTfwS*roukz znM6zv?sDm>7vw}sU;>*&uT07DT9t6ZQJ0=)MEmb(I{m@YGvDP%D^q*#?2-;mpmpte z5~nr}%H2g$RjB;Jtjmg7BUU%zC5A*hTU7B6tfRjFbms~10iau$) zY*@!PJnEJD=$kkP5Y~%(#Q9)CBdINkH_d8bjX@EmrgUVVZo#gmUWkZ( zS)kjmbpu(-md|Y6-)>A(a)Jd0W<%>spES`{;^<6F=`<_jBmMXE^iNxcc0um&MW|r} ztk;sR$>x`E*w2e5#XWcCd)-cTDA3)S(3F|x9(OAAk#(*2I_Ju|<;uQr4eHMN$+-3@ zzncB3Y?>uJf(-u{mM-IkRd4sdOwu?#jYF13nCU#qD{s za&htaVT#vhhR#B>ZhqkA^JWa7K25l>i!Y1u?g*1wb;n0^_AsIpH%rmIYcKCY>H1 zjiTkB1Q*zR?j)%xEZGds66E?h-439O>hREj$4w zHkC0I0{RpViQ^*XHSI3*O6(E}G0WX4xj@9K7i!+}t*O8XIYzQI;^QD;&M)AW`(gzG zZ)An{s%ahidhES>NrzjZ^l@np-lyP+*KE1+Fzrc^Iq(WDJ$9{39osobSLJ z$c>v;?)`E_a}Za!#-$(?^vNUq6BhBh`AEKI&}8s1v3f^{)uD32sX3&(To=){SFZmm z=`BOMI^FW!+)h34KE~RboO!U74LZI$_N6VMTuge?X~q<(%Zy8qz~J zPsnF%wCq(`#+w!RG&`f0en~W!=E}3p~H~j08wWR&2X_l4C3c86q8cZUy<9p={ z?a&gF6#MtleTnB{J5tWBrJ_&*zh>QuoTHj+vL+0Sxbgj?NDMU7vG*J!7QC8Y;Zq!Q z(8A59DBxa{271lDbcr!m*qE<-=goqOd0awA$#mtIS&h_N~mgmH7rAV&zkfKy0|T^Uy#*|%Bm)+_R`-9r-?AoS++lf*N>Wv zzPPaBwP2;XysWC1`Eh-yRAxzj5$q5A2Ij$m^^SzL%mGJL3K20(x z7O3oBo&Vw$cOt2mDmz-Z7?Paaz0TIb5E3W;Hl%V*k@#X-_QJw%T|Tl^B~IA<$7Iz&uSq2cdc%(kGV?+! zmx9=P7R()b7hk#9hB`(^oD#?l!eIw3G{dp?uM9koN!f&wyqF;Q5Z@GvuAXY@yBvzy>g&DE( zxlXr3ll2DvL1Vn(<(>wMk0H(GpM$_zzww^|Q1s=EN**HZZw5lB=gKn-= zLw5_Ty4Lp#*S;qi<)M<)nTamk!|fi)cS#I(AguJP2X>nZs@m&Ty0=+M)1VwX^c*Xh3_--z6R9I2f=jJ)_FCCUR$Fz&8@?+o#2;LML{DB%sH7g~ zNlVNwEp_TzJ<*OQnHM%-Thua8L&RtLn3^~?86niQ9MTa!){kdio-po+O^VSFfa8zka9)sorY*<$+u@4$hA+EAl&M~LCCCfe^_}dv z9Z`CzeIrDjgxXYCAO=^f=t+I-vP;4bwXERckdfnDD%4bSxl2jCy-mr_y4#+5H6#$i z68Ibx+jLy*b#JY5raW27YUn=7Lx#V$lxFea^4JVM$UFSQ%m_wI??wUZ+BG0j0l zGrbW{%&x-h+}%=X#x;*UN$#nZXdS+sk~GI4Sd5Qd_tezu$g$(iaxY6JuXb1&4CL3l zpM%)yNA^fr7Z)}#o`j5vGJTes@s~I$z*8(lOY&ml9dR=ryt3#J6q0uMsjg{o-TKAe zH;I!WVsg=VMqvogLg%HKjsbf1ZxM~uSt}Z+JGmze#X8e21TubF($>;304;llrX%ad zr9PD!iq#tDP32~1FJ3j#e5O^_@!?fpdLceq%#Mqd0nu>Up}h8s6(^4t2iBN+{ZK*A zgK5(f%?pB)_O~M48Z5oxq1jWAU-{@WHD>IEnch8IjmVkrIzm6Tu`xY{Urw4T(a)&B zIwqoJS?vaI4&G0~?unW76+t|Xxru>ugF$cm$2`PfL2bJ>YMMC08)THl>qAWF5 zy;UUZLvfu%2?FV2d;r5|FJ>hpuNo89#I}=QtU6tuksopXm~R)qWE2?vTHdGo5rg!S ziY&TW`&&Z%uE}HxO2o8?7%QN>t^-`+25VyO-o1{Nb?+1WEXFW?P-gM5CiiNaIiGMU zV`F1+O*SgYYpH2dO#>l@B6SPSn_W+29>M ztf+dYBL!z&kPel3CnL+QPS{Z{@U5=Z4w(;m@hj*YscIlyDD+T{!>+J*eBp8JTVYFO zB%rS3)_G+K1~5b}RNqD_E~ueO1v^DOIMqSDOm7<}ae~mwxqSM>Vkz3Kcx=@4sZi}d zn@~1>mD05B<{_SrQCDX=_sf+Q=ibn{o5if}I2LVO<4s1M*fnK0y^)>-e1X zqKVKE<_~jEDm^3)q58GW$gPwuCI8t<<+zHOK#`N>&sMBUj+dj;P7^4epNN<|Ok(}a zh^tStGfqfvLEcj^UXtHT;9O8F_1gONE7v_rw0Z4y!-t` zQ4Bw@YY&(~1x0G-IY~(GErA(~ZPrX6E2)JGre=>4v!1>i??j5kcLq=De7GIG<3hjJ zKO*tJ5*{1E$LHX+?w{4;fPXn+dSP5pzxbLka!$+q+uNWs z=1mtei^utO^6pX{Bhex5FD`hS5}<&+9;G4SpV!pN9z_^Pyr0m%^7` z-A&JS(-J02s1r5rFZWdm7t%eH`i7vJYj*VBm*+u&a_cwgp?wrZL&0jn1k}ouRJ(D6 z{!n(Ua0sMq@%-|_q`>@mO9)_!F0}qAsVRJY`Vdr4X)%)>dCx+9BPAW)=~{Oc+E!;D zRd}on-fWEarnD}0!uK@gnqSo^u-4DUOcwMCQ?c7$wqx1oAO#IAT7uyqGs0ofA;+YC z=;Ka?P)v+eZ9%VRG=kIxZ>R>WCZ~=`#bY|H9251nXBo}fdbWx|(=zUhFrw!evh7DR zmk;m3xNr$Waqq|FeRfxy#(9j4u-jD~Z2~27d!g-{=-wPtP3E^1wI^6|^HOvS;~YE8 z?I!cuE{B_->9B>LR)nTpu&8~wG_Ogs-bUpe@AZJ{< zsO9(f-=lpp3{kQ=DQ?86&pm9H{Dem(9sJIVEd%}?hdO_#94~D?_nABEI^kwPBSesY zHJ{0|lLlqw+q+N>*X=KeIfFAB`8vTE;ZNiHv`=sMoJ%bupN9SDV_ZvGgqw^_kFzrAnsA&LetjD)2N=23G8Tut+Mf4tQ*K5Otj3>T-z;w-3UKz_eu?*#(bZM=; zf?OU?(?WT!-Q`qLgyl28NX(d)azidARK++w?Q+oOX3Zkd?iAuYHancgO*jzdsfTAi zwAr)#avQh)qKxTIja^upFHiO#>)NfRgip?3CCoKzI<*P3y}Pcq(Bo!PG!kXe_1=40 zGF(`H+}jRpog~}AFHNae`Iw6<9C$Tj1+6-AgCrfst2o>yjxtD3pEL42?45%&I}V25 zj+@Bg`O4v(t~bUUuC-_-@N;G+eX-#AQu`4{P4{3H5`uD>JWaEjH8m-*XDwFCwKk>> zya7xda=Porq7|RF=+IttyTO^@{Br!nS*xfjB~JgnoWirsnx1z~`u6^KFBpu0vq9)mo6nkXXq4|^&nxKdf$YI$j2OT;a?}1A@qUB={VvlJ zhj*E}*SJWjflWtBfoTGT5?wGw+WK^k2|QUDj4M0bd=urtW1??I<^}#w}p6sfLW}!|C3YcXKc`U* zW{_>MvW>^I&6~ z4D4>TrJ-+%DqnV7U&7W>GV8rW(0dbv3c3b*;H!%>)h;~puz<+aYe{|5&eOMx2pB2j zzK;*BE0{no^?Y_n$Oy=kkOPSM;$1P~CUu92YA@;I3O7=_gK6VY#t41KNT6XdHx<|z zrfO%!&W$zmWyiuiymK-(!cETA1R>2_N)CS$-ggePT4I?1MDQ`*vhoR zzEbmnnAE|0^y$%3C&*(P8T&c56olPVchGbC$rMZp{+nNYdFoq8R8gi46O(CPfXP&JN}7#>y;wSIF=6bc+-*R!y`9r|6yu z|Minlzmp)(e`$@mq=4^n!?ROkB=ycmu!>vjWm{IgJgS9sbZ9Z@zF-;tryu|(Gw|eCFz$&ie!k5Rf?E6x32tUVK#E7X#S>Rz z-KPA%2~m5)>n{2BA^1CU%|lkMkA79+bZ8z!FBM^9;i!&X%{7ndSiAP^olI&qUt>zu zFL4Z#tng!S!l$%dc~l$51%=$oJ(1l!W-9NypNdDf-^p=#8Fvk~uI`#LThnkA?Q)(O zb?T2$$1e=U*5nsTyfWxak8tmC9(-XfDFbzy>eA7W980VcA&b+I-Z}Iwt!xSS6!8PJ zZ9fjod6-7n7=2Q~^WIXIoM`=ZMFr$h7v` z@$A8j*-v(~oQ-|yHP=>BXm?`oVhlUJObRlC!c$@wCQ*TPx~EbNMW9)1Ur&la*nbB! zfukd+tKxu zI0e6lxZ*j7>7s1Kg6mykd&~IHMgJtQ3bP^RyaSkXV8pwJn0Mcr)8L0{qI&-hruSi)Pxm1Nj)JY#X@gSl znGrsRf0w~Lg=?hAVC-VMZ`@anA>I5?kO?K> z&74YkcR$mJ59zwa&SSa&oPOuVB@y_BUvA*Fk-#cOqrxx6n-I4SMzm}lT!r;NBh&B1 zT`N2DpB{Sn^xa3Dx5w;98;|CD>cI?(?Ne!M=vc+<3!L!ZQbTBmw#Mu?863`i535#x zJMU8ThC_By^F8{yYnq%TWH_l_%>%l!( zx#DZQZ!1jLgJGij9a#`wbBaH)T&j+%65I;l{)97Fd6G4@`^w7vZf@3y72iLw8Z)|R z;2i9+w>&`58xeuy%)rpM!MC|Y37@TrUJ;fF-2y&hZe$(O$-Z#F>O40qX_R?))c5N{ zYpvWB0sGpETmITofA&A8S>GvX>Rm>Iy>Qyr2VDnhey(}5-l4-XuJ#kF*-fP`aF^|{ zRr6#4SogPAJntNN^g9UV$a)IGCTi8rDVDPlRErYx->#Aih-%4U9w-C50mW^DH zCVH+d+kM)vR7Gfz8vWVJFYcDfQk+Ffc8Vb!!8^WU$&yh@L|Eo z&}}kLSbLX4lMf+8Q}(2K)R`cs9v42H8wX4gwvSf?Z3$0(en-Y^c|#sS1w^JJ>$E{x zq+#Ymf0O~&!~05&st*)_8p{~9wh+>ird^Uf*O%Vj7a%qDl3d>>Ys8E{R>L)pMHslA zEaK3&8T|5MxB#`5aVhFd^*oJ53}37>x6a>a>Tzp^ZY`iB4ohW0_h7;v1szi={;*Oq znIGK=vnidqXtb{H)Xf9x`j?$Ee-B?nD>YIx%y0XNy^&P@i zj8Ev9&JI;KC9AWhUQG#LeLB3sXmMA=HX zNKU7fCHeH!S*_|=?{aMRcWm@dAk69;rqV= zR_(9lk&crcNd^IZdMbydo!@r>kShY~`}n_Bd?d=)xbd|ataa>mY?+d?fqCNtx0;hFRR9%FHFER{WEP%} zI%~n(oTBOEFyKuvBrR)8Yy+|4{-7M=bty4yoI`a78?nMNlY-j`Bxp{hG@>RZ8f+{ zR}sZv56%o}kdp*!6SHvUID0>owo9`7I^K>w{g^ef&Cm3xl*06w;jV3(P06RfGkbuiuqb%o3(> zDwkO|WnMcGEV1JFX!qFM+MMK5Skd{-VCo1E(lK zMcG2u^vP{vIG#9kUd0%?2#&@0!@WbeYZ30V)qv5Jyrv6(QWn>vET|_(?{Pfru?gLz zEddyH^r!)XMa{SBTKj!97y`gRUv_P-d8jm2#;xWd702P!tWg;4qiVKvUeS+iRIAiwGeWBfSYX22U{nozpEQdKuK$;wLYS-X9 z9NV3tTQG#Zu8NaE7ah>jgS8qy{KnYr;&Ahm?8?NkXUq^s0?iRb^H@h-nuYXH(&+n$ zpBT`OE&KEg3Zj*Q30Gcv8l)eBcIkhFJw_P#NM^GRk#XQ8n&50w_p1NAb=lXI!=J;j zv`7Z6#Z`j}3qT)r!zNw_^f2jS$JC!YxB|j*>f$Gsa39wFz4p!Oc8mRfO090KI;K1+ zOvs1dsre8u5j_@JNIggDWKc?4F+b-7`QJ$A)m#J1J_A!Rz)o~NMiY-iwa?jCgxSBy zNf0MB#nUYpzf{N@v&|1#CvZyvXf3&TJF7tA>!%CzF2rkrgeI>Q-_L(A9!z9jq%LJ}9e3Bo0dln;5k5i}S9-o?Kf-i3X- z6I~Si+UY~H1?|CZ7}9m#E(=>9jeTrp@v-G-2iRW@{e_?+p3v$du!U(A2$Bu(K-Y+HDo4{bPR{p>Eso*5ix;#vFa4-1KN@bEaAv|vNe}5=5Yns?n+3hpmhSG`f_l`Y-{Px?5C?2}$RhvLfF3cUVAc>38U4H}eK zdiK@xVf6K$7Melpd5S$YlguWE{yR2RYt1!J?Q`#T@Vs-=wR18~Y?(3g&tiBJp=Es| zQ?1=jhGSpf_@(O=HPyFqkt1*|C~bx4SkAoURgY2zM|e2ik0Th!Qa7T+ZDRFg>Gi#q z-S`#!=)(>P-BJdkLATq&SaXVU3~GHo%X-aW{F^PF1e#DB!C{v_Lp+ZH{AGuq2MA1Q zgqaGxW#I-sU4xa)TtvNpzI76{73nd6ZI7yp6je&rj1vx&U4DBl%RqmsY{gofB#v5o zK~BW$0PYp_Nm_NB8EeamLpTrk&vsa3Rj)1)y0cY#%RKZ=7AJBVf{kgJg#~A0$t2YB zJdO{;XMG7_cq!BX>8b)y21v{D)sjFPNuX`flv~Ta^y-v6V3mi?uYigu*x^iRFHmFX zy>l4QE}g%!OLf#4l|&bKP>fUfCv-DJ@Xglc1k4;E%X2dVt*1<1 z^G~)%FU9W_$S>~p2l<*+a}d!0fHfI0boY`AeGv#_`|j_I8c_L6)cUe=gKiEEr(X{oRH*Z5p z>-CVjKMJ?c!0MmvA7XZOPNrE-iT4cD0-xElsFroI2RvS{VM@H${TNy9!k~59SYh)m z+9e|1@Pka~qP=@Lq;F1@5;TrLVXr&M3_!&p`w0}iZsRkeQXt1&U+$94e=(-2AYLso`C zw3BX2{$fjca6SwdgbU9dpt>2P2@3q{a_K5;<|fb5m(Lpw+M!PU5zzp|C!q5Szet?J zTV@=2y-%D<3ef4?#?1QmS3vr4nCg;{$PlOM^Z?|w#9_1q2U%jn5_{Yld2KikwqoSlU&w@EsbVD;-Za5;W?cG;u_BEkOqrkkGw;=w?`i5Yk zpB?MUfA+vc9BGcW_Qci-{rZ=_^rIShM|SA|Ri*6lw`~BB>XXSdZkcj$VESyYNzS=8 zC8|uh)Y}HWX;M=KvL32L>Bd-6iYxVaR}nnKVa`@t@*iR~Uz|NYtw=dVt>e%xtm+f% z!b@lu5KZjJ0vayTCmW!^;*wrp+)3~vT&8~cR$PnYZ%XA<)mG9C&_ztT^u9Y#Y9j$s z;k@g3WZ!B5N+=*BL}<~9*0a;QH@~ky#+f6K9^U#g+9q63=YVc3WX=wggOL<)jGYC7 zY>naiEVFrgb`ou)D+3R5aAA$ui@%uj7*_@kneZ{Q*K`q;@eW=KIdMg}cVMDz$kM~p zZ(rR`jUiv<2AIS;^?Hbl7%J5zV;Om@_3zm0ezsDen<1Q8M4-hmE6tRh|iaHAzfj{$2$DkfleB}?=+2{_mhU)!FZ2g{;Gz|hnm#u!= z)--c=q+-@T{ds&}-{3r;y%9kCx;=~%J1WOs=Z zcI5W3@&>~%VYZr=T5>-tYJB^|3FncID@bSlC{Wz*IzK*afYZ-8I|}IDBI|Ti0J=@8 zo$?X>Wd_&?N4xG-1~`l)%LS;~%w&QWVog2RTF3xRxDji6ZCL5&<0hB@f;Y+hD{TH? zr^0r5G!|IDx0kdZ?Gw%oj7}M~Lrcz~`&mLkNi!Pj*Gd*Qt19rKF0)2Jn|M*8+r3rW z+=vEht?%z}d#$B8IZcN3?{AM=m1IJ+K^AC5|3J7KK=k))n|7}f&`T!uv$WSaW20y= z%a^c%TiKhxRJ>q^uZmzqw;vk5b;Tc+gK9IA38p{8=U*@`{Y~J)dH4$!?jXLebQ0fb zF*~CdDS7_r-*O_Whke@aIGA!RlQ(?5(dx~HWSL9j^NIe2FW1 zDFeCc9lT|a8I+!xGqW*~jXZttqNPMA9gs}@`}RsGY}SG%V+=VgQDHd^3$#1w-U2>5 zUXDPkx&=x75Tt(ywHpd@>>Do+xHp5wG_BhI`p}*8{Lo(e5b&W~ z5hjG3Bq-swW>CO4ZIz!}tAYTc_LE!meV1nl!0Tx5qVLwJ`b&teb6uId2J@VjM_tZy zw*)*gjgzA3|LEELcp7-jP>5&zee?rR$1gFX-WzR6CV&r{yZ~foGJ$ik2?)PB4bmYr z7)aSD^SkwC0VV`$9M2rSC39|g3;@0O1TaZ85?BOqi2^Rk><5mE`luG)FX_E* zJglk`EaAhQYv)^rbV2pgZnk~@Gtd1@$(T?6W9JIK0#@!Br0fKUZ3WW6GGh%$=c%ru zoQb?PgIQp)sal*F1o~z`$Y7wHtb`XJ(#SPwEr4R<;cdh&^OTrGkQamIAN@Nk#4a!^ zcqEqj-9`OZG#Cri?6%tiOQWg>qkjc#7141V2<`NnM}Z+Icztb!uQpjddSpC+L#9LB z;&SGdHy0C`m%#&rfg)O4?0^e4zVpkQaHGIFwGUyYsZTBZ&+_8M>?GTMw5tPzf2)McxUl8M)}CFl~dNjywycnouD5RRerFVqgfb zKWn!*!38E~xcq$rtGb*2e4eb64i>78JqaJ?}Vfsp$%U0cbNSlMO>9&SOb+omOOh1S?0Rg&+LAu>C6> zf3_I>@=X+>$={t+_xZoubOw^pOZ|R|Q^jgS?WSwxY+F*rkKKI4j!bQ^r~n!ENUnKO z6@$N+e5GB)e>Vz6$TNC^-nyNW32JFxEuYwuI{$9>%(8fD<&})Jn(Y_tUjq_z79Hz_ zaA78$0%5Cb3reOS2aH_*-?K-@5?Y*=-*a3rm8c7}7&3kFKe{yIIt++v&t-GK<<6ag zoI&+`gj8*%tpChT%vmY1s-QUfs)8)?V@C9Lb^p&#K-gp1cbT&1$9%(qye&O+8O7!H z_tAr8-hR#|p|v3gfhf}T*bP*N+yrr(9zhw`xtBnPxIPZ#7OT$uFyKtP`2XsgWFY7M zAG?evD%uFfA=?G{W)bCsi=MxQ-OlC(r@S}ahqeRCLe0mAJMZ}rKo#9r{(d>WA5e0?>}O^}WpK_m5*i33 zMlodla^tW(YjbEz@`fiAP3O8e@B=$d1*C4fB@j3@hSqV5&PxGXnNuS?`dFTZ~GQR_ygv^DS2g}UbEGFnG*m&1bi!*E~gz%cqVL@<+d#r|*8+Q-E$712*XB zEF=b;Zx~wJEee=K1Vl~ho}}Lc>Z0%ou&pk$h+7K+tCrnEX+5R59o;}$EC>Mhp$f{` z=?x3O#t|BvZ#PZt>q>fN_;z_90HC4;1=OkSLd?HVmOTr&$tbZ>2XrJ-m$II$u1E*n zQQ{2{$=SZkzom!L9(PeD1gC2iq;9Jy|0B~YU^eq*K${u_u*kinDdv%_euF<_Y9+3M z0o1uM4&hbP{hoI?w#g&yFY_$gzJzPrZ-TXm^K#bvmdr!su83_DONF_}-f)4e(k2=D z`v8K602iDxkZOlMF=~baol|_NYv?Kcrj1q0tcR9+&BDL5Q)?)iVYR%8`geW(=PBHA z7!X=+VzZegQ~YGPR~krLvm?d*Rpry=GUGTmln1tfc_%T`QN~Vl>#Ch{EA; zT_fOvuWpM}Hq7THQ@|U3X9LqXAzfsS+rQ19jw#XBq7mV1@e>Q?lc+*Trh33H} zaBZ-8YMxf@@2Iy2^X2VVQ1?B)6fGv?=$SdXJ3}WYxg^c0aP{q*fM9TMp?O}+LX!aO zs0c&>uoMx^(#t$niZ}9E%-u~UG6>+&N?Et4O>`FCvFb5?<=Qb#!uAiP94V)TgaV6c z7xG3<9yTA$P&TlLRzKLW^Xaz+CU#&ep93^lU}zkyoZ7BQg^zqQplcQArx!=!fL>q+ zP)BhaV2cr!z-ke+M3$Bx>cw4Q2RDQ_{o$!+383DrGxoeQwq0EPI04K7BIZrUiOZlk zvPJ8lVE7*7aMiIuK`lAX(X;g|!Ff*3ps<{6QiG_Fy(r|{5x{x-$!a&iX z_;Q%M4_p#h7aig#XB*yLd2_cm{#2jS6$3Xb`2kXw8{7314FUc3@)?g{w9Ar`_#qwM zO$``aZg89K0&=i|l)~A;eF)k|3#U*ZHBZf4W096y?AxiG`2`Q^Y{hXbqelZ5m&FO7 zJLk|!UW@)W!L~HU8<^dgqMO<&MoI#*E#krx;jO|7Ip`)-7JZan8VZ?mHK9vzjkr%2 za%&xUU+QIo`H`MSZ{}M@mOOKell2G|r*az#QVJ$G3|4ltS(44Rl0Xk1B$I<1B~^m! zd<%fXtF|cE$T5(U=g)0Fk(Lj7Vs5$JuV|2OEiu43(X`bvldCf)G@y9%OM$Oi_&ulF z#D{FpHCJ&%PNgJyPoOBxwwuPO0AvJiNVH@ds~wSYOkY7r2fTCl--wUbH05FdwSt;p z))@V%BPVU~Cj2ab6mko+{i7o^?adYe_TmFs!4X+YHc_|@T{~qcrx3^?)Agb@H>HRi zG{E(rjiB-4wnVq+Ybl5EgVgK4*aYSl4N9KLZrOt<~Tpqjv}q$ zyBp0E4J|Y?G0$|96UctI4QT#W(h)$H3ygxbT!saP`~bvGu$ zz~HwbRdUABVEj!!mK?dc55Z@Ezs~>n#^Z)Ezhbp6l(#M#4(A>w+g5?gIZ4??@zG|q zx&oY2%u%f33$%yIB55$qcp`1>_A}#?(-U^fW{)F~lBDS6R$gkVmA<-SFBN|0=MfX7f`j219>E?#K#dtYwl4(H2I1_?aQMIcOx>) zHjzodJ3|zR9s|_dH)HuR+ql*UC`shIRYV<7*RttCI4!#hzs@`3a~fo|z%{ydAeRo2 z&E=&jWn`*uq8OK%sR3ZIG1#R+^S@seq+KS1u=HDb{91euer-3aj^jZ12{!w<<_&J` zgWw3D=5JS*;5=_k8)CBu1Egv|^cMn{A_&lrY9I&>f2ib8Yjx>+NTql9MuYvUMPOnV z0Ff*UWJg{dY@KLBD42z+SEug{0bVqGCq49>Ft|`S^e+fkdnxOAe~yQnv4pp2P8s!n z+ZC^dVi9mXJgjhHmCz7DR-BQoJtmXpo`7NvA&@CiU=|4a69!18mjW>+`!?F!NQYt* zv>uonPD@p~vPQK1PtR)Pz#@q~x@03#vcr=_j1(SG3MZ1&{q8uZ?OmfH;F!B&4P1`I zWiZ+hfIVeJyue(LFC+r>_I9EkVEGK%$gCwVd#yhW_XhHzj+(!~54CuL*)F089AUWu z*3hv{suX~ZA{4l*VQ9AwT=@fHiy%m|>r9c+$ur^NDrD=l2bk@PBOB@6iCPoF;Qb@h zlImW1{SyD)tj+|g4LhN9k9L_^I$iPt$P9&*zCYFNDaNz1Q(zJy`v4Q`Yr|L#LUGpw^O6u+;6EVc?0$~w{HHT zugl-zb`Hw=x&qPbhL>>xw5~DuqM^)7SxmxH!YxIw)h6{d@Jy}o z9j@Az9_(Fg6X09LR4{EX$WE-8!Ad^dURJT^WS{#+4fsa&%sr92kE%c}qjcFu`N00u zoT)EW1TJ?OgIF;2;j%o8BM6GyTW%>!E<-24{;vj=EyIk4)a3DkZb#kh05TUF3WCSb zV>r(AIlwwU?><`ZBs;KpHg>~qt8Q+Qsry0gn$O(jnjv-S+oqWX3cwwABk6%7A`cV} z&%V`DNgiA!n<4G zmZA~!l>L^*X4&C$LtPzErXhwazD3&=ZV$i4cAvbOT-es%w+i^FlFAFdxEt4gf!Ri8 zCqNQ-aRLsW@o&Nv`_CJvcV*;@mlkxG9Mc9znF7@6FeC|xpNiw!7dBf-IpgrIN{)1L zw#SWI;Kp&9$9dD|;ixEX)^OJ_yTA9-F6=*|UhD1>bBzkaFBRx*8?MWO>d%OLbI7U4 zpw!uRLWGHK^XBHs`H8XaE4oKkF^7V7IFJd%eW;65+XQOM2eLI|!x4tz;HFzO-jgT= zuGg6&W^5eeTM7}FcrYT zCJUFscKjN6o>{-|NJpMxzJ?bU6N0&HtgT9ayZu!Me9U|xGX;Z~qpl&hW>2Q{9~Bz& zv-}#j>KS8UDE@=e-Ma(xK)$`}t$mbXn<>=0Jr{H}^2u+Qth2c_gFElO3%ba-_5vE! z3E<-LXM?2eD&v1I#@cA6X{Th`0Qc(!bYn$nV;4$;`?}2x|L}k3LENH5`Ix|E0rxcK zS7)IX4=~$9E{}zFC16tPz3acFpKf^2H!E?EIF{r!JYkh(Y5#WHnK1e$@S-zFH^ZR# z3S)QtEV|L5dtnf(c>hHdg_}RM!H-V{+R}aOn$BWAf41!Qbsvl{WY~jj)RdVIJ!9Q<6I>dI5TxBC)H|E0`9K6oE(n-C4=3H? z)-Am#W4Qg$r_og1@5c*Wmd^W3fvXEAz%4ZU=8oEQRyV+k1i6Afkm;VHHZtK4;KqfK zhFF*oZtrOSde`c)a7^34Me%-HrhcM9+Kr5%{7!3lR#wpn ziv*y$ju9Moj%g$yzxK~Q;DV+!=UHeI8}=@letUZb-0MC(-TB6IO~{{QMb{YJH8*i2 z#HfcE&zu?|@Vi&X8rXJD170A)b`OSfK+|ja>LcJRsigC0J=$bDPw?N}2fitSPeOn6 z_uSk7TOWq3mH+eis9X^OC&}L7Hhw>klXE|OGaE^l8|jwf4NuYVyz_7yDzWIJ+4ohl z3$l4x0@$apQ(F36_4%`CKnx8(e&ok_jH!#w^5cUY)!D$qS;Ee>HgX%d!8KaH9vyat zzXt^;Z|yHlF#Ljokcd?jQ4o9b>gslV^oBo_SRQ>7B7Ys9s1Lr#g8U^EL%{0>b1K!y zies>UlDohSY~7QJBV&Va?R%#CE4l~Y0upR;&j=MT)%15r26&A&#!u;BewE5G$zBDF zN1XDiy0B~BtJ|SmknwJH$Is6L#pY$H=S@@t06|Amqai@g91$A_rnskdx;Z8<$X8Oy z3JHK75et)@C0H3h+~_I%RuS}~Yi9Fiq08Phg1_2eAEV(f$p{>Blp z9$W9yb_xB$gm3lBw30p}&xn%5`FE_#J{x!a<}2`L$MfG4IHAwBVL&3^Db|C&EZSyhw~mor}S z;EB?v_)vbkwdX=7tQPwEy@V<*xAOtAG?xWk?!Eb7-8wt@PgKO0=j31Az3hT&nJJ7x zdB6J2+Xqj!dE@iDphvF4(%P~;M`A}ZvRye?YLwP$N%wa%sY&}<4Da>4e*L#EA&0}q zWA@9_g|*7u9>e!$5zPW+yeZKH>^uCIaNn!SaDj%Ztb#sh817e#?YdA<9=WN0Yv1hH zuCu?rOpjn2Vdww_iLc{i>tP){)(wTk0!h|NK3}&fEhn5FM;iT>pssGKrha-+8xH>uM|2d8xVt;pqREjru^-wIy*`T59!=Fx)k+V`v+k9=)DD}!1SBjy1bMnK zN0J9QZtsuc_!TXj@;vvmMWcCil!$q+QP?9_@dCs0>#Jak`kD9h?1cH#T5(8(j(tmn zdbdwb7j)Ne{KSdsxyUFv{`?;7co1^hLq$-z=EM{j9cE^n_G2tshHmpw$CRAY(mZjU zL+|1C&W&J|3RcE{dq(JJhQ>yu#-;}0>M#no9D|77>KBVjiW5-f>Nr3N%yE$9>3>rj zN1Iw(GhX#nJYb$KFtJeLGwXth3a+5UiFDt;#qyi|L3kf*4&kuAHf(GvqWJVf70yYz zTe3&j!*wK7yC?D0i6VLsOJC_qJPjQO3WOj%F}NWRIzc&nK}C=pTXiv11d{nQ)g>w! zH#~1dJ+wDkcBxuKZ2Rwi+fhlyE>Uqo8!S1D1Kx`)R-L&0oaN~S32P1ICy0o?4e_gj zF5|$OUsaf&;M57NuP}IdiY;?1o&NlqzG{V%+;laHrxrV)i7|g0*K_8#YpYp<4Th1k z=jR+v>k!rOb4j%0@FP$U6xRPw2MQXJ*b^45D$d;_bdD>L}U5u4hL!l1Q z?OkqqXly&v{7yt{=kG4%_N8q%v2J9j4v4^Rl!r+D>!*rJSUA6iELzDcH1kA=u&d`# zWDUA8zBtm@UccY%_75PQRkmn-Ign-9UM1a@sn^jPA(>^Zg!#7n9L9~|gl2V5>QLP; zQx@a#RovfKe|O8z zj31qbG)=Jv)?|JteguU4^SeL0^~wh&bC(C09LxkAr<(T!u3u40ORy5qi=%P+{#@YOCQ`X-%7~9FA{L_z5 zQ1L5R6&xGDl!~x<;)7Lims>w%-~8QA|ETHbINkhsOS%1SS7Zu7X!Hp6KRVoLi+)gH1rF+DeQc>4OPb6E9=hwFw-s=4X`rJUF7Jw>DApEu7 zMCCx93O(-lY9cCFNRAotDJY?r_oP7lDP82C(y*R5(

>)nJ53!b?G}mp0Y6IYvlO8&dxb9c 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 diff --git a/img/main_menu(blank).PNG b/img/main_menu(blank).PNG index 04894022f34aa3af8defdd6d197d34884f5f3f21..0bd93f94056d29252a337c930bb5382a1257d392 100644 GIT binary patch literal 21789 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 L!*Z2xJ^ViaU3G3a literal 9273 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 diff --git a/img/main_menu(concurrent).PNG b/img/main_menu(concurrent).PNG index 58cc452730efbcea1b52977a5cc8ef968c3f4c73..4e7dbb31cf2f342730d4b9c4dd016508f37365e1 100644 GIT binary patch literal 21979 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 diff --git a/img/main_menu(loaded).PNG b/img/main_menu(loaded).PNG deleted file mode 100644 index 8204007f934cedf3995ccebecc11ef5bc76022b0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8103 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 Bjhp}g diff --git a/img/main_menu(project).PNG b/img/main_menu(project).PNG index abb0de860e8554ddd87ff823dffe34ca14087281..c81542b4d98f889f7fc019715db755c411f2d7e0 100644 GIT binary patch literal 18610 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

n>F4KX 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 xWscn#g_fCV)4BiM!Um6;wkH^0g zU0$V*G&I+$wc)C!L3V3s-NboBx;ev&be4fu5m>oc`%*(!OW3&s5qIU~y%PYi`-%0(+cx^hi%jxp ztOT`nus}!t1L}Nq-g)N-vJDT${>of1u(wH+XpTtx#Cg#x+$}V8c|G^~!xy%m$y1{o zr>k)Yr%#^ho>1SMzw6xDrJ<(TQko)xb6FjgT?Wmj0sgtRw-b>ouYYYHRG%8b8&NsxSN-b^W;K~aA`9;3P8s2*-__(zo%dd0 zM22Q&2uAVH4=O-R5SLUh6gu%3)2b#x??akSA^GT|>Ut`Kl0X5(iVJf3Is786s+AMD zSAozJP5$$sq3d@vc~1$+LmwrH%pl_HpN5Hxv>j2`x3nyGw(^b?n%}+qdN9k{*1ZU7 z)8n_oz!)JqC#j#MTa6Wdsx1teMx%J?YwY%j1&JwFtg%=uhN9tQTJq)_A2JvFKBs}- zNXSrLe{ZBJqUeZ^z9v`u;`|k;4uxcYL{*+`?d+Y@)FqYwSWxIZJCq>95b{MMhNpX9 zKts~Dw-kq{N&D%aJPK@UWNcs!wVni zUoJhZnzXGS<897cBRe4!8S15aqR;`=qRgNVkCB~&pQ9LWzmwuG1>Darw>U{FtFd;2(4PG44%&nXn>z#LY*gtT2mXLt=|-MWMDPE4=jL{Yfq+SFDLG z3*!;SmZ){*hsf!+=~d+nm$b~25OA)=FqBIzbH)W))e9`g3+RXht&QT*ogW>M1^=fX z1U2d_p|wDP-j_#rqW-mNq)ULt1zOj1Ym0QaA$yo1*pv2J0W{?NpGEH#4S!4f=MMLa z>5mq<;~?k;o*Z}2=>FAz;ve1rwBiB9#)y}Oql=yHLWHcA?h>`&X-u4dDi zsb;9R(2-9ls#uUr7356A)w%}+XTmrIhv;&UxAm1DhG7!Qs;9z(sjEI%|2@mm^-BK{ zE(gnjpu~0WjU9Dn_-7fKe^e~Jv#1TN=vYudey<4;*zuC72GtZiZL4$qJUo8bdcNAr z*27nLg$=`~&W3Bmd%t*BCf1vaz0tUSO!y2Op!#fpz;)b^Zd%o z>2F<1-#R&29Jq9+do(Q_b>l&>yzTpicC2ly+9KV8hNb4nw#&`Dgf>LsC*|K^lM1x|RT7#JT|V5cp(U$7 zPk%n}Twr78BU{|>nmbo_!@K&Kw(F%B>QU16-->@Lj5jtPRNII)jHHKU!Iye2(?zqR zrRf!`Yc}HJc2k+s_pUKNc*Ik7`>h=UhRP-V%nqXalxL26c}ALI>L+Ezs(dA+iC|EW z*D%z$*C9RBIfUm#Hsq0B3aoA2Tr*$Ov!jmQq&Uk-B{#$eo{w>ugVY@zwm#pv<tlZ_`qYgZfR z>(+1dK%2T@AM;Q3Uo{SVTq7OCe|x9U!6~2kq8c5)Tx$U-*$8_Sw9>yKL#J0YJQLlt zB$e;~+hZ%$eA19yDEj=<@{8QWx9_!C2c7~}%zdm0j2d}`0m^GNVTy-xElZON*WBgm z8T}8Iq!`NivD~JkB%4zGa>m_#s>dEKc+o_uO5x1ep_d{oA(bQ4QcsB;g<+13&gM23 zB-e;eTEMqZpR9w&opM^`!hoo&khlH(C0oO_*s|pcYwk-8>e;&#Bk3uDYX9MN=R24W zTiOx19xQf=LGC%!9~L6VCa4cc)G0vg;(F#GuV-`l-+!4g_b2o>LKG4gfRIY}2=DCb z#e0<>zC7X*Xx7Bs%IhC0ZI|7>)Vf9b)C;@MbS82zUp#|#;5y@6PFk|p=`_wRue-Ht zHMb`a+xLe&0PE;F#nFp7{hwB{LPi$n>MfGTl_a$t6+5f%q4UVBRGZyHLYLj*82`X~ zVb|cZdBt7VJP;ct1V~*%6BQxD$`0N6rj~f^unrk2svZ8?V}8wyTwTc>>Yua2DPV<( zWk(JQl?}gaVj1V^5N*5e^|Grs=H=(=?E_u9NjhgPYgn7<==SbrQ?iGmd>;2DohiTU zPounPvJ|!nRxqqf?PO>AE95-crR^{Bwttwq9FY~Y)#+iL7rkdRI&A6I zAlLni5sLr#j0}%vLMy0CkKV{PkXRKGslmA1`#vtEi}?IeLzndZb2n56U9lPP0j^f? ztW1f_n8;-|H!k&M7ZPH<=lcn$NBPgW)!CWYjOzVv^l~33Tw;$?%XTgYY@gzS!MESd z;w0kC(P1D+F&G;wW?0Ms>TIkS_#_FBoQSA|>~|?2UL<{KJKPqdY0sKNTv`%K5~Rs~ zl0j60Oos9p(@32{_`OoW(72zm!jO6J)2EOp-?FJ-PMro$-<5b5PJJx8$jpn+{nNaP zC&S5AoPIpU`+>rTVje=f4OrINA6{yk`+S67--A_;)r9UVX9Q%VxkLzw*n9uPIuRx` zU`LB?5XEx%+PTw^votrr=>7Etl~S5fZ8JtedLPJ0C*W$?$+)@`fuXgXn z&&F7PzwK!ge*FD3bJ$?a0t_4ej9s(qP6S5jA=e@t-Uq z0-OT8*L}58Y+7HKxTnCUnmBBkfzveQFDC|p3Kk=$PYCfsSBi9=$S3;oQEd+7Cb%+> zFW0_@uMuxLzKZeFqpo>GT;o2=S^~rO)?GkcqIrA&R|?!Pqa|nTzA@i+(aQm{@pZ}Gv?%%rQ{a!dN)=SLDMYR=rW|EOyRAt zWqPJR#xysi!42h;U7jY@xrr@E%O6Kf2M} zjkxrmbbY=K*Mt0g?B6*WNaJxLMDl|1yIJfoc0ZCFb6Y+Ic9 zVs$8cxjA9Mt0P)cw+2pGX4V)NTi|QaNFh$}03%UC&&gq4Ld^#x2kP!z397cDWpVkD zsJ@VfkeM^uG&TT57LOGo>KYE)?_(K|JDkRt2D)TH)MNWjJ z;xBD=w0+ZrBsZH8+O10(79-R>5m%wxm)-YZR=Z$M`?6~;v+}`>NYi*zb20ph{Zk`T z=KePUTR9Jkx*10L1qX7_!8iDJ3JSj&;@DHTqC4C;-vA$VMWz0v^EO~_a%T}8$)R0& z%Yw^Qd0nY^(a!!$O)Tp@W00@gv>t5Y+^g?6irKe*Sxn0hHr4bGa)VBWM!s@F)Y%Lo zvW17Z-@y$*)0Nu_eE$#`3g2y6*X0n;LMGdvvM@&KEs0_6WJ3xt zvW3_k{F}}dSo*|(zL5q&?u>I2pAWx3O@jg(Bvo7?@ zwUFVKmfq-A$!wi8Ise{n_0_5JIaXM7R>=(AZB&@)ceJFZ8Y%&i`kMt7@WBW)(mB!_ zdm0&#E=7H!OPb%gKLUbuA9SQjdIRURzG+qUvF+00eI}8sb})v~U%Fd{i_PK8aa?V$ z$Q{E)*6RZbDx|;G1?<(tUUQ^0Zvnp|o^wz?>Nh7}ao-2u! zXofxCbuGUgZ2&nM8h@Xod3^^?T9hxy`N|Mm$AA{DW_e*EhUcO@Wr>3~8>Ak7N0>%- zF8x%3uy-iC`uS-&^PgNw>3=il7Zl<1jB7<(Y5%bb#UKXmAF|Bs#DnMM(@oRFB)MGL zC{z_lc0}@}(nBVJ|D=}U6ZlGpw!{?3(wYBv`^XGSxVJA8QnUTPf4a%su>HzS6TMG^ zDm|Rpqkxb>1DVtDQw0eTg&sq)p-$| zEd2HMLQoUZ1kES@M67CxoI8vQcSJo~bb*i>e@-W{e@^}}zugRwStcA}{$R$Q=C2>QpO1oS$fcomLW zc=DR#Ms@A((9Wj?U}0;`uhqs0Et|jB&QqV5i)!%w;AMIl92D==GS|N%htpUtTkNne zT$}qI>*Ia3n$?GCCHq+eQ>+9K@2$+j1=V$Go zd8$u&Au9}-<&PIENs1Qx`D|`p*lXo14!Io@QMhlk5mu_a(e#Xm-}!2?lV5Ox#7Wa# zN|{#1v`>czdyMh@qBt~vgymhuh1O=Lq|$qgP&cOXFoq9MD~rVVSF2LXJ5I~PSmsF8 zxu7-uKfZ~7cfD&glH*MYkLv1LH7>+f`E4Ij>LkwFCG#cx^1ey#K&)qULxa8M&^4ea zV@E_BuStsz@8{rjA3^ggCd%-t%LtiKd~N5e_}wY~KTfwcx@kqk3JLDK3o zHaiSj4UJd)`bzzPMF<`Nup(v=wy0-z85J`r=RW_fkk_SLwu5CWFTrVB9UeZnOPh$T z+LFP>#qt*#5#|T1&sCdrd0OD-hxWSt-iIq}7+w*@v!oC=vUKWXzRwCN(lGMo7e*jP zPuI2FT5k4hy<9**&gqa`!l7qXEMBtCA6B`HYU(`{I57JN4SBB%w4DBv!!{T#2%i5q zfm40}wqFee)G;(PNlYjrZ}{7~cmA};LVPwDI#LG!@;#a*Q%J}$$5IhLMg`#$$8EYs z$;3Z5Ju*PiIwPj|L_C zP|HtUA4lLhZ7{y<$bK)*25w}`>_hFTrlxPBXEp~S4<#r+m?K|KwM8mZ`n)1ise1#F zqgzX2`a@yYk3cqTZ26bFD3Nt)4&mqJ1TcQnc;WqP*8#`3E%E_-J@5m*;I?l=W=SWf z>%kGy;QhzGtc|DK2RxLG#K;`#qViEs;n#z_gLzm}Zryv$h9@4`DU;2Nx1uUVy%#H5 z@3h)WzGv54<9`^|`Uv@JozvNxf_o2RccG6^RuA`$p8X~BYppJ)rb#osH$7=DoTYEh zCS`}_p(_gaoa_;PTv@8w+S)p(`#XBsINVk7{?(P>M$&SEF_7e9ze}ABY@^spW%rR5 z6sSe6*)DBw{d^95n`|rwKo3{>B2zx?YJcclLfu?uzg1UZlkv-I?l*;JGgS)fwSsG2 zZ_TEHPV6^~4l3npVHK#*@^l!Dh-l0e)e<-~dvfq5{4Wvw0RNS;yX*nHCukF6#w(aJ8@f(}Lt{W#z%r`;Cgcx|!x0 zJz|uHJ0ALbKc(9;!w>fs_Z>@d&UxV(;VafNz!*RFW?!wxN?PdJ7aVD>eQG2VzGrrV z$TcnIGJj1w?dNR_^#icXy?h7fQ@+AK-8A|v2EP8QQVoNQ|7cj*q{)~z1P$J9c=Ybd zUUx}HYsE!B@xm9&Lkl@=O!1x$M;e;ResOmQ``bCLz6zpmlF!jLSp~Jda_C=h&ELE9 zntPS?ucckB#CpTR#PW+an5_}S@YQ?X#&Z0CS5u|2`a*~jN`O<)f0jU&Ywv> z=<7_r^_(1QQDuh&sB2!4Z*T?F4}9B+#A?s4TBG!$##C-yU2VVfUpu^&?nwjwosAP; zS&Nq_5nztAZ8Kavn|{0HJ8OAoLf@oB06=ujw=43zW=y_GWN))iy0QMPF*SVcq5V)~ zg~~(9^G@Z;K?Nm|jMMww>>be8>#(MNV8q_|HN9^L0F59Oa4)S>M#8z1mbGyY{kpIP zBw$bd6yY#f@o*(+w#T7F@loYQPaTurpqTJrbqv?anQ+H=Z-`DHHg7GroQ$~SLR2=` zjV%5eY9pC<2&QdUWU(GIDfZVREzGhH7ceDEOo^K&V3-+*L+1OsL-i=Py9KIkM5aHq zfCk)#Jcb?zN05+383I`CJ;o9cgpT1v^3)$$Z-|^cNLPZ^TgYHR zUftk3p!fp5lK(>e0__vH^+~WmsFn-~5&sy90rxNTQAQAC?zC+HL`V7-HqZqh=z33j zVr@=T1n0?rFZj4Nsj~>&YIx^!y3p%gPDsiAO&U-`%F6^Ody_M4N@%df{ySH5YWO4j z-us*&+zPkN%n$jby;+aw*KFyW`YHs>uaMR)T^H$oC9++3&5s{roshClbfV1q+wl&> zW?DN__#|%GIkOVnTkmuY#Jc}g&LV^>AO{z_frVo|(75^xmtHTHh1s*z+>7y69+_Mt ze+8CE^94jEPUmHO>Y_Cm>fBNIL5D(>=?t?1NO$>KuJG{cio&0x!8chTD2O!wl%<_^ z6~O?G+q@(m4Zr9(fdCZkbuj&gKJ>d$KE>2zVSQh=P4UPq5rWa*XRs9pkY1;sXY1pH z6wnal;Qk#>=gd2nqMnl|)@uZ?*e*JylQy3!2{?g$wH^IZe9Hw!Y9|YhJR0qSa0<<7Pk<%k`XzIWjbndUfVQm=KIwfE7n1#+bTD_??Q}s@ zb^eumDR8RpvXRNWd9pZ*Q+XU6ppj%SSPi!hlUvu{BEzT1y4E^r=W>cYGT(6zZi?6Q z8*^Q^zYlQ}?eIl>PT#%<7I)Q)e35R52zU6;uyIIl>^WTAg}$1buEjdT8D?Xdq}Zn? z@}*K-5RN+j;cYN0+T#&G;(O?@v;zQWnm&k*S<{iC=m4bKp??N`SbbATZs4tn*LJvJI%@F%Kx@EQN-3PEOF zO;W)aA0Jc*V&Lnln0lj+CunF?4gQI38)U8;`0D&htA(K?xrhuVanan+?`E*UzL8+zs223b1{qBH$X59x{Bc8W&YYRb-c#3mPdEk4nbUbsHcZ1*{bzJn*Ur`@+oxt zHP_>%;k=@=_4+GSBR2HOveQmYNvFu_f%T8)y_0o+GU#1{{Pks6dj!XXwE>^G3FK39o zZ29x6c>jL9pl;*FRf(?NgR3%>p~J&r`#}o8ctm3}tDkc5A6vqAGSxeM&$b!pBE|7b z$+<#Yv4^rr_vd(#n{vsZQ?&>Ryn@-mO|1qc<4i4>gx*~Md2vera@sG(Kx(JMO8E{M7q@eq2 zXl#faD%Ili3`<3Vj7Y1yYnI1=rKtI+4W?q6rRXlWIH_ROg0ckEZ?!NZNehipN4wdV zJ*yLJLf9?ta#%dj3p-U z(56LgKS9L(ewS2S0*lZ?&8Z@VWYB2t1oaDrXL5GwZ zWJ(n^J{zyOWh~tai{U9irW5Ut_ATgqZ>`LZA+<>aI?}^D{uJUB2AxZ)N%hF|Qj(&P zsL}CaG=QZ)D=>J$$U*MUoTyJyDcXaZVq*Cbbs$iyjw+jDj*petSyGAC(GcUKq2TWz zPC}nvB3rdlTcP0Y@*l|%9FI2XCP)wiDOZ5_V>|^BdEbQzIjY7;2OgNpb|e#( zwEb9*;oQ^)$>n6-5V(;GB7C6<8*=(Hovv{quOiLetR$AtLdM}UCOhjE2lnFX(i&w? z#m0O%hrPk0Bn*5JHkahG9Z~eN(%?CQpp$ldGFStLAe9R3DI9i~{Hrl9t(TVwb9*O; zhZagl8fE<=td&F-?#oWTsHp1Q^B|MNbw&@ReEyZO%mbP{`0w#qYzf~LmNvzy<1B@@ zGnCB<^U^U1Q%ua@!PGN8ZBoaNJp{yU-Guu?aw)U6*@YtIwX`xPpGV3wu`}lX7CID! z==eO81a1+@dK1tP3FM^wG)i0qyTGecx7#z%5wULc4bP=2C9aBOI%?aRUI`t;zjhZLL#mfx#ZLpRk<^*&YzIPFBOv*Cbzr>L@eU1myL{V!l9yGoZrGiwy4o zM(S?Pp6dnbL!QgXn&v5r&xpT=W$Sx%n(yOkW6h#O}SrQuZ>vG-3`!F5~Eq z+iyiqJ$za465d-Vr>WF z*1~^H;80>)vQ92s>BgK~mY97Z0~YE`aLR)zI;)r%_{x``h+*!=K|dGAo?eBE{Pct2 zkmMJ%I^f#3hfPF;0Jr!j_H~0lBn$Rb;1Ox|z1H6vPLU{O8f;+RgujTD6flYr#6Lyq z)G;K$>n~-$hgnH)lBVgv170xVlRy8_^xL}^wS{~KK-+Fn!1@mC^$ZhzcuZA^uc|tR ze()h8_vRCmPsxJXm!zE%=5$RA*1OIwk|40sQ-kP=w@J&?frRfCt0>zuw}Yp}8sJR97Frtnbja2`z+#BlTt zK(j-=C-=&>d~&!yIXIoTXaQONrd!ves5~*XZgF9fn^x7F)e^(3C1AY$GUB@Bf&Sk6 z@?CULNU8gzs%aiC03~i^Sh@xDX;< zNoC~Rq)mViXk(;V3sQ-&ys(pU9|BbBJjky+C>_|^`8eM1K)AQTI&xa(8C?2G4)M(? z4+1k54Rt<$0wQ---1_rrntBE7j9BR7}UP-DA z;Dv_5k;(%qmQM23+l$Y3^vU;>?q`fbzYML^ZoT*Y0G9Li4h@Wz>+=41P*npH=rt>3 z@Ztav_G?_=2wPSucd(GteszjbWf0lwlU26{o1n;BPZYtb^w`jjoj!ZWhb7w#GMiXN zj-%hcmwNBmpUcpgG)mkLoR-FUM}-fq&2;$7;>OS{9~@fr8mV9Z1Nw7M7DODq-R2i>1z8WyD5Ii!S9qV zHNQ41hGJRGK9oAO60@_W(n5^r{m+^=E4Ch?QS3e!NGDKoWl!w#b*~}NaJx#w6ax}3 zeZ5Cf%Y9g#>>2I#nTp4S*z|hTNdXG$$Fr7+}ImLyu^;4{e-czx*tL6 zD}oMIa{0x!?BOS!@KWb5ENA{OSzyIEDWAv&5~>u$mjY(&hpY3G_@k!2<;op%3AqxE#9=a6had3FYE0) zr^h-`BqVtwbYtRA<}V& zfyaA%|1|YcJ9i<+Y&!}$OQtJhdMQwlPHa(6AqKV1(jpD6wkae0+a=km;LJS6H@FTC zc}7kQIhF(AI2#Zt?{e%?RbQVf?s0+2l`v=QfFex)71{bOLzs+)cOZUKo|@9>rQ&r+ z7l70JAJ&;oIB)2GzK-vTh9wDJVUCDQT7K**?t`2S{jbJ5pi6+$4*wPwGN$GVLe74E zGQ(XccQ-4}46}a*0(L(4ug>Pk)?iv>)WskF%)ud@Xbr*3Le6SN{ilN$_d_I@-v6-~ z906V6fVdP#_pdp=fwwiwM8P|P{$+2q4k?nEHOTP45*6@8y8mUd9kRc+;v?Z4$S!WU zf%6IcNtqbMcmLbrAn|B%UkM6SI3^v{AEEn-Da4*%`MJkmybZ$y!4DQ`T<;)TecyUP z+?>GK5m4d#<8jDy(b+>l*!7qK9|7%IEGK^3H%pemy1++-tjpZBJ`V_E#+-to#6 zrNu-i8$ID*Hlva#`}%;1zv}33Y*8^IX}ohaF~oCOY|+M}V7}WMB_rB6mP0vF*aUQn zQvle+(;Uhcd|aG)ShA}k^mVYex4AfMIILcHjgaYEKp6thl!=6#fbzShtBvx+|bQ9?f(`PO2FL;us&IGJm*%8b4le^=s0E!!Jx z!ME$qtM9_0r8{{+g}Keoe_c4*Rk^9$--_P)c~JT|!fdNzPwfc)=!tSmMghYFO0+2$m4RUwjDcZC$lg8Ro8IYce(>K8udC`R40)}*)8LmsoG$5HVqhqUWLdwy z8~o06^NN`l1H*yOw0}G5Ju>YX7!30?pqC8qSQAnZ#W6-z^%{^as7BL4&Z`;{9%fA+ z4jghO4ZM?d>2=NFvOXjCvQQ9naDZhZ_G!$(^nC}j@Z9DuVf1~wh6-ChDM7JBmah;6 z!IK{=zCTgX&!W_0C(%x+auh$uHfbrRO~=%PwZI2A}GMT)eo&17Hy?pUzm#f!Ce>u4*>~vS!?WQ? z+NZ@;ct_T-faYT{Z^aPoq(IL>l+eYh1;m25rO&R|04s7C4#HM&E3b0K&}$~^smZ%# zVvceDLa6f8lDn1*|AWaOI3-m16aq^O+#DROZp_tQ&gvQ@Ecwspd~3|vLslcZjL*iA zvSTB#NTiONv1&|wO|R1_Fc+pvnuhkpJ2^u$rYhVlO5Mz0!BA)}8Zp7hMbw+;xO8ot z_3C{nr{?*rv(WMT>Sy+j^W(F)68V=o(cycA5Xxm=nRGY&560A+x>(4Kmpbp*&sMWDGNLhu|0~$s5>|QFC zcYJ4o{X~>s7v?%UxBrtxm^eTL4FtZcB) zeNM6Jc(@B}=T%{{`|J$M@sh(Z=YV%$#?MbdIISX$UuweDp*4GEbH!>!DY5SiIhR9H z?ok`CQQ-~3JGD+4mQ$nSMkBNAuV~1fZA)ZFHHN1Sj*9WTsm!rLBB#Iu&3Z`+HJ3s& z7lKF_qK)s$@d{q}($FC`HlED=r*%&uLl0N0LrVkok&j;x~Dv!cjakPhMGtC+-1m^&+j(`_iS7NOeWhP)-R!LDz6>D8tzv4*`t z-#)+IBt2>*{f159RWBpk60re2*xttcK)+A0^&n4b)TNvl;%Uw!Uf&@f`nF(hX-wQ2HG&{N#wRTz7!%xx+NfJ>-_g@82n|3+)S8Y zTjw5W^tWxwRZ%%8Nc-4wvZEkdW+dksBy{U4)6fvHEdy3_LI^6bb(OKeU$&-zxhX7D zCrS3#*QF`gKq2FUDC?kcqhEKFKL1~O$m}6hkY58tdMwIe7`{tVI&NRWwY*?dBfLrS zv})M$0^UDj&A~Ik;CP#~zoc!Jf6UJap5_p951!^RFK$~&vx!-g)?$*{e%WH#<1!63 zu7|kGCS)vTZW%7F&X9Q5(>%|PBa zAGQ58-HJH$M9;6-RAXl%{9lOd`B`81lg#A!C4{HB4i%IbmaT3~j2`MeM`FBta?fUN zS8ms6=Gf2fX}Os7z|OKweTw$HdLHHewT=tj2ie$8kM;;dH6YKbJuh#P$z$3)FSXpF zHKtywUIveDsP915ADU4rDonfF^**MdKI(1#L}F~c(XUP~@oENRod<(65&p#J^#?J% zVWnM z77m7l2x79sp3M#1m^1idQ|c)XllRIr{X7 z>-*l`ERDG;)Ah1SQc9-HPq-K2p-r!g_MB8ts&)Pi3-V^aW=rqKoPcWVBq^WO(ic}<0f9mTF*Vl2p=>EiS0X*<2>NyNt(8986^3DRSNU$b%hZ}hSBoQ zB*oU%H$g|AokwUX^0yJZ!tNZg&7xkxOqQ_0O_4{OZd>>&Hy}jv;;Ine{ol%H54TR+ zq&SlZ^UCAcrF^G3Mavl($vKRP@MEawLyI zGrs0;7TjSBZu>*jY4LOn#`=*nMQrnY6-VOLwv6wSW;t(3qX@WdWIZ(pi%A<((o$@@ ziq^yEv^I(ql^n*auUAdGiwENiNH-gL3yuHkVpymc-pkh`X)qEqb2X>1Ev5}!OvznC z#0?tfq*BWwvrvf--T4fTpc{Wp?o61N87FdovaRP=zW6U=BBAXc5=&cd_3ziUaK{&D z_%)jCU=(?e{a79Kd9cwczqS~$I`8rF^3RA!+_~QT)it3Rqf>;_hIWlX%=u5GVP1GC zVtHAla>X1rco7{@YIK`2=3Q>7u^-XvS}L zJAfA!Fdx*3ua8hFsJD8}8H8P1kVd!I)W?wS%*pMq8w+BO6kY4HLyNB@H!9BGgI^#1 zwf$wF4K~J)7)RC_4)ZPAG7b#d+Cw$ z1E?SvX3)g*`*WYFL!HEt{zC!D$0KVgfvBKegm$5f%p)?~c=Y)nL0@#rzEj%J8hF!H z9R)6uOy!$uo9h-?IMFmcb9ge*z8W=KVp_>c+5D(mHv~UUvih!QNtVe|D_3^1q1K7E z!U9l7IH_sx>c0E?;i}q(2+^2i`Quu7Z5OX)wk)Q3ILsG)x=@E7HQzj^D5IO@+%Orl zSoXR4*PfZzG~5%zRDVrWt0ha@Cz(mswyGrhM{XsfS9Gulf%~+(oumET8^_-_kh)|k~oY)eXba|*J zX74Zm`b1`zz>KxD>lcW$&_wP=&)q@!re@}f&xfZh>_zt6vHts(fj&q#zq`;`;a#m& zMM76}&ORb9?tdW2)p3{yg?jmyk9PCnIgD~MT})3>D!O5t1@G?o28m^T_{zBN@77B0 z31lz69AmLSJQ6uj+^;&0dKuN}u34&Q*%h2GH%CZ;a|}7;uM-@Hc$B-o5;f+eS4Jjx z#Z;wKY5z4?AD#+!Bh$CXx!ye7c}4PW`K9BfkJf{{;I-nga)xnU$#}fFz!lE>9T(H? zeUUrq{xwympZ4TIW98&+U{haK($2Rl#^fJ#zo{9LiJCp~AD4t>2D!bT zV!hg+ALF5yx-M`*)jD^GsFQoLcEIyhLzPZ@{~9^so%Me-yC3$#@=F9ZI;N;*#7DOH zPN~}U+!}({jx2l(KH}kS-oIk37pHa!WHSpD$AD%_1b&57vOVd{utNVBg_``Kqkn%j zu@|=Xd7?2*AGPuymzhWG{Am(@fyzfgu(zav%zkj@)_r2lIp+!LS1g1!gBGePhJ24jT41kEQ^Eu#plkD7Mb2Vd z)ACM0Ap5`>XLlvlOGuaf;0MTT-S#uVyq{@!Epaoe7`pz+OzWJjYdjTFl7+jykJ~m( zXl`z0`JExAO}BFvmwq&QVnX_0NM_HegDcFi>a`Y+=5Xcp>t4;L>-*Z8-fG49Q_Ax! z%R4GZ`4&!n4re_lnsPhJtk6CJ%@0CPSskS2o>c2jxxS-D>Q&JbC5n!%$#T1VB+en` zi9wVXzP04#T<=nn#5t>u;UZa|q!PsrbbaoV0b9~&v0e77%=6IhS*rf~wv!8*bGx(- zKlBgH93Hk_s+9_AA4HIbI77w!EW>#{k!ypKN=~wFeb)|2nA?XP;ByID$+7XH91|2Q z#poPHEfV;K91UJcaOHZ68?9}qA!HNgpC0HON*c@vz-iB?iz0>6^+vX%QN-~hjP-*EcGL+O+Fa!=avMoP5gSWs=R6Rz7Ituc>Xdp5qtcIiraWM3CftT04!c?} zew!xn+FqzQ*WZ*>A>aJ0b$Fv`?d!o%l$c>H@i-#cNPy~~pAe3)dXdn-tb5C9y=*1p#wZI0JR}oSb18fP; z14#2SG$6*}W^bOANDV_@9CH%dWXU_Nd*3 zNN3Y|_FBVpu|i8tAp7Y8BU4AYDgzU&JO1+Gik`Kv^IM2a@W%T3B-pyPOD8Tho@7lR>az-r70X&u%*+fv1C8D1{Faz-ifH=qiB6)!Rf~qk2fiJ1+JP+5qZ_gm#rTqR zb@%A_v-J|*Q-6v`gv-MuWv(A;49oP#^4pTsklA&f`zEL_-|d4O4qp;BL8T&yW&t)9 zXm^=la%bA9XN8}9@d#3XFalQ~o0apl%Se4D`}Jd!Gwux!V?2_@ouYw}KJh%jM~Oty zYS6c;8n;3>Y(oj)=(mZeRW_dR=~ zPJCz~ZS3Jw_Usxgq8T5ej8)z(Fv7)|V4k)Sox1W8J;Z`YL~b^R)|F>bl&E`5p9Dr{ z2TEKC)sO=-*}J^E?^Vnk9gTf#aVBW3uEbMT19`&QUtFoGJYd!8`;QBl$gx*<+)s&n z<2ooYcR3Xgl@u-2lD|MN_{o;8``%b85G0BU(iz#L*(pQ3Z|D-~qV>4ZH zW6XzH56=%*;c_j|ttZl8=Z5dRu3B%FZtEj}Z6R%tu6=wtu3k9fe44y(UVquG*X7gc zN!d!Zasrx++%^dcq2U!X7p=Ts=3OWbLb~jTj&{sRnYtjp6RTRmjZ5S#F;_$AMa{Z!;p-dRcT0k@CyzjS2g7~ILsInZUq2&O|In`v1$@pNYx>N_MfL6&yzpFD%SeegJ6JN1+5 zR#)^*`1Znd{*xUI5z9RE@4bbf5-*Chv^W8){q8#Wp)YX;d&ZBMb!)Q0fQ~|C?rA3` zwdq4p=AXfjm*)0?4vPnm{JkC*kuE!5Ye*OKI}0!mu&Nw1e6+3om)gni5#!Xmc?~4h zoGZwVOLZn9dLys$E`aWV(a=I0x3Um1zJESg1zHoP^?&l^1Rj*Echb`hQAxewlGg#z z)xp_G+P>=H)5Bvp<9oz{+lkhAS-1UKmG}z*iU>BGzDmr2e_y>DOOV)ol2z$mUygp# zf5(n8$44!kyMGWxXB9F{F}Y&nq9uh@uRCuOZTPQF#U(9ke%v#F0#Id+VfC17xeiL| zUbc+HO2q)l4Bc;dLwxDT?0mCFQ+^^5q>)3cwz9&|>7%3K(HTE}EiKrnDuyr%rG5QP zKaK7W>pG!MME8CgJ9h*%81$FR8H5i$o0xyy^#_kdeN=uyNJ=AKK6G}CCN8JitNP+50VdsZ? zs;oSD==IGQ6^N@DBce9ug=FQ}bi<0)W&6WD)LstSeVjm#xhC&Z79N%dCasHfg5K>a zF&;^##a1F&4YOD#_rf&0EXqle>H9T_mBaN1O0@hI3^`C&@j1x;7&@mC3r9HxNZ=z* z%umYMKZA6W{cY@+&Xph*Bo(1QQ^eeqm|z#atTv7vSNL_>!Fhy?<3!T6{oh`TO-pHm zOO;4)<(-$eVR5zTo$}`Yx?f*TtI9W<1@@MHlLP0VyJh6=sX=R8gto6z*U^%US1+oG zUS$UbZCmIV8$cNFNN!sRS`=6?^8dffs{mor_n1$<=kcGXKRxDG9#&ZXsyZC>9KSws zt|bY-?&$UP5@W*XQ~B0FVo{omRMm16+T-fB<3|h6ZJ(p@K^ShOz_RJ->C>~l`RAk^ zn@_#I{N#+h@BA`xvq3M&_VbH%%5tY(lFcT$cOu^I>1h+oTcPOS-#xH!@%Gwy99P}~ zz8e4dh~mI)Z|_>NnkL?>RZg^rSl)(c)lNQty2n_PeutV76k@_F)$l|RLe~vs=%%=c z=%w$9p;rrA0ojIo~%~x8z^2AwIue|d}*u|>wbsvvx z^Kej4uGw(V_w}_Uc-kA%kZ&6G(eW^th%RZ+E0Wra=5vj%n7Lx*{U+~1;~nAALjugw zBm5gb7?;MP^%7-Vd*W%UK(r~BBw#m8ZTt`epUqXe8e_#_&qVcHE12%jFGjMi`Tn1}g zpDz9FZxdJLVKgKC^VQLwr|v}!0ZGh*UGNKGw-bj=F&&8OmlE!J^u7O>x~63;6Qzl9 ziwnZyEPV4$u}n6f_F^9FaiOz0>0H}}c<<|oisk4gEcK3PSE&TQ|6Z6( zLLfrZjJOi9k2`QL%%$;o>v+t#){Bz`JIafSI#a&~)<5O*p~M_FnA>d6tPZSq9{ciq zB14&))nDlFsGKw$(4rxlVlP=Sg;Gg7yUn=0s#P`2pN{DkbhQoeaKa7^z35Qx@Up2& zQPp?3g$}>===cJ95&$zJG-X)q;liT+KUfD!7VQgj;S}Uh+WZ8 zo?gI32CEQ4QpbD(5ZBa%N^MJShML{Vak4lG9XBxv6hAKUhC+^!URjA)uF@@?Kqp!d zT#PnLU959Zy}YXBH`)ck5&e@A04qFG;=eNUsZ=9cB*D2eoind`u~B?tCMUo2qehUM z36|Bn-yyEwuI{yx^EMl(sueEAbdGN*X>MGH-SI-e63%mn=nOy3Sh8ldl*Cv#6|_fk zQuXTXvb6p1U1)J?-+-Q++`T~bWs#A>#j!n^SDfPr9eQL6(ZJs`l~t~Q5Se80nlpP$ zHo)(xS(&YqJ8l$x;ga)FsNyhxAv40%ib&QjG4kJjcX$if{1)omzL=CO5?ub?KAQy zJag)1`4!Q!K3t4TzARL2hx1WwPDWwU`|2~1?^+D4A{zNUolMRv;2dO}kKT-pkFU?0 zB%*KDGJCl`;;-W}tB%dQD${1x?i#8izRedE-$7>I^j1Gm^5~PEj=%}uE2)7$D{h+F zz>u1GP@~npfr~>6&~Xubt$?Pnv?j08i$T3P@`sT?d6ScFkGeA}5Te=kEjf4Iy?5Rg z#e^R^0QwzOjF8aG~>!4wm4p zSB=8%5;zf5?yZAWddu6@zt-V0KV=zm^OiSFwCFfW${c#lBIgUQZaQ45!xSiQYFz(*FLSQt3H1@oi7# z!(Qej<84O&#uG^FZsGNs*7tFMg#H%tb`b56mU6dAGpv$p3?t(iwR6oRPwST{dYxCP zlqs=oZXu|hH}qihR?dP@qnuoqFTO5gV}G1E9Y?d56tW#+eC3kFwljT?hK25r+csPp zKGb*ffB*8>TWGib`$*HTB)N9xZMzN3{%GSHCWebgp_fYj8A-+7dJW;^yb{avV0zJ5!UY^4p2gZxKh`jIe{2z&$Cd~toAARkgbzL}H1`OHO); zS;QJdMu5q4D~YXv0Vm7+R4n=-<6*z->07Z<+irfJ7g|#q z?vFyn%I8=vyXxp29JD2rZ|tI|)aWzQOERINmCHUCM#K)e2(2zNzSY6P*-_+Ou!A2^ z?>R9_kf&oxDE*Qw%&{Yb?8e3HoT15mMCjLB=>8@z?cPP~psd`SVTU#~+18G`#O>hs$!7h{o!Vhlz<^*SuD{CTzL-*9@u>I1E zyf^Q1%Xz->TL{3r=2a2Rix3o_1wx?^ozqeSHI3>Pz+4?;f)zd#+TCR4iu5E2R3JuQ zSVefUzFKla;|)&yk*BE|^GD`*dPbK^^sByL`kk?i^Dh*f${;Zvt zNb-=aUaggCe#j}_+!jQXUII96e{C$>)AHt&A>ULSrnbKW(zZI41YOX z!)z4%YCW86#Dn_&@np-QzzMFHhpI8T3c^@Mqg%*EzE)Au$RGKaLGDrGFD|6omEe@> z{6fNl27vLq>Q%sTvP+>-fgcC&j6NAAn9&0oPz&!BaTB?+!j!&Q*r{w0Xl*rzgPsx_ zpVCx8AkuTEPXEiXX_GGBqtIHZ`ti9yVs8Nph}fFGO~vgj8iM2-&_gE^Gpu`}a**MoIg%U~yv9`o zf{_5fwp2wT>Gc5ss!~QVzC+$7u+3bJZLU2e^Rale2(;!3elldW?i{vVfjsq^xTqJj z$iaged$0y*t5P}Ya_1wmlMbCD<1qMExtfu!;N^msio(>(2hBb2EC}Pr4buDgdt;m0 z6qX+Q5nOF}W|P$Dj?bav4~C;}LdDGd$o*|Wj(UC@VdpA8u1^MUWWeLPvyMe76lMm~ zAFwcV$3>_)vH~D#bELG(PYyc_ZxGQ*R0;`44BjLKqci06h;50#nREu^6&4t3UlhPD z(fw5czOUdQ;w0|sBgE0~C=-kU0+WQQZLH)6myk;xjOfjUC0GM!3=0guxj|~Nz>|%o z16Ob6SYpRtSX3g!Z~gpU>p0g};8=rXb6j8jIq@p|$RhF4V2Nv7`CMUBbGte}e8C2r zQ4+Z3>4ASkr{fQ|Tmnn`UoIi3O_O5e3`17u40Ti{Z&%ObreiCfZ+VoE!h-~8!-GCJxEi6uxT$~5etdedNlMBxj>G6b($JtudHiY7)H^6YkfT=Nn&|D*FJgbJ z_#_IXRMiqfu~q4vVG`$V8V$TKng5J#;i!3NjItcm7mgEcJyS$}(D8w}yUNk{w(=TW zP2hwd2C6TPjZ|}N&s|rd2*>Q`iY$hBua1N#(N;>0pr^FJjf#08X?55& z?Sgl9f`XrCuEgR4{`Hq@HKIz_O>&=Jm=OyQ8@a!mr8y@bJ3~mLhtfRPy*o{~ePCAW z51#&>_NwD(olpAe0QySDr_Ub{jn^nUJjI1Kb~8slds<4a6Fo}4^OhKu z@lv(az{Lh4#(>Kvu)q%tjxcgpo%dEqIe8|hib|Rva%D{Y0>_p1Q(_j3*6KhuZgweV zp|D|FIv(>U%O&cidY-4>cZm5`eZK9nxYC5?8j>uRu<}4Qh9?N9iX@7)9Slv|&?>U+ z)#7c9JCyhb$-f-S@KUoJ;@x*Wyr1)>9v}yL06O1Ns)QqM~<+p6|z|`b0Vd2Ki+S>s8J_c+C<7p*zk>IRk)PAI;vTL~&anSh>n2F95}9 zzTYMVc&1qbp(M?#P#JAyFPyEk$aRD7Z|35dIPCe`omV~1@RkEE>n#d0PSk2w6#8oC zkMz6`gT1^i%>1$&n2uMmoT6qQALccE3z#dYf1yxN6Ki7atQRgxMU~FayVK~#8glH_ zJp$a1S9J|d?E_=0;~W1IBk{o(CHP3iuuCLIZ5N>=Q1nEP6nA1a6HE<%Nz*K1CS!2{ zT8{Q2_D52OIR%)Ha&$>U8+;ST${T@E2@u9H< zL#3=JgpFw$N_IifIuzZQXBLP;L}zPjTv$-}lN@t)jt#7df^ZrVOE!H-1JErHz7an&|Ek5Dk2G zbE(fRverHAckYt`0%WZyUTe|t=7xmT1LwcpjbEhbZ?3m(y0taNR9gMsw}ePR%dtN` zu}lEFEW@g_`jV+qZWz$riphJ=tOkoxWq)4fu0O4YIjQYOP5>x za;&96G|kOtesH=<=LMap3!vVQ#4^rr{)`NEn`ljOm8@F6x&PLbn;YZx=vB}@>;2r z)o<>fObf=+%z+#LhnveMQ(Yy?zTJ<<;_NkkbA#vMrLzchLlm72{NHBP+;-rX)6XSv z=$?P|=D%oiXlUqk_0K0B@sYvnqZ7zuPfuA{Sq=ZTi|-$CNlnzS!yo^XE(E)H4&cv( ztTqIWZf*s3QeP+wS7L!(y+7LFcQZf}hx^sb55x5H_z}zPj#FP>ORJfi36^u<~GH&m`KHVwmnyE-5nX(?(`vMAg5s><45w1^;K@K08nvgvMdV9+kio}?he z%GU?I+WJ3}hXEsy)LXr=GLd6Lb%AYaT2|gEpen^COCmu0E*rl_7N$B z_euNBQ`JFkvSskrKu+*tdTQ!@TrhAYN#Jk@^q|>2s;NG-M4_{@)2=7q#>25W{`!L5 zWNXUs#`7)v2is7DrviY=$4g?97>#oO^8yUf5QHll3v%JS3xv8;pJkG|t z3>&-|B<;U^qqDo4b9NGhg82hKh}4B(ympc-K=^iSr_bGX2rmbYu-KfcrV>2hH~g2| z)!~!}tV%ab!lJ01G!&5g!tG46irdb)R=Tg0{1(SdlV(k@i=18?Ym>O9ah0y&ssO?V zj&hLv%l}CjC~1j`BVQ2b9s6OOTRE4x$_~cgjQ%o^e?9gU;+d9H5M=nN)#@G zeXq5X=gTLLQ1U(GdqlQ48^8(0{M4+I05p?rw0L-D74Yc=vT`-~gQWNdRBEoKUn&Dd!2OU#t(XZ5eV=5R_M3n zh}%eL3#jd(Yhz%Gd8+%wdLzdT~WeX&A{?i@cz@}uCgM7UiNWCf%zlj)<2IFjk`jhW`k074(#}ZnOZhNdffC}VQp*6M${+jPo z?XQ_&`XT@Bvj6})#?p>_BI|G0L}5S8(nx71T{oy$WaTJ&~N`!NCLB1e#j&e8p= z9jF0{d?6^?aaF)&bAvijF`FmN$HzDESi1F;&FUwv#EIgGyluU|h*Vj=g8YbU z)gm(i4*Yxkg9|i&cZreDUTGyuk8`DI`>1L6#qWbH^6xHq{(>0>Hna3Oe4y6|nTBls z82LSpMZtLiS$0i|}xj6LT?Un>P|DM%>fab&K{SUUD^hfltLpmw=&KJfjt~UX6wr zwOZay4C=jztH}H5_+?eHNH>oIb>HFywBc1C4Q0(G0QXa}a2+i%+vp_(4c(UYIG$qG za`kUcPL-j78?M@s`z_qR%}6(4l8sIVBuN%kN(EaH`vI`eNxUa0ko|c0nb1d<%4ze- zJbg7pARI=y-6_1I**zMkj{90-i1cr9wYHt5tKxf?OwN@(PkU^56I2 zURcXZju$=4Lc7{8BBS6|O_aHD?6_6v2jMt7LhGx0^Rm3BxKY-DKh++uA*T=H`TgsB z6VwB7{0-r`>noUa*3+YyBMoqjYlRmkunZ)`vXegNuVq2TDLP_l4LApSL9on&g7?LB z%TRIk1|Q$h2J1GD=%6)R?)3CRk%D=9l+g*2?4JqyU>F~QN}8|cRD^N-umhHO1xtON zpWwR^{z@KREl+SUnD@M`I3pex9zX4S8F%EgO+Z``|4Gvm(2|FA_MXcg4sqSk?$;<# zX4lBYz59)r{`jWEU@MdPpJ4qS$ethH^^6B)dr$NBgD9R%&tbWt{YHOkAn%+1)8%G! zYlmchV(v(^N1otA&RxITM4@2B^+P$2Z^8%q?UDW?pQ zE`DZDPEU}BQ&JqwM?7y=DW6wNt~{xf%wifk` zgLOKf3V1DT^JBd4a(LRthui9f1S`jnpQ&;D*4I?K_}1Xy`y5}1JLh{Bo^CJ@^L9^+ ze1eB9GZxA#;J(H}c9(3^T1ii_z^D`VpW)_@bi}RZMGu8{I;t8?$L9FNL5w2S7z|JG zznm)#Z@KTQVR7)yAa>Wt31;ig^s1&J*4x6+OA_0HxvA$6BdL^b@dcm9LUjLrnj5vT zTCa?Hs4Q(jQSB;m4W!?-gQ$9YB>6M{#@#-BU_x28=$wn-%&6SQRl#{V51 z-*5)H)~XF6-g$O9!bp%;vxA}jLs0qUHu5lbt_9gQRw2N^C@R35t@aP#L=0-!y?@8~ z^V{zQDI+S|eeZ{lU^j1L8JXFPyj2+v=+fgRk(t>%J8#~k-%Fz*$lV7;!vZdJQEiTD zQq@92QR{`K$A@Ja$s43<hriX3vB%@FoL|EQLsrRt-NOqZKFlq(m>}LbrCR zf*jCFaa$g^Fm-eZpzIwO#qF9ypmc*7o%`e4w_FqCz;=a`rr-42xI_*;h4#wj4y|DV zKxs;L>Am44ijvA*dnFhCyjOBu9Jx4jcI_YfCN)qPA+xmrfX3uqBvtfQwJLx#VVFSE zM-dQ0RSQ_koQ?MB$7gO;Or{3R{S)NnMgu-Q6GdCz9&zaYiJMrDz)>Dz2>Y22tEK9N z>_+@+EXBXH+%)AAD0cd5Iyq5Lvs;OZ0X`!Y`>^##0^DXIgbztt-Z&sXcWUX|16F@@ z#_u=E*Zy&6lyk0=)myA};*B6Txx=QFI(W+#D2M^uNFUV0vz@IO9CNz%G19 z$)WL}w8wCz$|fyCO=YKoEcXmRg)KkQf^PB~I@G|wddH2bQG0YK;j~>{-%TNLgB+85 zNw=i4&QS@naPyIhb+PppRjP)H+jc{^b~sQR^FE_P{L}IZ zP|@{|qxCex1NMjElmmnj5Q{PAJeu#XA-jskr1 z?gf2GsK(ug%kao$x|Z=GlGZP0fyVv%!#&O`Y*aNu6q)eg=PJ2OplmmVDP>RiKVArWnh?L`2T)IjzZtk z)479XxQNuGr=R)10|Ipk|8Jg|8+DM9J3nouedbK1ZEN;Q2d&>(0(pQRMQ!+2J>LWn zrj}CmN*tDQOG!6&$*=?A5*Ku%o5D2{JpQ^-e^QO7dv;_ zQpf9kXtHPA2Cbmam0V!8XYKvJu&tTw(QC7szs9l;wql4qCY)ok_;0`&82f=zkjXRL zW!@?pwH2bdiJe=unjmWd)4;G*D|(q7?1d9|Y0^F9^S>^qww(M+4xf6#s+CCkweuVM zH*}TO>~Wg_=*^jN3ntjhcvFW-Sf^G1y#`eH{{(I^xWD(6)jJ~f@LFfuMptUVY~@nx zlDmg*&byrTXVVUGR*p}V$TtEe+JNBcr`flHfr`e+*G>I~tRNKg8oX7X0OU?Z6K~_J z7m0B7;H4ydA6`iTkRWrLlVg!>6ia|N(m?*}_zYU@Cp7|MQ+;J?e-sBGqwpzF_ugh? z)aTk%4>!l3{;d1mmllFbRm;oVp3+|e@{gen-EP25zReEu&9=%d&Ewx(t#8peH)KzP z0)yLJ|1CE4Fv)r6ItHq;;K4Cu#RWfBfkwL$?;(Jq%8fpjZVp#k7Lb1)LmmeD3L4S4 zLZsa=yQRJ<9ka=r&JU)#@*K)5`_AmK=~IkG_XhraWOEj%R(#b2T$)};OgvLPa5{}L zI5`(Dtjs28*Owo(BJL6V90DBu4#E-CnAGdvo$yWOsZyQzQy&55DBaUDZ*0-~CLc@A zrcp*~BdozwL&JGJS52}U)YZdN>c4novY{TnE6v-YIY4>Y4eOvahXtQzGq}~;1-Z#& z4%g_0&6)@fJ9vCi6l4Wxq=q+?P&i_HWKdV&w_W_mlJc!h@-js=W?jZ?LokjvB>i7i z1TBNZk9uRA7*CpPeI?!?nQAlJ{^&(?iTW()o5$~>9<5F zY9N>G2lE;7hh40FpI65=%PmE>I`w)13d~OJ#7H=QY4|(7TQzJn^*52|q3%lMUF|_* z9h9BI_wzV;E4n5Va>p?ErW3pDF_Yqc`u-OV8w4} z*4pVvz})EtqE89f@-D+k!^o^w}4xX3-O(SMd6^>37PzY#_^0)|LiGQ!VQUqUJ{m6McfzVo)lt~g`R7JdziRp zNnJGHb;s2!5l`!$+`(|lbU+bIGWQr}|00Il zliW))yv)Pf3~y5bxz{GRN|BQU(kK9z*SpO64|(_Y8wlXNd#xq_i7H+Ok~9_mTx_0( zTymQSILbhDp&0Pyj}Xzcz>bp^rOih=faaq25c%4QcWj$MR?d2QLUJNyIAEcq+EpmH z@*x?lQG!@bbGzo!lXJs2q?DJMrF}461~Z3OB7tuZ4?N@8Ffv)F^5uBz4u(A%m~b&z z&^bF`ihS6mg5|9KHt=&bodhZ6QgP)y$EoAFmg^tV@h&qbg1h6GE?rG9R70F{Dp@cw zQ89D|QybW;9yEMW<$m%Xp+!q;@6z+~D;FOs%7ys| zavEhrlah;Ql16yH=|3&r&@QDtXPZ}e@*IEyBX3Fi+g^R+mxOm2@% zoXxKu#_TWP13Ezl!;9q*&d~I+l{Z*cTWE8bYS=yv?~U~}TH*Ft05dKQTjSLE5na6C ztU7`SW0(+I;na4d$ZJw}q4cT}FisQR`2i`24J-jk0H_i0fgM+hV=$PO>*0KdTzTB?aOb9NEN8SdCrDS}W=3doxhsi(mvynrE@2*6WP*Ot;Y-O}~a+z%)&>w9%*h$ajV z5BCMxJ52I#{^W0QwV{{?@W^TFDLD-)<EWS3h!k^9SqZwN@p{+^3*uO@6sIH$gQ}fe(|SClcRs z4sTf|#dL}N>wMYhfvqAgn%PhGpUIj4J~5~2i4;_$kMc!b$@cS*M=-_03-FXlIe8V? zU)3uC;z1(@S^yDg2Nq0hYr#%1!D6Q~QL`7m$!!%hLyhrUijuF0$LTrOH}}Jc+R;Uh_bGEH6G81r^S0} zqEBy9iRfy0tHM&VsYR7amzGqOy$44M@2-L&P?EHUy!ZwiP?&5!MlO}%U9L?>#cWxDgc{9 z`xKw~NJEOj$2N!OB%lO;y{XfUk&*A$yJXmKIyOq|O<})YCPc%R{#V|K_pgRbw<2*6 fbel$OI&AQxL(R$4#TT#9n$}R$h32VTyZ=7`YK@+{ literal 8626 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%0AuISTj30iu2Br;L-Cc>-CcuphiC$qD*wT% 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 Y@OwnH&kVQ5p*Dc(eNCleMYF*F2dq`+kN^Mx diff --git a/img/main_menu.PNG b/img/main_menu.PNG index 0bce40cf3cb011330490a514c7c8e26bb89ba184..bb639835eb44b0fd09a56cb239ce6cb305620f79 100644 GIT binary patch literal 21263 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 literal 6908 zcma)BcUV)~vJV!DGz}g>ke-0j1*CU^geFp?H>H<=bm?7;w9uslG^l_yX+cVe91CcW z-Xq{S7+NSw36KzY!E^5Y?z`W2?|Xk_WzXI-vu4k4X04gE;kR$;v!3HV2LJ$A4WJNn z0D#VncD>C=Pa8GI!5p+dx?ppC9YFoSl@%Is##7rw8vtm`Vm`ckmPRw(huQ`M0BrA0 zessM7Rc-(PceVjU+wy_aI`T^m;eAKy-uIx*V?p^s3_F9_&xQN+i7k;+_XiTz!@jFe zkvVsu-d>TaW9rFVlgNe`E zNtJtsuX6G&UzIu9`tI53XQ`8HJoa(eX3?XAo21Fg)n3y>s&eJp3a67qfut#>SiO#@ zw|cb6e6Xc?oQphsAr+3M158yUe)mL55OUmOW0pC8eL=AGBMc?J&D>3bw# zro(vo0?3e2P&g67B9wnhv|U}6)#-~R98oC-wUYY!`gOfvcXQ8IEXDAdsmoRQm5v3`eJEF`?}nje<_h+R5|`LJ zVWl9S1olbs_AE_Qn-E%0&&~&Wc1K_DL(Kx~&Um|)QLd_*@u+tx1r{((2bn|UUZlS? zh88>vwQze4p%zuXmol<7hxmzdPCtNwp_b39%Z!pE%J_)};EB}f4@}LbI)3JEa;=D$ z=>;!i)k5hF#s<{tps-pk$=M4B?ae(ZLN}sHoaZ0oU=g7dXQrWCFJ)Yav(QjZOP0H; z{f4I0>SMo}rQot$#JgLM?}rJ#xaLY|7f&#}X)yM2Knpek7OQr*rBvHY#`@NMxEv*s zwlp5QI;pp6#{XOg~WSGbGO&j)W zdM5Cz)Z*#-Jx;pCFRwlZb5erm`hZ(7rfD6B{Iyz1*<1KbqtDL=EH8u1iril!gzR+~ zriXJf%$1c(Pc>fEaeqwQTsQ;(u1vxxznL08S8wwcRan*!#`P8XrN7vOhqHMLlM;alBA`Q>m=X8?h%o-dd9DH^S z%CsnNZY%$G00rV*ZyyF{w>a`da7Au-@Fe3!TVj@@yJA9G|HvQ{P%8}$hC51IRZaUg z%N(0ZrJo*2`TCy5ZcdFib}N7yNm~;P<#PA=eAY6t1q(S+X3FiWe?lvW^w}IWJXl1w z#)?f*Ir*Lyr@2bXYKrWeepFlcqj2p*LUakLC(ZShO39j$GfT4?tn&KOdWe6_&oshn zh_U|2I%lLr8Y%j5m~WU!!_l{kz1AUq&-{2cxh*F@8m#S8UDJ4dR~5G2)Uj?api@-e zbcc3iy(?(VN*T*r@(7fQ*ZA1vRK?mww1EVOr=c5IGr~*R$XvREmzCI63rb0jBAp|=(e{`^65oCuV9GF=_V;)v4~q6`hzF~F9U zX0;ue4aPH&rY;mAi_%mOn%|cHjRH^(x*j+K+l@VpiNqoCMq@!fJjMGsUEs7nJ`id< z-#Ao+Rv4n}mE-0Ic#H@gWbW@5Gn8c-gJFF7+sgO>VP}p*g1R#hEXJ^bEPdAH0z+2R z)q?FttiE;kfnPesO6c7Qm|sM_;q7?z%bpQ**2Y4*k^Fs)PfN=3KdEOLGJ@5}^?IAV z^PX#Ju6Uz+_>0q}knK0Fkivt(@o0ZfiL(Q)4)>N`P3V_5ud6-oeEp}2bgn%7bM4#q zePGOwpnJRUi=4XUA+^Q?QpU}9N=Z$GhEuDV3ySxDehsZ`VjB0g=gUFIB$t_no~e{u z>CnS09z0mT^J1Ng{M=o0ywwcpyvG?VMc!X`ZP+d;^0X~z!9b*O3wa88Cyv1DPX>b0 zQ=Gh2+ooLMScmt#M$a^E$p5kA%jasXNm^A}?=;Qu`@{b2nhr`|}b8VE*V%v0{24wC5E&W2(lF^;odS=xbn zl4I0oh-aP0LCCSt*n@~ARKp(;P7Pvdu+4H)dwkVyEau}DqNT-v7TKVT6bf{An__IMqGV+Hy*-y4tQ3H1+lE7jRK_hBm?u0GhZAV;n|Mt@$1_J z&E)TY=!MDlL-VRca@X$V952~HBW>X`&KIE<0p~=rtFqmP{Mnn+ za&2tqB_xKYJf+xJPLzuvygIJxHpM&$G;fe=`)p--_GO=QT##8z^pk0)eB`osKs)mI zOX17Rw!w*a(r10pa#`6|0T+#BIqE#T9fe?wRp(z{deNK~g#FO5XxwtXDzg3QSAFn^ zVO}WGu46awA`$rE5rj_bdIkhM*Doc(ZJO*+VtyYPv<0@4P?a-<&t*y9F~F(le`^#l z1Kc(iH4`OB#!87CXMkgj0zBf!>)J7dOs3DdRoS-ROvEMMBd%CXexSKYt5Fy}ac%CI z^@tfTM>brfnqYrBO%i0*R{wEK7X^6j`q8MxNGs!VMc?g$^}(P}fs}X{@_kVi8}MvA zuUwCD%fjrz!;r^hQBks$#wE#Hrl<^p7UC*k?Fkd8xc3p5vs(H?5axI>D<<`;ZNO@z zoRO!apb5r=CZ;y9_8a`v+svYyD3O%!YrZ*Wv+*$DI<2DoKsSmGLRm6Sbd{0q7sqXs zy3ojKSH9-j3D7PV;Gfrqu^;B`PVkj!fZ*_p8;W^sn$U~iWNh3-3~9HY{Tu9bK*y^i zbAZ=awaA^mZ%}zRTL9p`HcGucY@OSWi+tMaIlI7c8T4H?>?0dD`E<6xaDt2%ng?VU zf>_q#lEopCCt{lV ze#-te;e@c6@Ef}aZ3CH23qTKI=%9)MSD^Z*Lx#UxGX@yWn^q6rK3!E?PAt^Qg z3xv>!X+`I`H(`ow#a76`Q2zr-NlFVoWkTO&=*rDlTA91nI_ZSl_}Xq!UoA|0ZoIlI zF#PVzlcU#Nka1X>s?m;FL(&Yn8&Z*zxjy<%xj=@c_?>~*xA*kvXa1Tb1#H(3-0j>Z z?;1J#EZ)F%#opOi8;vrt%-7pnG2(#xOGSQbVdY{1svokfu*(@)&YN?}A9oqd$40eH zdhj3By}1ByG5?K36~y%DCp{3BpxrzxV20wzU*TA4EU5I0Qbsi+G^l(=06TNBl)ch5#vj7B%QeN zK7bH^90TCID;T)@0bG8ptn*Npwh#^F<{MhZ7-I7H%iTQ>f~e0vk#!4p(MQJeR)f;H zu&xfq-vB_HO30TE^M{LptJaU+05jt5H0|9H3gdso0f=k@!e=hKm@IeZJchY_T$Z*E z^ntJLhP8i{5-|du1E||B8CxzCDz5qll7YJfhk{QUI?m8k2;Earis-PD+;Cs4>mdAu ztcR528Q^SDCM~woKXT}3d0nOiq~ft8UDQThhg2|lbz+q0>Ym~d5zYrN4+N3_JU9hJ zUX1Z&2fU8r2FCcCf_;vX4LtInDW(GeVadY7y;7d~*7Xk43q@A}Z@bNbnzIk3rDHVd1QMXGB2F# znI_+|wXS&hIarX$epiaGFsFrc3feg*73)XAQ7L=PigOPtu3WMu5el6>mX?+l(I&JP z%NWZgKck+(Pt}UQm<-Lx(Lx3Pv6qH!Au;1XlUTRUF<<&wfWN_4v{4^c3a@o=-yk`> zkO)9dQ!fGQJWL0k!c@^z!g|~MfCgnvPL6A1wzE*3*1@vYLSK}1UAi=5`X=KJ<$&PP zURJapes*%(*(O7FDKf9QbSRzvW(7sh+K!R}9@Fe^wQ>%etIU!o&&0~)LWnoTZ~qy@ zJ?=a|Qq>crD&SkBpjRD0QO$&zwnrZXkw#55i+Tj)1`!Z<%y(Mn-0u{*^5R=;B20ql zhgTsusS;(k-%!LIOB;d{J`Gb*;&s&Dfkgg;RyJH?P1Xa-42MUK*S&@1p7HUj?4WkA zrm98TE-QSs$TZ6pFAT|12x8u3wnXd#tEIE3FBes&fSA?V9vjBHJxa{%<(%;)}_>9@K+cvzo#wo_XPinojsa(;E z?p+HJv6~AjDZ2U8VW#<#zwP2=a6uZC*BM*C!RNx+ugH<%NKAaL(WKumG;HWbFZl+V z#QnI3P;UV#H$smR3GMD-#akO)V)%tLu?F`~>M&~=Xdu)^Yv!p5BVXsZ%wQu#r3&`LSA~QN#A+xjMVVl?B!C4HW8ZrhaZoETm-5Ay&TW;6#vgxeD;CBo-k^=Rv2pRm!EBAHYFd6>!dovfz=aJ^@Owup}0 zPd=XrVK_O30fJP{)9SzMT5Hr|6608^gA%duIR4bHTTZBq!3%n*WW;ao1mG9WUSND;ynnEhUE_3UO)vLv3HY9t9|A_%P7zC}>8wrlhbev3hA--xb5Z)q6Kamvt)FMDcgf3~PP=(k$Z-XKf^hFIp(HS_~ z$w?J$ixZ%A^FrFF(BiFbozUx5Wt#U(&IN1Wbwic?LAY;jN^@_-|S#B!{=Qbs6GdK|UC| z6C*%k!*{w=oee{NOo|Ab>GWlCl*1Q~YjsiWXF9tTrVq5-J2gTb;bbxQN(rHRCpfL8 zLbiK z1XB#3AiAM#8u+)0t1Jc!MQE-8c^rd^7vf+|Q8o3Gxc)m0qq|@2!!G#6lr)eoDAWIn zC3}?kuWA3QZ~rYOX{90b9UXv?=DU9ccj6tRF+MIpylLNMqt}t}x%OZNk&jM4ZeLDM zLL(Wx7Z7p4}2LwGO=zz0aEIYH> z_<>r}?dX%qXfJ)bZG=}Os0vFs5`4#@qj>fXYxaEgU^S6d>DJ$tWSFxA%_eL(VJySRf=ElQj#sRn-f|ftJq*n7w{o{C(BEJCKqG&a&V&oCB!rFtJqcI4zU5J<=IRjM{MpJjwm0KF# zsx)hM4)Ac?;@vG(G_4^wF0}mpvR4U`Hfv$H_Wh9r%U=JG94Lcb{PTqFU~Vm>((9>8l5<07=WMS0(2`I!Y&LP+lIFX2-OW)}fe*1s2!8A&ZgWR2ws3{;n7z znQ~i{DL_%w;B*YrJs94r0Zr%Vko0_VGfc}I|FAv|uWVQ(=elWTwQezEuxHW(Q`B9a zV(CEIlYX1(4mRG$KXDJhqHEv(ij5(}a=y4VqDr9o`P(|k4Jb0rIi&XaWb`4S$bfsf z^JO@Gm9)js!sli8di$T|_8)_R*L-2`7#q8i?KzPNQ1tWU_R$Lw)dlyIJL~PmQU6 zJ}LmWJrjE1_r>?O*}}QG`9j+bF4dzzz0A?S?Y-KzT^xk48bh}2*GUr}v$;5(Y1{oA zzEINqYB6kMd%f&=f(G*nj5smAE<%)cudL*W3_sd=RSZYx-6+%F)Ev3x!DC-_(AWr#Co)Mi*CZN+<7W!=D);VLW>8*J{Kpe(qh{g1+hOen@<1!Yx=jwA+G(h zL!Pe8z}99}BdJf}M*sX}6$hx85jd+58^<`F zK-)+;XzkJYQ*aR>*0X@~LMI+;rFm?E)`Zc%;dF51Uj+c34UKgOW;{Ryr5bE bRg>fTh{l_*HXF1rRsaLNTabDkm&E@8H`r3a diff --git a/img/project_editor(experiment).PNG b/img/project_editor(experiment).PNG index 25379c43f52f8a631f5bc63c1048021cc43cc0d1..6a27a273d6965a6d667cd7699d72c18384346f4e 100644 GIT binary patch literal 14798 zcmdtJXH-+|7A_ikuSPloDiRxAs(^q56agbDAYFP1p-8V%R3HItfKny&F1lGzA@Qy`Is~zx%$mNl$5-7i$YZ*8owNlhj1cAOr(j1$T0moF1YI-gp5dF8a z7f~A`(;NhnHoJFM@v(;yHbJgnLd`87r^8h_-5e^@d+(SPN$(*3?mYyxv^)O!o7)apQB7N1iWL z$8xob)be9md?gN-%IgVmU>{LU>@7T}2dW^D?wcvXym2DekQ*f%8K{9;_oc^%E{4wp z3<6y@A)Bc+gTD@dfk3$d=ilr{lyVb8i9m(KPkAS0|H}no#wXh`D!Q(>{`owhNtBEx zC%=`t*=6gRMpAewcauH(VBi0Ae|@LG50|roINd{VpB_Y>vV9-{N%7x*@M{Fh=aCj2 z(=bMF8V!4M@3_VISY=-??v+urs0@RNpt1kSJ=}MJu%3E}js$w193h3HRQ$2sb%rwY z0w`Z|(GRXb;3jMAA#FXEYZMPGz8?+y6NVK%18l^JZb`fzJ3mW?HC2~(B2fIP?WbT21&h%G=7@oyRC^QS#OMYSjD+ zblx{{;ApL*AdX#AzcnvLOf2cjmEPfdL9hpDQ$i9?6QtJ$AvKRW)J@>8g)xw0+NQV= zt^F(0KV3l!zk>FyjQzM47%a(GCjD=79^%z;o>FK^+SVA@%X*R6y4vo?RWf^UQBLIZ zYh&OWxiT({=OB&QIbVsl3~KK8FP=SCA?(0H;l0{C30H8HEd!l0alR;tPCnf_-lekv zlH$fS&D#BKG@NOTj-;p&(#?IHaf91VHaG}5!*eptGk)sDGOciQJ4dj=e*B3aTVb5O z63Vk8=UA|ywyB)hnXA{gntTjWbY&jX2uTLa?cE00uMBp4m~`riTN^FD@u)SM#*r~d z|GvD`Yq5aB8dBd|)xxNYrv$*Ivr=Je;G2=VX&Ymy%s8SW~0M<&<%6Q0yNF4VVcQEM zvax@E;)h?(2q;KY(W&}uWlZ-&Yavc3TWhr}(brZPKe-fN(PxNxr=OUI&jdzZl_4G4 zIC%1e>3x=vN<5fZPyNE(N(9rs6H>lI`ZMf!^BV&Zq8W0bK3D$kas-1=PMfG2!u(J} z&LlD?Mkp=^sT!I11mRODDW5mJ)xi)d!69Gc6EBzq*;yKm%g+<+GC=5QO(YpRob0KC98J*4by_y2kWw6)`~P=&Sh#a3skH!@8+_{3!$cQy`9I z&j#$EibvT{TolBtuX+5cA}QboB~7iRPJ@<%%pa!1i?7bQBYVO_cQ7~@HU!IwMZvW@ zGGTXh3Zz|IQ!Xv=j+8n#exY;37e_NyG`XdS=M;P`QNfpOf6R4dOvAHO)>z&?^14KL zlk3&S5ku1E9H#eyvqc!Od;El`K-Qmp@%=}ZBr~itBLeC2Ohajap)05U$c!B z!91j5t)FgEE2kHYm0g}#wRQO|fPhJJmE+<@!e=&AY$0r|3>eQTKLZI;daFYVQa=_gD z`2wpup)4Zbz2_%#rh21}#sm9wWMM0OKYMM}GQcPYzhNvMl-xh_f^g{S!dv4}@MGb} zswj7>m-$ZKw#>~RLZj&taA~x)=I&w-zi#GrWmcd2$R^0Sc4}eR*jj*x^;Fic;cRo- zu%U^ij8r2Sru4;FywfMSXyseQ{V()Qxjc+(8PS=TQp)ZiBu-zVG78v3f37U0c3#G7OHI*$Q9r{UG1W7BxUWY~ji-DMe6Z>yMFiOCNND z)jcuFP&wqs45Z(*022s%o8fRCpP8c}Qw)FOw`=Fpx~SfS@5=Q-zq3{Fa!zhuzKKcqCO>wbbtI-eodUMbgV zy57E--ABy@8_Nx-xI5Lg*cek`#BV07+mo(wwZGghSM^?Ppon8w-~Bcf#D4DA7iLUD zBK;qrC?)G4TSuKQ2geCEU`70hr#{rBEfrgC4%qR5VML!n5Ym@ahA=qe}C70ozyMwQol6R3|CxglnZ`9T~QgQ&b)&k&_fU(3Dmx`bekvD`>A-sdo_+HtUwJiTL44 zVI=-=2-HF`SBR94joa%$H9tcnSk6Vlwbi>JEN{<832Go4XRh4}Td&aBGTjAyct5%G zmrqgOj7z7K5T<0Vl7|hIAaTKeMt*JHqc#t>Yol>w`}C)S_pnu?m$&9HB-r5_XCSwY zlx=Y_etT%Z{vnjRy{ln3c2&+*^-9_Ga*dm8Elka9H}aj)OFhcI$5uVY{&n@6uYL+k z)d#OhTPDREy3}8>?WZm?+Y(s6WRS>V%uyx5T_+ueNwq3N*PyHrC#;VGx#3_nK7XYxhO#(O;#ka z#O&$u3JXXR1pKQ^-@wmrJjoaAb@qgB(3ibc?;7%hj=?^1zzJl))(j5=P63#q zMUD@6UKHl1LTaAiNgr|$fI*3^> zip{-erS%fmbL%&;UGG=EYn*b%SR9m9d2XjxdB*8kjV2064wgSPQnvc8CHFjLL>JA#CqJGdpGTdRRjrdNehR%l%%CZJ z$_`z1m(*kPNf3`$f!cTQYgKFv&a zo^WQ)$wWn_$OoBW1WF|Gt;CoQ+TH}rycvYicuv`RL@zpTZpfrmTLaULTxOf~L8j3C zSPzqKk4er9OeK}j(InQf=a7jCQ8XlmYYSb&EV#ZbwVR4jpF`~-%7t_m1cj?_pZB5g z{9G8%^FH6cI&s?B@@T-zog6&)b?FC(=Sx_-y3>8+MQwWY9mogCu*&#MScy&r$Mq;y z(!y8A=Mh|U_<1SH>m+Y5-}BY8{RfQ<9HL_GjIGk%e^@N!qF0`C6yA~DGYYqBpYVGQ z82iK(FBZ0U&C}6Umhv4R^QLz(%r#|i%4qdYHuCvHiF3ER+}iLlck((YZ4C|dE*YNd zG!GtOo3XF}_xWY#cZ-$XOdo+5G%U(e2ScU; zCqJ-pY~d!;OJKbbv&U1OWNJcrvv1M>&9+*Y|L>A5-t_3};j9A*-~bdS}Y{ZSP5KMS`{TsK1?qul^pMrpU;U^QVkm6R%ts zB=DpFF;)aiXqI2DH;9oK!-=)JTZ3QU!$T=?+fY-2rczSz(-RvGvInYx#nPDAH%xQt z?GtXyfMX7QlngtNWWFG6mHUDSUBTNLSc*!T@YsETjv{{F1YvxpGYQEpA3?o@H3;gC zK^V`r-ARTH=xRLsOum8#AWCt8Fy0b>Q*hx`XAp72Nh1UV;-&e2hLlChcPP!r}%=LDZ;w@;QkbFE}C?4Ja+=y?%z*dpOJ7X&>@2w#brFP~tiPV(?) z>+HE_f4Tho%6jjH=le@I^8L&M2wg_$|Y#_7}>peiA_cPp&-1qUyVlE1}!D4 ziGj?O1sTB71K-5VsaMXy!wzB>D@Mt-P6%BHKW)OGG2sc$%74#l-CvL8Bi6W*vg|oG zl87#|`XbllwGRmJ_Ym+Znlgz?F&`#Z6)bqi&us6K3tpP{VeAj)7~57&8;SH_H` zS-mEY=2{+ltFFU3u& zm-6Fj9cFAhed$T!W%;`H(C14274{pG=2o@K@Td&7l7wd&&AO$oGh1o}+Rv+@Ly{Be zBCS91@yy#H>Ek(qZ$G@>ekx0c^{CHltHeaoQSe9#)71J~kblu=y0o0KcJgE~Vy$D{ zgQ5HC?D(6AAUPLO^d^1wCP^xVD93>K%ACjwsq8gfdabJUW}ME9j8L^;*2xoZr%FX5 z;t3;%gh#8AYCU<{50>!@V@QkwR$ps-SZKS&GBh>*L;2WN#T{dw(`gSR%Bu0tH{c+t z2jx4vFI{)FSCZZq+|eO^s7~$#sYJn$Ae1CVf1RjalJkjx?J3q(>L39|`@_=VmyF1o==>adX9PjO__k|YSP2U0JPX&|dwse&WO0V)^?;9 zEfV%>F4UB436gnGQ$bfAPq}$(oqBYt4YkeRQ=@9V_fs&=)9v*8-t4*Cu1%49t1&cf z*_LAx<(OMVb>AiOZo{DhtD%+tt$0$3&SPloL#pvK>+*0PvPvH&gn4)3hDBQ}}o1seOec2*SOXDEX zP-nQ2S9L4k9F_`KZV8;|Xf<8*x7xTD(fG2BuG6w|3P!uH;o`UaqBcp(&N$x=3=*KP zAD2Un)LQ^@1X(+rqEhvyvGw+ixU);Rlwxio zz}vakXry1C|zC+GGDVzK+inboP)5XKBBEx*#^M=5lDx;$z{ik5ti^ zPj@7%Lt`#|+g9UzIjUjPCd!_aA)isE@wS@Kj3oX-ph2sNP!`#Y@3Wk+pw1s?!&@v~ zG|)w7kb1P3JQe?v&?uw2@M~uVCjpV5X5BoDApXM7|I%<~+aOgQ`t~xDk72`Z`J+^1 zRbEPv53kVfnNDdF*IYTb)-_+4FyIbubf@29V*~rEPG2q# zadS&Y2+>*6g*sDyxibm5aNzVR*pVy&p(oBHHJ#Gr zM50Bj@&qI$ueJBw3X@XPf~)53;576lvS-U05iu!8eyU^E<==!qeu41aY9O|dM${x0zO~Y$Qdnb|V)Hd|%J`DNCDv9(tW=S6-VFTikIQk%iJ>PSmE1_>K z$o>Y<$ZsVXZP9g=`9N&8bE+Jh>%y56D`=r_eO?Dq55XC0Saz%_ifCqnKv|O$$^C|R z;FH?ZeJ;4&qZ`g}>c$6L9Ng2iuy|h#HsY&5+OkLdWWpA%;Ns z4Y0RNOiY*yQ&YV#(-{*iR6%&EcS>C5&FDy@vwW!aanRFY&#ac#Ix2&z!}A=iu^ETZ z;!vJsSa(2*42z-OB_ZK3_a5{HCo}oOKdTuCQ;Stxo8irj396Ce?QrIN>;k_`?0N3- z6V9L_CnMtJ>%!mMpg#x0V_d6;st#}Cq$lNr#<2pHU`niUj2$?L*EWh~JF6$+0dLn( zzs?kMCI%zVeJ!)P3MfoH_FU(XN@H4sgPyGt&0x+Dngbuzm%Z=aH3&L+3O+h@MqTuL zB1k0Sq^}}G;kh;F4tCUr)2?LIQFyYGzv{i{+jWcDWn%2M9A|iWPx3hAbG>DiZ3elS zKTh1}EEP536PnrBR8ZCBJY>?9DO$b^OgB_LgN_dQ;RCcQgJPuq3JRl^_Ox5hs#iwh zI>sQmu6Em(Ckkdwj%Wk7nZCf@n3wf-f7r#QHRkfc==s3yoGstJDc^CCym(nK2<9>N zYy1m|gJ3%YaVn|alx9jq=~`gvhU~tMmQ1o_W4Zofa&45bnwD5t^66?^nf>E7c%%`l z@udIhC3wlIqcI zV}m~mnt)lGbqmX2V%)D2ZNQIA9{u^e1&hT379Sz|>ku&62p5p4T<3$1fc{e9Rp9+2 zRshppq~Hau@%+1L9?(^oUI8jp0Ir~w1~DnEey7-1Zh)9*|Ax_|ZrpPjzgEvs@b9H5 zd{&nW&43kY-a$q15mbp6!XJj#z{aPh;@YLn(`aM?+%2sfrhLIM#;b6mlVTFy{dTC@ z|LKP@p{>=&mkbUod%WK1BviRQg8rUnv(cKadYE)7;`*|Ln9hbm^L1H{@>RFCotwG0ra+J<%1_)p4EQ zIYUT=c|J_Ut>m)KH+yC!v!rEU97rsDFfI1=bj>HWnAO<%=Bk8V+RT``Y_wdY+0Oav?$+d;*t0=^v-z_NJtk>X9Yp3;eKNL7u8-tfns zt-*?J^<5DTk8>8YUX?WlryN<5dkIl?)d?0J7yFqmtyn$d>x3|l)oYGGQhue97&F$) z?B2bz*5?A(x<#FkQIIREPs4i0ljuLn@WanLD8!Ta&koESRe7 z@0r9@+-A-4Z-TyM(BzgGNeg@ym1uXwh<;-Q4wYwCtd0A$bUd*5wPsD?TI!@AD&;UI zBgc)p(O8b}xB1+e&EhLy!TTd5Vs8op1vpjGvEQLEK_If><;Fb5fwmy4SC&`OeRRv z{bpSasn(o6e@wLX&W7F2$}iwIX~Q1)pc%}(u4l8>Yw@#4< zkx5rr=eW^OK0@m7B^xm17nR=`;V!F=ZSHZuNZ5OoxK^r5jT7r&R&4-0n|oDqXsTT8~Ub|Bpr5D`=y(Dbuq@lV|-RjX8tjzK~hA5btN!r!SPW z_H0sLrzAbJy`2ZPuUeGm-_twD6(G#%DWaTipS(}R4b)gQ)jtc|f57{q0?#3S zma^zKQ`5hG>|W>Sl$Z;VF-$yPgSUTZl$gy@b6H*%Y)7W&t=m zpxS-6w*tQ#nuvHvWckpDESAH4FxzOXV5*m8ZdrmnsUP90UdGx>Ze-j;wp-{6mtU4} z!9;(jX%%>uI8c9O4biI_v#_syRCM{48xfSy@aEzlyk|s=zO(f3EY#j2n~Z;6d!%@U z^4YfDr}pIdZf)F))x*i_69#7w7m>oiJK)`1&{hZ>;LrT{!TEI@135CEXj;Jn3DSj0?fd`Kr}t^ z>QzN26Y6<|sEoCEV}6(2scqY$dcg8(OJfe*ScJR>`O9m#(Px^ati7bot9JF>p?*Qz zZgG|Hj``&+FJ~9+@~H)_HRE%n&cz9A6AlAq()r7-TpVOd*7ts6-JeO{^iLvY)~t~i z&pT?dQ2ko4pLKLDPayqW+fWxzP{FDXn(ng-hv~& z$x|F&J@EQl$lWHI@{(C&VJ|^!}qD;LVj8?-#b6m%Ori zqSCyF-gAl(%tu~rN3J5m}XxggBV z-P^U*&!BAo;Q40e!CgX@R;2H~0sUCc%AhO&ux|1J>FT{{>6rq1Bp$|F;Ojl!60YxzbKh6UO)zF_Q;IR5&YweJgk zTiLLkz^%~ejJ*Ojf1VI8-@H#kgc386c0_CEO-|P?_K-LFT9h|0BEatM?(e0U(3vFh z{&Ddg;V~%C2SU8xY#eC^Z;wyB2d9UxoQ|Nqaa^QBzoDyp0ppWKm-~s6i5{MhPCpmc zEAkkA=81Hx&-_Xwyg~s5zXWu)d_aGjCj}&pRS!Mlc&GFF+n$A7WF3me7p`UmC0=7K zFBmS+A0p^sBmWhYL3MVjmb`pHcX&Fz9xDXl+Nb;gW$%n6oJvYDd?xG87)j>6m-O)y?yjYQD&(o9g=43PvE0D=P?! zaqKfD2|qgE-^NeUCc)?^oQQ*I8r?a-f7JhGVN5qyj}E)w=OT*wR^9zYy+pkr&J+ggApt$?$dC9pxq@u_5YhQ()$()1PT=?U@Gt<+2yfv6!#+cC& z$gAs1fe1!(OOBB10`Q>@8992BEoV+poNHw$zRx1@@$fKna$4>+G3%Zf1ZKd(?y`p) z=E7jd{H=xFIl0QpEDT~tV6T9RxN=kz=^$x~;TqnRxq>3*D|rK?{rdMyJ=3*>h#9aN zc}FYc5z)T+TkI_>&e!k%Th${y(0k()!sVC_g^98BTER^2{2!}*XDAZ@e1k5h?H_Y= z2HPag^MQYGwh#!lq4COR6`HiU$Lmphao9TsDg(-ilkvJMB_gYpnl3}?E1jN0{nd-u zgAC-18E$0-1`ID8AFO11&9O2nz5G)PzjpsP6>X zqbCKE^Oj)IxQCuK)xS|mv(~G@KbpqXTwc!9(v6mi%60N#G9^!uD5Le2Rd-4A!1A6r zjE;`bl<@)c7inyHl3j5nY@YD3I5hny8BupLw63tbKpcXlNt1H;LdpP!>hqu8qH6_9beUH(6-FFS|baZ%)Rh zf;_()SVGk}=$l{AOY&kg+MLk{66H;>!YF|$iK)AdB!o_$zcuvXY@5p%DMD3+G7X0g((CP-{-TwA_MGruDVB#EB!U`PO z>CVZ9y>x2NO-V@G0y-M+wxL^_n~aQ;%hqoIz36IgGHa>^W&T9b6qAhMTiPZulFI7> z1QiU->}0TiU@xc+x^2LjRf7bC2O!g)2@hkJbA6CAkKURj9MYtveZm8a_7?r8=pe>Y z!-{VB@K)v??HC^C&DaWSrlE@=nUT$x~McTR`TW7;r zjC#`X^~0oI?%yXB?Fm;Wjc z-e=4Kui+xNfrslqCH_P`4)YF@95^(|?x^fpmK>$SK!n2{dw;^wl;yzDR7*c;LNE7} z#*FS|CS5pxfSPx-=Ok}UiM0Sb+fk$MWU5U<7M!8bpT@)U7jl5!tT_OhI8nQ|XN1b}|v7k*;YJ7I=_diGaL*(+DD zJAI8s)iOh#PL)zZl)37hAx4NVaE$G@ytcyAKDhhcV*c0HIdKKstdi7LwAY06S-x~c z|J8Y0v;yB+-&2iB&;LLn-dI+Bi@Ah#7&8kns=POtO+sB>fAyWD(LF`qCs!n>QKpwW zI8}CZv~;lGz>tO?%SSP+95!UHeuD3Ie3OV44Wq(5 z>1WHlhS=-RkAv0oiDm!|!Fdk7wr=FbFw|dsN#x)7lDIoaX-3q_0W5Dw*0Q%!$sQ&A z@CdZh>pwcTt=}KI^P+xBhIZ&4&ezvv1ev1GQy)K)-zalX(gZJGDQ|vh99=Cz?)u>? z8~i_t2FB%-yCv%_Rf9l~y|TES+J9w9Ty(cCIk6Roo-^+Ragn_`>ze`fe0*|J^Q@a4 z@K<^N-)OIm(AtI3fQ|r_t}BX!k*_4t$58=*e196{_e_Qr{iy3M{MDh$_h~3kq?aX! z5>=a6gVVG^ZbI6;uQ9G5|0U^F(4rj~e%S?uHp!u_b6)Ro_ac7oe0&Z)vYXNtnLpG{ z!|(#rY^zzGJ;DB^cky!P$mZu2sBZS-&UrR4v>$z>WzfPv3YUq0u zvs+<;sp;VJ`Dk5F@>wERKDUH#T9$WAuK`skgu9Dj#CJwBze8M)$}tx_Rc`4s@GYRD8YZxz} z92y7$iek<<{q;>c;;+=ZkMik!5Cxd)4#QAyvh@!0Kqq{*jOd3(uaZnx>%PZR3(h4c z-(CB0mSybah~1dKDZJh6YSsD=WdJf8u|!+|6W_FGE>xT+hHJ9Q8F5_Z=UUme(ZzUId8%c;fc4dzGC zD1$N*_p+&ok%vI@X|p;AhrGBZa9N!CZ70jv=*PNRaY}rs^E03hB>Zk+i@OU00matB zt3~{Ur!X_(dQM=vScH$`uH}sN>BVfSXyyP{o+@MRSY>*el{()RT$dWBI(rDf*Yeo^ zVK86J0zdk*L!C6$q5!;gbj2NW?=OjaW=hSY`SMwRMoO_YaD5#jEcq8K+}m>l)P^LC1C{Z0Zrw9Mq(Q*b+rvZBRprKI9CAW7u?)G`rqn( z;%fSB?M>X@L}s3(4)rX7RE}+B~_IZNi)N zjEbIb5U=$QxgGIVa^G__6R1*=DV}FVNd;efo)X|9Kv9a@L1wgFfL>dQCZC;2u1$t< z1rd{M{!g$f4bgPg&iE%#?n6Yw7JO+WCyoSs8iqGMS$9-<5j4L}3GpSZ9t$#ZVoC2F zHdd8lC&h(~m<@UzjGG5;W{j=$DU%Cbe1xT2y*1m9@Q+3OrPc9obVC2tBT5N=4p5a; zhBbT|G|1U@$b@WHM#y8<5M|^hq`YEenB5Df8_OHM>0x14v(~|~mqt?m)qaqZE&Tbv zYjiYF5hz#GY6?|v_n$xyKQv643MMfkeFl6GErWiBSESH}@^HDB_w z`%x(SZ`t+jrSnTcyQax&`N=5!JINZ2euF~=0J7&(DC)+rB9n8EsPOT&U@~954V;^A z`sxh_KofXuRCWXC<58_K$1GmNiGVPgHa?xYXiJ-X z9Zg~(8gOL6lj;g$2&z1pY^eFn`i}5GB&@0KxpHU+JFbd7`u+}>v!y}G{OQl`=Lg(m zN&qnjW}Yc2H!g@bTL_0y1(%1fZ7}5*%+`A`b7*gs$Bwhs)+GoG54t7{llDRaC>rZ- zr6^)J278F0u%Th$&DXQ&2hCrQ{JQ0@Qvbnl1ka8Q$kQwIWvl_DJ}zYvaGzTj^4a896O9nTZa z9)bYo+PdEdp_;17O9gUK@;bR#p%&W>gRf=v;G9~QmN7q?9KjD}sO8BDN_1iRWx_U=ioh!0!<~ed^G09(E zKU(S+TZSlG249Tu6}!UbEsq<*VPYnsBoR*6f}A*l} zDT+X33(3RVR3cTjDl+kwbs1L$R=1Z~r`Qh3I-k`?@kg|oy@VnANY#8deRo$Q^8<2>*_X1deyc&Tdbr`l*V)T3 zh3?grl@hmc17rD{7xV`+jJorTr=2R#Od;3`@jKpX`{d&&kRI>D)9EAqaD`-Dx%&8v zT!29>q!vD#VP-{?U{B27WKSxS1%1CnK+wcQC(aC#j>=iQshYF8np}TXDgw z<)V0wONU&k)xU-YetNomlF|Gw5?a=#gqV$l2e@KVRxQWw4MlDT&R((>azVBb(znU6 zIM+bGOJ`pN;7ctG3dfFYh}|bmvn%ryXyhzP_Tl6}l0uD-ft=rAW7>Wazwz)!$7}2d zU>7>#nsJdY1zF2Qx_`FXAkT*Ry=2cO`*oMIMBX{W6C`_9M1@uf+iMzz3(C0dtrC&1 zv1dDS!pugphD=SG_){01xF-A0u4ZHDet3%F+FE<;wc0k@k*Y0i?FQKbnb+BJ@`%nW{BG3D|=hntNuO~41K0hhhxTTTsl%iu_P~*qZ zv4!q$FR?U>vXfLA`f4|2iF3MUQOIqJtlB+htNiM(7vp}EmUr4WsipnfCf~6_sx_!0 zZtTgB>wB#E%3`a~-v*g+{@nR5*Qh&Mul`iyK%YA7G&Z}^a)jSGVqNF;#O{nc$LR>w z`)`dH>fQF+s>?a8lK);kKkUp~n>ktT>2kK8$bRVAe)6@2BUU4|`L^6#ZJf!*GOXIv zq6cH~BJ}$|Z>OKRYc3YaW{;jCHC38Q$)1b+I8EvGR{T4(Hi-lgr`?}@*p zW*-O8-w5MnlomAuM`qEyn>BmbG~jeUB2-_sHqxugY_@uTFx)Gt>P4-8kjS=0E>?ZO z-7|D6yj8&+xm?pGWHf*+#fh%!co_%+S%(HWLkm3I|CFFA*w6=r4S)YKAPkbeITnM#+XzgPJHtfCwVtil9!x~hvY4jY`Td=B@&`qXIS2>i;b;n2)8K-d=ek(J}Ad*4}&VHJ@kB`K*NAzIBuN*qLJh z0DxIj0`&vfT17_%0QeBexc`ur`WfP+ zVdM$`urwaMfi38Fj{yKNtmbu2zGV8T7=1I60%%*DG^BBrTv-^{yezds-rx2F1QEto)3BU!aL!My!(-sfJ_0`H{s8NYU)A1K7-k0McR1JYs>II z1X(7!%V~i2b^S?qcj^_%JH`I%F=>^D$EoKnbBF$-gB>mnY1_Yn_-=npE37*pqFK*d z{<$d8$m93oqE2oWmhnq=fHw=%lC|2Y!}sAH`{g*6ciJ|B!>lu%1CODnZlnQtfb(gVm93H@?DtGFCxYf4?`5%%e zelYC+VD$$K^9|0O?qWquwzfW2wXy#U-d1}AA|U{8fEk5tB<@C!9u+^PelTS{t{$)2 z>UF;C>T&uCM!3@>B*^CFc@_r_#AFj5+;d8udssLgABu&fUwI)@Vk(M-TC{@AOcb3W zLp#>zjkcVPxBWIVs|$9f*400Gr};R0pCNy!SXrnHr6JHlWyEgiOpenWoN`mOD$840 zJ!+j1g#TJe>OGw|+vypz9K4vESGl1 zQ8HoEq-Y{s!6vtJ!`6B7%&Ht}sQh@jeZ)|(*mHElQ?hNiN|MCARn94Kr_uh2mIuyH zuCCSDB*2j=zXlp~N2-eh;!1A-BFKhW-7fl4*fTrlymEX<*jJut?iTQD87>&|O`_6g z`(4_yFvG5oor`A-NgWE<~_JwbdWXt-h^huNed8~tdbzIbVUYFjJv zz2!IXy@PnaM?)?#`k?2C^$b`~EA2~RHc9L`;eOnLRG7FPTPf*`hW%paGQs8oN6A;r zyVwZq8EL(qkk*l*Kp*`>8r!=qmR^T;;o+81=5Jk~B~y))`|hrJ5zy6Vt{pXfXOk_Q zYeG3gjjA2E@gK?PZ_G}ZThB%Ex_?!CQWAC#G%cC3^t-hbkx zSno~K7c|5hkc}W%-EC&A?tU4%K{(JOJ|%Jy!+Gv@yn|e~PElJ#sNHPjiz;`4)-#)t z%HJG+{n;Z^GQapknL4RrJ+=VDHbXj9RbwSj_PKEIyuimkyaQ`-?F&P@4y=-@3i_L&<`Kt`-h7U=U(u{1#cSAe5y1#HlwaGpPJ9 zD%kew3ZQ_dY~0nD=9O>%E-a(W|tI}Eef!NGkusUjP)3pjyr;sAu<(`lX^GdX3^ zkfqJV=wK#`dR15Gn-eYeZeUbv4KoB3`|kNEt=GrGVltFxP{cILTu1=-44!J-;@wa@&psWrx4xmrsql2c1%bkXI%^ZE3xuawY7PUwY|eyBx7&RZ zoVZlnpSd4xVb}jg97~~i?B0CDYxtAg^pL=hZMX!@>4bQ^+*Ihh(Z*Q`I5WoLY+G%b zl@{%jAKsBHdyP3xLPyygQsxS)l9hQ(Sxh&OY-yfFMSS~Kz0OZg+Z1z`L)aI+ZAK__ z4x_(#Ae3{LhT5=PkCk9&?R&2Fk7x7MPsfjxSt4MGC9vQgHQLw)}W*F@LRWe5p z$~F>kdX!l(wO&F8roo$pv&_bs-q==JuvqkXJxjVQhK)kj-zP03D3gJus~?Qel>;{r zcj2}Y?cKQJbH+3750cHD5U7!N)Ulnlg})c#h*y8&Z(vo;dbvCC5Hkj2D6<1;n8W28 zuLj;smXa6_Wg&jz66%m-Y=Mo^U#(CBmJQm}W`)1RjP|#b>f66ipv0q2CMfHidEPv0 zrXx2owFhlZh`uHzh1NcuxOB=n;XOg`LG!O@rP~6A#MEraw@T?-xzaOKz)3O;Hni|o zl!Z~!rHFA!!Q~UOovplbZ{M7Q8eZ2g`JTiX^yR)CXl%~xOGxh?p=&1{A)Z!H<`r>z zqg1D(>i~a!xomA0y7Lg-LPimDb?rwC3}}cSJTa%1adVP7%JVFF=e%N=1RTbn*VVXJ zy?(97+h=v#{_8<132qr-KOg@<6nnCX^Ky|3bbc;_vd`LOt~8P6BVnU48z(B$vh00L ze#bsjs!&<>l>bk~&&2m@IT`kidOLI5{o-`G)f1of5R>zdg+sc42qdi--OHtS&XZwt z{R zs@Jh(Hc_A<7y-!G;#VQp?u;*ic2D>!AONR;P-Pp;9+I+BqETi-g|>B$rO^Bb*N94kX$#p=AXq|l;Duc8f+ygBDu8ZW<$5hbac!~Q?A@EAL|c-v(a+L>CN_g?q48&sPzw&X+AKh$d4dm-gs!qC+_Hs#}n~IYir{djk z8pC=Xdc%{WQyz3X{6qD2BEoUgmQmZX8^^!hkEtpo3> z#{yIZa{8;-ZMq#}KKAheo@t1#hF>2D9r5?e2+9`6W-luvB?&xB=7A-`7v5o!(qg^#YOq4;YX*$fE>enRF2bSrE}0$@T`NAU7)?Ehti!~ zr45ePKlrTM^#B9j4IZ|K&SVPw{?o(LZoh zsdEe^(&6~LUeoQg{8)9>;o6Aq-v8c7>=Obm;Y~-v?q6j5UIt0#AxL9cmG(dnZOVMQYg%dnP7 z#e|jt@ydD{)MCx6kZlKSu5Oxs{r-%|*z_S0?Qe`(aZS_ybptjtJ^o=JBE5Tm#=Wv> z2b;zt!*HM&Y5NPao#yJ@y?NhkeUCR$jWAgv1g!7!jp9wY+77FB5jRYb5ZsYvGIPvK zAl`%)|3_R{O@&!0JQQlX%ZHlVz9^xOO7W6kC%?W{^y;7!XB*~Rrer*|HDq4wh(?c1 z$4U5ifcZ@MSjTqT4eB0M^!G&UZTJ*?+9~}p+0DWo?3dOexvQA(&9`d1^PP1zPE0qm zyYW;!5;RUcK?&HxUk`eHQ|DOLEzw^M9x@5lkcIAa_wmWPX_5i)%S?0)Q*~_yrk!XW ztyrcjmP3~NW;IILM$>CX?L-?Ze*iK>3rDZ7%Q~t}1$rsae-=B<)gO3t)|$wi==JYA z*W!b=o>+gWew^yCp^)m&pH36h6*olVDA-UV&3<3N?r=|CCO!qiXG!m))|KGR?684lfrz zN%*Ct`qJIIyx4aujRpaO#!FZpfEPe*8Y!al^AdWtH{)W7h+oGlw8@bKUYO@z9O?;Q z_oXS$kzDN53&^%-fldnUKnPg1BV^8cqV|wNehb;B&UBBxJC5^c5Q}Rv$L=y-Wq!vS z5wz;MPN@KzIRZTv*2q@~pTvRgcon}BqP}LM&j?-M)ia(}OREVt_M)xFLnO#HxK8;` zhYsYmrcRydUcUp6ABF=6x!LW?8{5)^x3Rh5Rza<-!PDd)hr2CGCV;4b!}4NGq0iQU z!PwzAfX}==oRZ$^6&W0)B)SQ>R@r?`F54sj$Em(mAPgDqDo4{ z-^7BPg#O4IlPIz45%sH)m(qh#@?9oR-Ra8Xjp7NRp`KavWleX&4lco7*0g?t;H%q# z08BDPnLl4Nk8W3Zs2O)?Y23HRzp(uSlX?_H)Et4WwaC4!bYHpalsICqv2w{pue1(4 z7y$5K`X^ciQEAFDC*nWFsemAFG7$$vW+6dNqU$QE4243xm8?K28^MFw9+$ajFYwu%EcNMil&vcBJ6EoqviGx8+n;TK zy!-t^Y}~9gi7Iym?LO+*FUseoVn*Q?%ACESQF8)@t=8jO99`Yuk(>y*O_w6Ie6udo z$4ZgB+&;K6jUSJAA6hhgR29Ig0@~R-l2bF%|P^c%qcvdZ$tSh z&a(2pMsMClMd5qy&2BqmWgGH3{8yVm8sEH^%}iP>BwPDah4wZH@6H6}oMwREW_xb^ zgTQk-M2zNzjxwlNZprVsDMW)U;=H$1CH-5N^~MG3wL6qvOZU?zkmn1A222bn^KtNF z_j}<|09}*WFog2pdc;rz$32)M&FIqy9kq--n!h>$qhuhZC0mrJC(v@&Mzz8ce6~VQU#5w zZO#X(Q@`NH$5>pNe--%5A~ZS0*n_304uQ0^bj&qgsGxUrB;$jG+{5cYoj_Eb$+ zPhLTntHBkDt*vYc+&i%(S-SGqd;7KRp1Lh~PlEQnmP{f0=}rkO9I5mot27@<>k#*r z#>>kR_N=$eB3^k+-{F{EBm?TsIC$1Y5ssw0rOrLM=@G?2Z~WbnX7)B$0&EP77`Smq zs;dKrp}jF=265Z^i5e>^M=R>qMnrZa=il_WL2bI0p)?Gy>$TY2ajydLj^>a}9VT38 z&v+RIssYMwxDDv8^vQPyWyY5(cjJT_wF}5^DfwADEb?SaYj=eFUHE zyO(~pr-wHPV@4{>c-D+uOpk=5OT>avOHB1A-q&NGt*JWNB^{%SJp>gq-$8ELeiJTD zg#piKX=V0hAzmX|h$gr~ z=r?s}mbn16SUW1x2d-&H8TW+$m;pqfopnI%Vct2s|`@C=by%Mqt5@ zH)cwLq5bCTMCFx&G5Q5jC3y?p_NCZFWea#+swFoA>h3srWn~3K^6rDiT@!*jm0@ve z^!*utRIm~xy)Fg8V^5%cG_liz3vw%Q1li-dRi2!C2uQU8sT^_`puWG0kULu|Qde{M z>T8NUIiod{>F*HOBohOP>EnDq)ht;dFE1Sz7M=ZmUZS_ za9IsxbbLIqRlcppVl_ z32;4+@YeF1kQELxZ>t)^mvuO@kUekiLu@@I79Wij9vakYaC06UM)R~T4RH`v-FQt+ zB^lepmm9P1U!MVr>it^bT|Q5DTe+=G!u(#fYwdhri*U2sfukgH7_sRLbG0xpm${s-Pz$}0Ac$xc)w9A8GYB*lK@4vKQZ>q(k@TpIHhN)Co@Rc++DJiKfISiuVv<@6nIQP}( zxn^=4qoJNp9Z`y*8K?Snn*dwt&P>revDsU3TG0^h_gtHBYo$CPm%uH1Lr3Nf+_|1E z<5lpqPI}dseudk9eHZw|fWgnEb&$eTa;=AC3cR13+e%vi!@MJIYo&L*#JQV#^Q5US z)gXAYRIc8YDw2LJ{jL7SV)y}bbNQGVkJov%<1q`xQK}BmC|D7ba?@{_0u{vy{+dre z1%4}|09~%$%&5ps4Cv~&Zlb|F-qu^(wj}tP{_aZ3?`)ONMRDMmM>`+XX=h^!O&iKr zvLOu&eiQWmXly3YMnbe8SIdvTYtI6d7lXjQgOCd~U3g5wt5)S_Md?UpUylaYy1Qf4I>dL3u z?$wQ-Qg-Vt?=FfmW)V3T@~6=tVXsg5(NT)2(zs9)A1Crn$-ko7(~~28E=54tRUFv&syY;#&Xa4jP({5}eBsB3*H0->2pHURi8><>Skhl!y{b<^SiLjtJcgW8U|ONDEo zZxjHT^lwUoJIp;40w4gO0YR@=*;Q>!_x=$sr08^F{i@PQKLNnYKnSd?&Q}zt58xgu~(B;`Ie?4-+Ak_b+T^qc}}s*KcoX#Wv@~BKr$jUwe0d zE)Uu~mK}Iz%4?t1hzd-H$A>-M!9C~xqOmqssS%tBTkeafEJ+*?V9E+Azvky7R?`e9 zd-aLGKw@jI`1~2A^LNg2H(t1K{DJB?_~%L0a-i2w@sK3t8oGL+eosyl{?^7@)$&53 zr@gDYcinm36@B|i_gN?!@dS8hHnTc9eXd=-rMGBnVu;tqb$-~zo8^y4(T=@TX*AXTiWk0D<#sFsSFIcHpn`db7|LgW>|2wGByj0q<*W1FN-W>X>Y9F zCBwL`dH`=6;2)oNfIcq<*;U=tb+HWSVPA)fa&&;r4oNZV^^nIj8f;P1<>Jq?le_M2 zxv{m^-m0JG^hFXOx0Wa!$2^q31c_Tg6d6qyr~n_NozkQ&9fN0o_vGaG?7v z*TY2ROVn|Cag@Hv>THvInL)ULV3HT zvhcNZ<9m2iRcDL|kH)pS^Dlb1y`d$3^rFomcsj^7d9{L2BO`i)!0uL!k7j&bHPq}; zQ49FKLkb(AZ!oF9@?C0bTYKX?)MaPSwZ-7BA7am0)3#aJyp6do_iBpDJ4szTQ1b-M zgQxY-$yFdjXRI{#)2B}Xzq(Y^Ost+kvcnexdcdVp#RncQyW=K%hhEa$<%;6LP{ZNv zmGbfwdy#aA2M32=n#2L!3{fg10wt&$InXkL)ruUDHP^up`l1n8Z`N7*8B*XmYXXQkpySaZ5{)39}=o!4k zR&LSxOW1J*p-FXLe$h?D!)yHpmO=T?GvtG}FQP$f$1+9$^3Dk-i@_tg9;T*la#Mk! z^4#iZ`mN2=cD03sm?8J{>O1yZA*`}`?qjaZ1p}%%U)5`M6Xqag-D>mQVOa;gsP|KX zUBAINQvYb*8?^W;{on9@M5*}<^NnS%9JKkFN>z4VurzMuY*(|%rCO@4t`Z5cI_as) zl#6Di7tI6v&y;Az zYDuNDL~>@cF#a8HJmUHXNG0mXFjQHe*F1S-7>>NB8itnY&>pPJ_?t<~WXzkITNPQ# z@tZTRTx8SVyEv154y#dPX#*6E@g6b6qWQTkH)T(vr1eN5-*b8cp}e%mO3%=cSo8mI z4fCnVTiJ}>xrXegYbGAm%7E)9G{$s^PfQ zN`$3N_|nYlJ@G+fRqQW{w`;!I&de*tB?&uTa0tjvr|+#uzPWDO zOGN9~TsM!oPQ_tB>*X*H)kHA{G^^$K%Qs!Hdx_TVvC6x0*xfe~$!j=otkLsl)Imz4 z(!a4W+Rcr80pX8PkZhhR{26dm0(`PvGij^hR%FoKnlU<@m3dKSom)c4z?Y|B^NacI ze8jzVR8qA=IJkLbILk-PTW1oze%8!%fQZ|Jl83-5BMt&La9ri62zx)?kN?o+`MIqR zegY-5rPz``NOq~2z5n?K)7UZg-`Q0fyDJSiD+UlUmAMQ3Wx(2;fWjvvDt8)|q^e`9 z{(^mIXej1;ODv$^4_Br}`2VRzh-j&90tcX|iM)a8BAzQN@|{!+9tKzPfIt4#+DH63 znw)}<5t#T)H8Owmb$VC(*d8$#xKk(u&&>{X$`2vU$o+IbG-5uH!T&Ez$HxjMzlSv0 z(_ef{Oj-_fVay<;5r%@LtO}u$*7ez4+2uiru+=!Sr}j2i`S`NzwPyK{7{_U@K==x4UXEB_!Elc*du zs5msan90S*F+H7e#phFg@z6Lg!&gPLqq%AA56Wlyny7>6QHU7%77Rj@YtYlZ7KXEcCpvfPBN43Cvz-4DmEBLvP z9LM@nr^7J6e}fRmDzncYVEhlNPx(_F;R-$*W9!`<4GbckK7Z*uq*a|t7@ZDe*@ z86?=nYJTFsC^P``BN0EU83ojf3Qch=^URw~6jX1MG zP`OKBC7o$=MNO|aJJKJmEcvZv%YcS^b7;_91o$Twy?(hfdxC0_>r=JIzveg|+!gQv zU&BfJEa->})<8_m&2^8;Pk%}1Y#;K~ zTl`Wwd}OM z)iViPr!FybWBSVw;RrmvJqGaryW8j=52?B@OP z<=ippEI+l!^3FYxIQz%Ea#^9zi@~G3K~xzT0Q}uw<)=@K^K)jpCRP{Vll}Mc_T4^7 zG>kzdtPv~+%&@qLGg`^H-h$&>xZBFFM3*y-KBuZ(Cc z3igYRgy{gzx%vLS#-8pO+j?lF5_I?sO@q5ctJrmG^0jAMiTU|297z#Zlr#S*(u0iC-i{0~Zx?AoIg z@^k6oUz*tQCwm-Owco&sMX3tw+73!q!rxFVe_?NZefnq0>q#bBMqdcrde=7LwgJO zS)auVn)N_<2_0?2n1t1-3`lDN#qyvyN3dHVr1E<+m@N_hAIal10;Y)`?5JNHoY`qG zDc+cPcGA!U&tjS^-14~U%paEb`y@2EtRup(2P(Sl?>}OM42QfM zVm{k#Ry+ZO`8;?K;wWT`8<#e3nfXGfb9iVj`;LpCU)v;kA{6qCJT%7AhxMQAjiElO z{v(4WUO8$!KEPW)Eq}GA;p6*_5|-hGgevg}J^*gsFHmuyE}l?*0Kw4W0)P5kRKo=q z2myt9T=@ffY$4G3zJAzn%HEkR{^F{EbMwe6bWZn8`GERy9AN?ZWC$tQ4n&JN-Oc7m z`Z{z`3J6)g26Za_w=(ZU2xPj;g@2xQXW9MqNt>X6QSkSb7w4b_jR6-gV)uKR#r6ednnLRr?^+q`l zO^|G;70L=iO39E>yaDb`EHveHy6DuWjNkp<#w$NG|6tTK6Xr7(`$}u03Eq>uM$Uh+ zo1}V4*Hnx;!Kt(0w-&P+f7)c}VGUCCVr;2H`#T@^m(#FeYMj`1_Ltlu%GbOTUaW!3 z+-SJQFkRTjhqAw?L?5qgWNv$#wdLmNV6}uBC1~dN5JcSOOSVgKzst}ECbOQPr%hNl zdJR$OV)i@#8{)oR6f(tw**$DPS1lUG^pL0ipz&Ys=4*Zg;GCom)zSVRq%&$g_y4Sr z{Hf#u@Dcx(`TqZ*|IbDMZVzHO7u;oTL@lZZV4~s!bgBD;o&@Te0fsk?XZBsc$WrbU zY#Hh$RXW`3?oL$`xH5H*ZVIf8g%kv*l^xZ?yWjA`|7irr zR4_HaXKIGL0YMERaMbCryQjmTmucZ8?1&L@&RlU$0mEYmB!7IFrRXi{tK>vHUQ5)r z1DV{8Oh#T5#l{+1_0`=~@cWhS)3a9T({kBUsQ@~9>j{&Vp>^^xufxK{86PIzdW)Qi z!%AVXTcRw~o8CiI__Mf)BmKnn$lwG(XC=5xExfs^-<(@zf`PS4Z$~-XmSCm{ zSzSpT>{H+=BuMB(Yxc>&kGD?7vy&!I=nl@hpBTy%?Xd20Tvm0@H0j&RXQj==} zhguMsN@}w+@55nNQ)D~Sh*u=_t{#n*6rCFJ#)(lzC6Y{RG5_Glm!{R1Z>_yABgwMO2uS@q#`x4fsH6BqQJ1ptD~!#}8ZOI{Cvz4LX@)pxFNQXYN>cx! P0nk*xb-hRx8Th{d?O-Qz diff --git a/img/project_editor(report).PNG b/img/project_editor(report).PNG index b9ef04ebee06ebdb91c30697170b85b99c46aaf7..6ab88983d9e90b8bd884957955c8890539bf0a99 100644 GIT binary patch literal 6906 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 literal 6981 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 diff --git a/scalewiz/components/evaluation_plot_view.py b/scalewiz/components/evaluation_plot_view.py index a21bd2e..02af3f1 100644 --- a/scalewiz/components/evaluation_plot_view.py +++ b/scalewiz/components/evaluation_plot_view.py @@ -88,15 +88,15 @@ def build(self) -> None: ) label_ent.grid(row=i + 1, column=0, sticky="ew", pady=2) - label_frame.grid(row=0, column=1, sticky="ns") - - print("plottings") + 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, top=0.95), + 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) @@ -138,8 +138,5 @@ def build(self) -> None: def update_plot(self) -> True: """Rebuilds the plot.""" - # running into a weird race condition when rebuilding... - # this is a workaround - self.after(0, self.build) - self.after(100, self.build) + self.after_idle(self.build) return True diff --git a/scalewiz/components/handler_view_plot.py b/scalewiz/components/handler_view_plot.py index 7eccde2..2f530a7 100644 --- a/scalewiz/components/handler_view_plot.py +++ b/scalewiz/components/handler_view_plot.py @@ -34,7 +34,7 @@ def build(self) -> None: figsize=(5, 3), dpi=100, constrained_layout=True, - subplotpars=SubplotParams(left=0.15, bottom=0.15, right=0.95, top=0.95), + 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)") @@ -44,7 +44,7 @@ def build(self) -> None: 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) + self.ani = FuncAnimation(self.fig, self.animate, interval=interval * 3) # could probably rewrite this with some tk.Widget.after calls def animate(self, interval: float) -> None: @@ -52,24 +52,28 @@ def animate(self, interval: float) -> None: The interval argument is used by matplotlib internally. """ - # we can just skip this if the test isn't running - if self.handler.is_running and not self.handler.is_done: - pump1 = [] - pump2 = [] - elapsed = [] # we will share this series as an axis - for reading in self.handler.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") + + return + + # # we can just skip this if the test isn't running + # if self.handler.is_running and not self.handler.is_done: + # if len(self.handler.readings) > 0: + # pump1 = [] + # pump2 = [] + # elapsed = [] # we will share this series as an axis + # for reading in self.handler.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/project_editor_info.py b/scalewiz/components/project_editor_info.py index 445f457..8106fb4 100644 --- a/scalewiz/components/project_editor_info.py +++ b/scalewiz/components/project_editor_info.py @@ -7,12 +7,16 @@ import tkcalendar as tkcal -from scalewiz.helpers.render import render - 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.""" diff --git a/scalewiz/components/project_editor_params.py b/scalewiz/components/project_editor_params.py index 40d4c4e..b7ea8c1 100644 --- a/scalewiz/components/project_editor_params.py +++ b/scalewiz/components/project_editor_params.py @@ -5,13 +5,18 @@ 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 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.""" diff --git a/scalewiz/components/project_editor_report.py b/scalewiz/components/project_editor_report.py index c3d6f09..f567e45 100644 --- a/scalewiz/components/project_editor_report.py +++ b/scalewiz/components/project_editor_report.py @@ -5,12 +5,16 @@ from tkinter import ttk from typing import TYPE_CHECKING -from scalewiz.helpers.render import render - 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.""" diff --git a/scalewiz/components/scalewiz_menu_bar.py b/scalewiz/components/scalewiz_menu_bar.py index b74021c..bcc374a 100644 --- a/scalewiz/components/scalewiz_menu_bar.py +++ b/scalewiz/components/scalewiz_menu_bar.py @@ -17,12 +17,13 @@ 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: tk.Frame) -> None: + def __init__(self, parent: MainFrame) -> None: # expecting parent to be the toplevel parent of the main frame self.parent = parent 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/models/project.py b/scalewiz/models/project.py index fa4a1a4..bf16efc 100644 --- a/scalewiz/models/project.py +++ b/scalewiz/models/project.py @@ -91,24 +91,28 @@ def dump_json(self, path: str = None) -> None: if path is None: path = Path(self.path.get()) - blanks = [test for test in self.tests if test.is_blank.get()] - blank_labels = sort_nicely([test.label.get().lower() for test in blanks]) - trials = [test for test in self.tests if not test.is_blank.get()] - trial_labels = sort_nicely([test.label.get().lower() for test in trials]) + 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())) + tests = [] for label in blank_labels: - for test in blanks: - if label == test.label.get().lower(): - tests.append(test) - + tests.append(blanks.pop(label)) for label in trial_labels: - for test in trials: - if label == test.label.get().lower(): - 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": { diff --git a/scalewiz/models/test.py b/scalewiz/models/test.py index a9dfbc4..6f5f8a6 100644 --- a/scalewiz/models/test.py +++ b/scalewiz/models/test.py @@ -85,17 +85,17 @@ def to_dict(self) -> dict[str, Union[bool, float, int, str]]: 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")) - 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( diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index 35b4a07..7205436 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -8,7 +8,7 @@ from logging import DEBUG, FileHandler, Formatter, getLogger from pathlib import Path from queue import Queue -from time import monotonic, time +from time import time from tkinter import filedialog, messagebox from typing import TYPE_CHECKING @@ -36,8 +36,10 @@ def __init__(self, name: str = "Nemo") -> None: self.test: Test = None 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.limit_minutes: float = None self.log_handler: FileHandler = None # handles logging to log window # test handler view overwrites this attribute in the view's build() self.log_queue: Queue[str] = Queue() # view pulls from this queue @@ -60,11 +62,8 @@ def __init__(self, name: str = "Nemo") -> None: 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 < self.project.limit_minutes.get() + (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 ) @@ -116,7 +115,7 @@ def cycle(start, i, step_ms) -> None: i += 1 self.progress.set(i) self.root.after( - round(step_ms - ((monotonic() - start) % step_ms)), + round(step_ms - ((time() - start) % step_ms)), cycle, start, i, @@ -127,16 +126,14 @@ def cycle(start, i, step_ms) -> None: else: self.stop_test(save=False) - cycle(monotonic(), 0, ms_step) + cycle(time(), 0, ms_step) - def take_readings(self, start_time: float = None, interval: float = None) -> None: - if start_time is None: - start_time = monotonic() + def take_readings(self, start_time: float = time(), interval: float = None) -> None: if interval is None: interval = self.project.interval_seconds.get() * 1000 # readings loop ---------------------------------------------------------------- if self.can_run: - minutes_elapsed = round((monotonic() - start_time) / 60, 2) + minutes_elapsed = (time() - start_time) / 60 psi1 = self.pump1.pressure psi2 = self.pump2.pressure @@ -152,10 +149,10 @@ def take_readings(self, start_time: float = None, interval: float = None) -> Non ) self.log_queue.put(msg) self.logger.debug(msg) - self.readings.append(reading) self.elapsed_min = minutes_elapsed - prog = round((self.readings.qsize() / self.max_readings) * 100) + self.logger.warn("%s / %s", len(self.readings), self.max_readings) + prog = round((len(self.readings) / self.max_readings) * 100) self.progress.set(prog) if psi1 > self.max_psi_1: @@ -164,8 +161,9 @@ def take_readings(self, start_time: float = None, interval: float = None) -> Non self.max_psi_2 = psi2 # TYSM https://stackoverflow.com/a/25251804 + self.logger.warn("%s", interval - ((time() - start_time) % interval)) self.root.after( - round(interval - ((monotonic() - start_time) % interval)), + round(interval - ((time() - start_time) % interval)), self.take_readings, start_time, interval, @@ -182,6 +180,8 @@ def new_test(self) -> None: self.test.remove_traces() self.test = Test() self.readings.clear() + 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) @@ -219,7 +219,6 @@ def setup_pumps(self, issues: List[str] = None) -> None: def request_stop(self) -> None: """Requests that the Test stop.""" if self.is_running: - self.logger.info("Received a stop request") self.stop_requested = True def stop_test(self, save: bool = False, rinsing: bool = False) -> None: @@ -326,3 +325,4 @@ def load_project( if new_test: self.new_test() self.logger.info("Loaded %s", self.project.name.get()) + self.rebuild_views() diff --git a/todo b/todo index f6cc0eb..b69010b 100644 --- a/todo +++ b/todo @@ -2,13 +2,10 @@ todo ---- - new screenshots of eval window / plot for docs -- implement a singleton pattern for dealing with the handler/editor_project desync ? bugs ---- -- eval window save only actually rebuilds every other time ? - refactoring ----------- From 58066bc28b5bc8987667f7f862c86f7fb677c666 Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Tue, 1 Jun 2021 13:13:57 -0500 Subject: [PATCH 35/49] back to multithreading --- scalewiz/components/scalewiz_rinse_window.py | 63 ++++++++------------ scalewiz/models/test_handler.py | 49 ++++++--------- 2 files changed, 43 insertions(+), 69 deletions(-) diff --git a/scalewiz/components/scalewiz_rinse_window.py b/scalewiz/components/scalewiz_rinse_window.py index ecd98d0..43c6a34 100644 --- a/scalewiz/components/scalewiz_rinse_window.py +++ b/scalewiz/components/scalewiz_rinse_window.py @@ -1,8 +1,9 @@ """Simple frame that starts and stops the pumps on a timer.""" import logging +import time import tkinter as tk -from time import monotonic +from concurrent.futures import ThreadPoolExecutor from tkinter import ttk from scalewiz.helpers.set_icon import set_icon @@ -15,9 +16,10 @@ class RinseWindow(tk.Toplevel): """Toplevel control that starts and stops the pumps on a timer.""" def __init__(self, handler: TestHandler) -> None: - super().__init__() - self.protocol("WM_DELETE_WINDOW", self.close) - self.handler = handler + tk.Toplevel.__init__(self) + self.winfo_toplevel().protocol("WM_DELETE_WINDOW", self.close) + self.handler: TestHandler = handler + self.pool = ThreadPoolExecutor(max_workers=1) self.stop = False set_icon(self) @@ -34,44 +36,31 @@ 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 self.handler.is_done or not self.handler.is_running: - self.handler.setup_pumps() - self.handler.pump1.run() - self.handler.pump2.run() - self.button.configure(state="disabled") - self.rinse(round(self.rinse_minutes.get() * 60 * 1000)) - - def rinse(self, duration_ms: int) -> None: + if not self.handler.is_running.get() or self.handler.is_done.get(): + self.pool.submit(self.rinse) + + def rinse(self) -> None: """Run the pumps and disable the button for the duration of a timer.""" - step_ms = round((duration_ms / 100)) # we will sleep for 100 steps - self.pump1.run() - self.pump2.run() - - def cycle(start, i, step_ms) -> None: - if self.can_run: - if i < 100: - i += 1 - self.txt.set(f"{i+1}/{duration_ms/1000:.0f} s") - self.root.after( - round(step_ms - ((monotonic() - start) % step_ms)), - cycle, - start, - i, - step_ms, - ) - else: - self.bell() - self.handler.stop_test(rinsing=True) - self.button.configure(state="normal") - - cycle(monotonic(), 0, step_ms) + self.handler.setup_pumps() + self.handler.pump1.run() + self.handler.pump2.run() + + self.button.configure(state="disabled") + duration = self.rinse_minutes.get() * 60 + for i in range(duration): + if not self.stop: + self.button.configure(text=f"{i+1}/{duration} s") + time.sleep(1) + else: + break + self.bell() + self.end_rinse() + 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/models/test_handler.py b/scalewiz/models/test_handler.py index 7205436..bd85edc 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -8,7 +8,7 @@ from logging import DEBUG, FileHandler, Formatter, getLogger from pathlib import Path from queue import Queue -from time import time +from time import sleep, time from tkinter import filedialog, messagebox from typing import TYPE_CHECKING @@ -100,39 +100,30 @@ def start_test(self) -> None: self.is_done = False self.is_running = True self.rebuild_views() - self.uptake_cycle(self.project.uptake_seconds.get() * 1000) + self.uptake_cycle() - def uptake_cycle(self, duration_ms: int) -> None: + def uptake_cycle(self) -> None: """Get ready to take readings.""" - # run the uptake cycle --------------------------------------------------------- - ms_step = round((duration_ms / 100)) # we will sleep for 100 steps + uptake = self.project.uptake_seconds.get() + step = uptake / 100 # we will sleep for 100 steps self.pump1.run() self.pump2.run() - - def cycle(start, i, step_ms) -> None: + rinse_start = time() + for i in range(100): if self.can_run: - if i < 100: - i += 1 - self.progress.set(i) - self.root.after( - round(step_ms - ((time() - start) % step_ms)), - cycle, - start, - i, - step_ms, - ) - else: - self.take_readings() + self.progress.set(i) + sleep(step - ((time() - rinse_start) % step)) else: self.stop_test(save=False) + break + # we use these in the loop + self.pool.submit(self.take_readings) - cycle(time(), 0, ms_step) - - def take_readings(self, start_time: float = time(), interval: float = None) -> None: - if interval is None: - interval = self.project.interval_seconds.get() * 1000 + def take_readings(self) -> None: + interval = self.project.interval_seconds.get() + start_time = time() # readings loop ---------------------------------------------------------------- - if self.can_run: + while self.can_run: minutes_elapsed = (time() - start_time) / 60 psi1 = self.pump1.pressure @@ -162,14 +153,8 @@ def take_readings(self, start_time: float = time(), interval: float = None) -> N # TYSM https://stackoverflow.com/a/25251804 self.logger.warn("%s", interval - ((time() - start_time) % interval)) - self.root.after( - round(interval - ((time() - start_time) % interval)), - self.take_readings, - start_time, - interval, - ) + sleep(interval - ((time() - start_time) % interval)) else: - # end of readings loop ----------------------------------------------------- self.stop_test(save=True) # logging stuff / methods that affect UI From e77e8f7227a867d53ff88ed5f1aac961ecab1a7b Mon Sep 17 00:00:00 2001 From: teauxfu Date: Tue, 1 Jun 2021 16:23:27 -0500 Subject: [PATCH 36/49] cleaning / update screenshots --- CHANGELOG.rst | 3 +- doc/index.rst | 27 +++++--- img/evaluation(calcs).PNG | Bin 0 -> 24638 bytes img/main_menu(details).PNG | Bin 36164 -> 0 bytes img/main_menu(running).PNG | Bin 0 -> 34386 bytes scalewiz/components/evaluation_plot_view.py | 15 ----- scalewiz/components/evaluation_window.py | 7 ++- scalewiz/components/handler_view_plot.py | 47 +++++++------- scalewiz/helpers/{export_csv.py => export.py} | 9 ++- scalewiz/models/project.py | 58 +++++++++--------- scalewiz/models/test.py | 5 +- scalewiz/models/test_handler.py | 18 ++---- todo | 17 ++--- 13 files changed, 93 insertions(+), 113 deletions(-) create mode 100644 img/evaluation(calcs).PNG delete mode 100644 img/main_menu(details).PNG create mode 100644 img/main_menu(running).PNG rename scalewiz/helpers/{export_csv.py => export.py} (94%) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8879bc3..a9b37e9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,7 +9,7 @@ adheres to `Semantic Versioning `_. -[unreleased v0.5.7] +[v0.5.7] -------------------- Changed @@ -24,7 +24,6 @@ User experience concerns Coding concerns -- updated the :code:`TestHandler` to be single-threaded, collecting readings asynchronously - updated the :code:`TestHandler` to be more robust when generating log files - updated the :code:`Test` object model to handle the :code:`Reading` class - updated :code:`score` function to handle the :code:`Reading` class diff --git a/doc/index.rst b/doc/index.rst index 1f1223c..8f56287 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -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 @@ -116,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. @@ -185,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 -------------------------- @@ -200,5 +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. +'System' at a time. diff --git a/img/evaluation(calcs).PNG b/img/evaluation(calcs).PNG new file mode 100644 index 0000000000000000000000000000000000000000..72a3d925f8c50449bd83e40635efeca3fa7e70da GIT binary patch literal 24638 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 Lcu;Wv$*cbZdD3ts diff --git a/img/main_menu(running).PNG b/img/main_menu(running).PNG new file mode 100644 index 0000000000000000000000000000000000000000..ed1363e7be5578f36285e22af89bbd9c6d6a0d51 GIT binary patch literal 34386 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 literal 0 HcmV?d00001 diff --git a/scalewiz/components/evaluation_plot_view.py b/scalewiz/components/evaluation_plot_view.py index 02af3f1..3c86ece 100644 --- a/scalewiz/components/evaluation_plot_view.py +++ b/scalewiz/components/evaluation_plot_view.py @@ -8,7 +8,6 @@ from tkinter.font import Font from typing import TYPE_CHECKING -import matplotlib as mpl import matplotlib.pyplot as plt from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure, SubplotParams @@ -21,19 +20,6 @@ LOGGER: Logger = getLogger("scalewiz") -COLORS = ( - "orange", - "blue", - "red", - "mediumseagreen", - "darkgoldenrod", - "indigo", - "mediumvioletred", - "darkcyan", - "maroon", - "darkslategrey", -) - class EvaluationPlotView(ttk.Frame): """A widget for selecting devices.""" @@ -102,7 +88,6 @@ def build(self) -> None: 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") # white diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index 44bbf0c..cb9c2ae 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -13,7 +13,7 @@ from scalewiz.components.evaluation_data_view import EvaluationDataView from scalewiz.components.evaluation_plot_view import EvaluationPlotView -from scalewiz.helpers.export_csv import export_csv +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 @@ -71,6 +71,7 @@ def build(self, reload: bool = False) -> None: # evaluation stuff ---------------------------------------------------- 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" ) @@ -85,7 +86,7 @@ def build(self, reload: bool = False) -> None: export_btn = ttk.Button( button_frame, text="Export", - command=lambda: export_csv(self.editor_project), + command=lambda: export(self.editor_project), width=10, ) export_btn.grid(row=0, column=1, padx=5) @@ -138,7 +139,7 @@ def save(self) -> None: self.after(0, self.handler.rebuild_views) def export(self) -> None: - result, file = export_csv(self.editor_project) + result, file = export(self.editor_project) if result == 0: messagebox.showinfo("Export complete", f"Exported a report to {file}") else: diff --git a/scalewiz/components/handler_view_plot.py b/scalewiz/components/handler_view_plot.py index 2f530a7..343b1a3 100644 --- a/scalewiz/components/handler_view_plot.py +++ b/scalewiz/components/handler_view_plot.py @@ -44,7 +44,7 @@ def build(self) -> None: 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 * 3) + 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: @@ -52,28 +52,25 @@ def animate(self, interval: float) -> None: The interval argument is used by matplotlib internally. """ - - return - # # we can just skip this if the test isn't running - # if self.handler.is_running and not self.handler.is_done: - # if len(self.handler.readings) > 0: - # pump1 = [] - # pump2 = [] - # elapsed = [] # we will share this series as an axis - # for reading in self.handler.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") + 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 + for reading in self.handler.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/helpers/export_csv.py b/scalewiz/helpers/export.py similarity index 94% rename from scalewiz/helpers/export_csv.py rename to scalewiz/helpers/export.py index 29df9c5..6032d09 100644 --- a/scalewiz/helpers/export_csv.py +++ b/scalewiz/helpers/export.py @@ -4,7 +4,6 @@ import json import logging -import time from pathlib import Path from typing import Tuple @@ -15,9 +14,8 @@ LOGGER = logging.getLogger("scalewiz") -def export_csv(project: Project) -> Tuple[int, Path]: +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 = { @@ -34,9 +32,11 @@ def export_csv(project: Project) -> Tuple[int, Path]: "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": [], @@ -85,10 +85,9 @@ def export_csv(project: Project) -> Tuple[int, Path]: 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(): diff --git a/scalewiz/models/project.py b/scalewiz/models/project.py index bf16efc..f1d928f 100644 --- a/scalewiz/models/project.py +++ b/scalewiz/models/project.py @@ -66,18 +66,18 @@ def set_defaults(self) -> None: 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.get("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.""" @@ -171,23 +171,23 @@ def load_json(self, path: str) -> None: ) obj["info"]["path"] = str(path) - info: dict = 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(str(Path(info.get("path")).resolve())) - self.notes.set(info.get("notes")) + 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.get("params") + 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)) @@ -200,15 +200,13 @@ def load_json(self, path: str) -> None: self.flowrate.set(params.get("flowrate", defaults["flowrate"])) self.uptake_seconds.set(params.get("uptake", defaults["uptake_time"])) - self.plot.set(obj.get("plot")) - self.output_format.set(obj.get("outputFormat")) + self.plot.set(obj["plot"]) + self.output_format.set(obj["outputFormat"]) self.tests.clear() - for entry in obj.get("tests"): - test = Test() - test.load_json(entry) + for entry in obj["tests"]: + test = Test(data=entry) self.tests.append(test) - LOGGER.info("finished loading") def remove_traces(self) -> None: """Remove tkVar traces to allow the GC to do its thing.""" diff --git a/scalewiz/models/test.py b/scalewiz/models/test.py index 6f5f8a6..5a62282 100644 --- a/scalewiz/models/test.py +++ b/scalewiz/models/test.py @@ -27,7 +27,7 @@ class 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 @@ -46,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) diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index bd85edc..006a8af 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -100,7 +100,7 @@ def start_test(self) -> None: self.is_done = False self.is_running = True self.rebuild_views() - self.uptake_cycle() + self.pool.submit(self.uptake_cycle) def uptake_cycle(self) -> None: """Get ready to take readings.""" @@ -117,7 +117,7 @@ def uptake_cycle(self) -> None: self.stop_test(save=False) break # we use these in the loop - self.pool.submit(self.take_readings) + self.take_readings() def take_readings(self) -> None: interval = self.project.interval_seconds.get() @@ -142,7 +142,6 @@ def take_readings(self) -> None: self.logger.debug(msg) self.readings.append(reading) self.elapsed_min = minutes_elapsed - self.logger.warn("%s / %s", len(self.readings), self.max_readings) prog = round((len(self.readings) / self.max_readings) * 100) self.progress.set(prog) @@ -152,7 +151,6 @@ def take_readings(self) -> None: self.max_psi_2 = psi2 # TYSM https://stackoverflow.com/a/25251804 - self.logger.warn("%s", interval - ((time() - start_time) % interval)) sleep(interval - ((time() - start_time) % interval)) else: self.stop_test(save=True) @@ -220,24 +218,18 @@ def stop_test(self, save: bool = False, rinsing: bool = False) -> None: if not rinsing: self.is_done = True self.is_running = False - self.logger.warn("Test for %s has been stopped", self.test.name.get()) 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.""" - self.logger.warn("TRYING TO SAVE") self.test.readings.extend(self.readings) - self.logger.warn( - "saved %s readings to %s", len(self.test.readings), self.test.name.get() - ) self.project.tests.append(self.test) self.project.dump_json() - # refresh data / UI self.load_project(path=self.project.path.get(), new_test=False) @@ -248,9 +240,7 @@ def rebuild_views(self) -> None: self.logger.debug("Rebuilding %s", widget) self.root.after_idle(widget.build, {"reload": True}) else: - self.logger.debug( - "Removing dead widget %s", widget - ) # clean up as we go + self.logger.debug("Removing dead widget %s", widget) self.views.remove(widget) self.logger.debug("Rebuilt all view widgets") diff --git a/todo b/todo index b69010b..a1454cc 100644 --- a/todo +++ b/todo @@ -1,28 +1,29 @@ todo ---- -- new screenshots of eval window / plot for docs +- try to clean up export code / VBA import code bugs ---- +- none! I'm pretty sure ... + refactoring ----------- -- we have a dep. on Pandas for one little call in export_csv -- could be worked around +- we have a dep. on Pandas for one little call in export helper - could be worked around updates / new features ---------------------- -- 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 +- none! low prio -------- - port over the old chlorides / ppm calculators - check for config missing keys ? -- color cycle for config / projects -- the score function could be hooked up to poll from a logging queue, perhaps overkill +- 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 From e3c1ddc1691f21025828cdba5465d2bcdc24c2a9 Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Tue, 1 Jun 2021 16:27:40 -0500 Subject: [PATCH 37/49] typo --- scalewiz/components/project_editor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scalewiz/components/project_editor.py b/scalewiz/components/project_editor.py index dbdcc15..c72e1e8 100644 --- a/scalewiz/components/project_editor.py +++ b/scalewiz/components/project_editor.py @@ -87,7 +87,7 @@ def save(self) -> None: self.handler.load_project(self.editor_project.path.get()) self.handler.rebuild_views() else: - messagebox.showwarning("can't save while a test is running") + 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.""" From 8002300224d0c740e4c96e55fbd1a70f237e1b64 Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Tue, 1 Jun 2021 22:08:50 -0500 Subject: [PATCH 38/49] somewhat more elegant multithreading / update docs --- CHANGELOG.rst | 32 +++++++++++----- doc/index.rst | 2 +- scalewiz/components/handler_view_plot.py | 6 ++- scalewiz/models/test.py | 4 +- scalewiz/models/test_handler.py | 47 +++++++++++++++--------- 5 files changed, 59 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a9b37e9..c2788ac 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,4 @@ +========= Changelog ========= @@ -10,27 +11,40 @@ Versioning `_. [v0.5.7] --------------------- +-------- Changed ~~~~~~~ -User experience concerns +User experience +=============== -- overhaul the :code:`TestHandlerView` to be better oragnized and less bad -- overhaul the :code:`EvaluationWindow` to be better oragnized and less bad +- 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 to reflect the above +- updated docs +- ensured exported plot dimensions are always uniform -Coding concerns +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 the :code:`LivePlot` component +- minor performance buff to :code:`Project` serialization + +Data handling +============= + +- :code:`Project` data model now records calcium concentration for brines - updated the :code:`Test` object model to handle the :code:`Reading` class - updated :code:`score` function to handle the :code:`Reading` class - updated the :code:`Project` object model to be more backwards compatible -- ensured exported plot dimensions are always uniform -- minor performance buff to the :code:`LivePlot` component -- minor performance buffs generally +- refactored data analysis out of the :code:`EvaluationWindow` and into its own :code:`score` function + +Misc +==== + - update all :code:`os.path` operations to fancy :code:`pathlib.Path` operations - update all :code:`matplotlib` code to use the object oriented API - lots of misc. code cleanup / reorganizing diff --git a/doc/index.rst b/doc/index.rst index 8f56287..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. diff --git a/scalewiz/components/handler_view_plot.py b/scalewiz/components/handler_view_plot.py index 343b1a3..53e2435 100644 --- a/scalewiz/components/handler_view_plot.py +++ b/scalewiz/components/handler_view_plot.py @@ -53,12 +53,14 @@ def animate(self, interval: float) -> None: 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.readings.qsize() > 0: if self.handler.is_running and not self.handler.is_done: + with self.handler.readings.mutex: + readings = tuple(self.handler.readings.queue) pump1 = [] pump2 = [] elapsed = [] # we will share this series as an axis - for reading in self.handler.readings: + for reading in readings: pump1.append(reading.pump1) pump2.append(reading.pump2) elapsed.append(reading.elapsedMin) diff --git a/scalewiz/models/test.py b/scalewiz/models/test.py index 5a62282..9fd45f3 100644 --- a/scalewiz/models/test.py +++ b/scalewiz/models/test.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import List, Union + from typing import List, Tuple, Union LOGGER = logging.getLogger("scalewiz") @@ -110,7 +110,7 @@ def load_json(self, obj: dict[str, Union[bool, float, int, str]]) -> None: ) 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() pump = pump.replace(" ", "") # legacy accomodation for spaces in keys diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index 006a8af..cb35296 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -7,10 +7,10 @@ from datetime import date from logging import DEBUG, FileHandler, Formatter, getLogger from pathlib import Path -from queue import Queue +from queue import Empty, Queue from time import sleep, time from tkinter import filedialog, messagebox -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from py_hplc import NextGenPump @@ -34,23 +34,22 @@ def __init__(self, name: str = "Nemo") -> None: self.logger: Logger = getLogger(f"scalewiz.{name}") self.project: Project = Project() self.test: Test = None - self.readings: List[Reading] = [] + self.readings: Queue = Queue() 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.limit_minutes: float = None self.log_handler: FileHandler = None # handles logging to log window - # test handler view overwrites this attribute in the view's build() self.log_queue: Queue[str] = Queue() # view pulls from this queue self.dev1 = tk.StringVar() self.dev2 = tk.StringVar() self.stop_requested: bool = bool() self.progress = tk.IntVar() - self.elapsed_min: float = float() # used for evaluations + self.elapsed_min: float = float() # current duration self.pump1: NextGenPump = None self.pump2: NextGenPump = None - self.pool = ThreadPoolExecutor(max_workers=1) + self.pool = ThreadPoolExecutor(max_workers=3) # UI concerns self.views: List[tk.Widget] = [] # list of views displaying the project @@ -64,7 +63,7 @@ def can_run(self) -> bool: return ( (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 self.readings.qsize() < self.max_readings and not self.stop_requested ) @@ -100,7 +99,8 @@ def start_test(self) -> None: self.is_done = False self.is_running = True self.rebuild_views() - self.pool.submit(self.uptake_cycle) + with self.pool as executor: + executor.submit(self.uptake_cycle) def uptake_cycle(self) -> None: """Get ready to take readings.""" @@ -120,29 +120,34 @@ def uptake_cycle(self) -> None: self.take_readings() def take_readings(self) -> None: + def get_pressure(pump: NextGenPump) -> Any: + return pump.pressure + interval = self.project.interval_seconds.get() + timeout = interval / 3 start_time = time() # readings loop ---------------------------------------------------------------- while self.can_run: - minutes_elapsed = (time() - start_time) / 60 + self.elapsed_min = (time() - start_time) / 60 + with self.pool as executor: + psi1 = executor.submit(get_pressure, self.pump1) + psi2 = executor.submit(get_pressure, self.pump2) - psi1 = self.pump1.pressure - psi2 = self.pump2.pressure + psi1, psi2 = psi1.result(timeout=timeout), psi2.result(timeout=timeout) average = round(((psi1 + psi2) / 2)) reading = Reading( - elapsedMin=minutes_elapsed, pump1=psi1, pump2=psi2, average=average + 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.log_queue.put(msg) self.logger.debug(msg) - self.readings.append(reading) - self.elapsed_min = minutes_elapsed - prog = round((len(self.readings) / self.max_readings) * 100) + self.readings.put(reading) + prog = round((self.readings.qsize() / self.max_readings) * 100) self.progress.set(prog) if psi1 > self.max_psi_1: @@ -162,7 +167,6 @@ def new_test(self) -> None: if isinstance(self.test, Test): self.test.remove_traces() self.test = Test() - self.readings.clear() 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 @@ -227,7 +231,14 @@ def stop_test(self, save: bool = False, rinsing: bool = False) -> None: def save_test(self) -> None: """Saves the test to the Project file in JSON format.""" - self.test.readings.extend(self.readings) + while True: + try: + reading = self.readings.get(block=False) + except Empty: + break + else: + self.test.readings.append(reading) + self.project.tests.append(self.test) self.project.dump_json() # refresh data / UI From 0d76e0ac5fd214e1760900f09f75bf9e8dd9b6b9 Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Tue, 1 Jun 2021 22:13:29 -0500 Subject: [PATCH 39/49] docs --- CHANGELOG.rst | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c2788ac..2c19616 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,6 +13,11 @@ Versioning `_. [v0.5.7] -------- +Added +~~~~~ + +- the :code:`Project` data model now records calcium concentration + Changed ~~~~~~~ @@ -26,7 +31,7 @@ User experience - 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 @@ -36,11 +41,10 @@ Performance Data handling ============= -- :code:`Project` data model now records calcium concentration for brines - updated the :code:`Test` object model to handle the :code:`Reading` class -- updated :code:`score` function 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 ==== From 6e569d6f636aef2b6e7056351528b4e5c1eb6850 Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Tue, 1 Jun 2021 22:17:08 -0500 Subject: [PATCH 40/49] docs --- README.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 899f30f..b0516ac 100644 --- a/README.rst +++ b/README.rst @@ -11,7 +11,7 @@ If you are working with Teledyne SSI Next Generation pumps generally, please che 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`_. -.. 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 @@ -29,6 +29,10 @@ Usage python -m scalewiz +Or, if Python is on your PATH, simply :: + + scalewiz + Further instructions can be viewed in the `docs`_ section of this repo or with the Help button in the main menu. From 98141a04e7e147d25d0c25f1fa7c222fb435426c Mon Sep 17 00:00:00 2001 From: teauxfu Date: Tue, 1 Jun 2021 22:29:16 -0500 Subject: [PATCH 41/49] Update README.rst --- README.rst | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index b0516ac..fb60c71 100644 --- a/README.rst +++ b/README.rst @@ -21,6 +21,10 @@ Installation :: python -m pip install --user scalewiz + +Or, if you use :code:`pipx` (`try it!`_ 😉) :: + + pipx install scalewiz Usage ===== @@ -29,10 +33,11 @@ Usage python -m scalewiz -Or, if Python is on your PATH, simply :: +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. @@ -74,3 +79,5 @@ 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/ + From 1dc4cea311a0ca3e248d28e0e049c4a805dc0277 Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Tue, 1 Jun 2021 23:00:46 -0500 Subject: [PATCH 42/49] cleaning / add sample --- sample.py | 39 ++++++++ scalewiz/models/test_handler.py | 155 ++++++++++++++++---------------- 2 files changed, 115 insertions(+), 79 deletions(-) create mode 100644 sample.py diff --git a/sample.py b/sample.py new file mode 100644 index 0000000..7849b60 --- /dev/null +++ b/sample.py @@ -0,0 +1,39 @@ +import tkinter as tk +from time import time + + +class App(tk.Frame): + def __init__(self, parent) -> None: + super().__init__(parent) + + self.count = tk.IntVar() + self.delay = tk.DoubleVar() + tk.Label(self, textvariable=self.delay).pack(padx=20, pady=20) + tk.Label(self, textvariable=self.count).pack(padx=20, pady=20) + self.cycle(1000) + + def cycle(self, interval_ms: int, start=None, count=0) -> None: + print("count is", count) + if start is None: + start = time() + if count < 100: + self.count.set(count) + x = [i for i in range(1 * 10 ** 6)] + [i + 1 for i in x] + [i * 2 for i in x] + # this is approximate + self.delay.set(interval_ms - (((time() - start) * 1000) % interval_ms)) + print("delay is", self.delay.get()) + self.after( + round(interval_ms - (((time() - start) * 1000) % interval_ms)), + self.cycle, + interval_ms, + start, + count + 1, + ) + + +if __name__ == "__main__": + root = tk.Tk() + App(root).pack() + root.mainloop() diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index cb35296..07251f9 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -67,6 +67,22 @@ def can_run(self) -> bool: and not self.stop_requested ) + 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.""" issues = [] @@ -86,10 +102,9 @@ def start_test(self) -> None: msg = "Water clarity cannot be blank" issues.append(msg) + # these methods will append issue messages if any occur self.update_log_handler(issues) - - # this method will append issue msgs if any occur - self.setup_pumps(issues) # hooray for pointers + 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): @@ -116,8 +131,7 @@ def uptake_cycle(self) -> None: else: self.stop_test(save=False) break - # we use these in the loop - self.take_readings() + self.take_readings() # still in the Future's thread def take_readings(self) -> None: def get_pressure(pump: NextGenPump) -> Any: @@ -160,49 +174,6 @@ def get_pressure(pump: NextGenPump) -> Any: else: self.stop_test(save=True) - # logging stuff / methods that affect UI - 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 setup_pumps(self, issues: List[str] = None) -> None: - """Set up the pumps with some default values. - Appends errors to the passed list - """ - if issues is None: - issues = [] - - if self.dev1.get() in ("", "None found"): - issues.append("Select a port for pump 1") - - if self.dev2.get() in ("", "None found"): - issues.append("Select a port for pump 2") - - if self.dev1.get() == self.dev2.get(): - issues.append("Select two unique ports") - else: - self.pump1 = NextGenPump(self.dev1.get(), self.logger) - self.pump2 = NextGenPump(self.dev2.get(), self.logger) - - 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 flowrates to %s", pump.flowrate) - def request_stop(self) -> None: """Requests that the Test stop.""" if self.is_running: @@ -244,40 +215,31 @@ def save_test(self) -> None: # refresh data / UI self.load_project(path=self.project.path.get(), new_test=False) - def rebuild_views(self) -> None: - """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) - self.root.after_idle(widget.build, {"reload": True}) - else: - self.logger.debug("Removing dead widget %s", widget) - self.views.remove(widget) + def setup_pumps(self, issues: List[str] = None) -> None: + """Set up the pumps with some default values. + Appends errors to the passed list + """ + if issues is None: + issues = [] - self.logger.debug("Rebuilt all view widgets") + if self.dev1.get() in ("", "None found"): + issues.append("Select a port for pump 1") - 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) + if self.dev2.get() in ("", "None found"): + issues.append("Select a port for pump 2") - 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(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()) + if self.dev1.get() == self.dev2.get(): + issues.append("Select two unique ports") + else: + self.pump1 = NextGenPump(self.dev1.get(), self.logger) + self.pump2 = NextGenPump(self.dev2.get(), self.logger) + + 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 flowrates to %s", pump.flowrate) def load_project( self, @@ -312,3 +274,38 @@ def load_project( 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 display or modify the Project file.""" + for widget in self.views: + if widget.winfo_exists(): + self.logger.debug("Rebuilding %s", widget) + self.root.after_idle(widget.build, {"reload": True}) + else: + self.logger.debug("Removing dead widget %s", widget) + self.views.remove(widget) + + self.logger.debug("Rebuilt all view widgets") + + 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(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()) From 6c4309f70736a86da43ef0ca45ecf7c6c8cc4823 Mon Sep 17 00:00:00 2001 From: teauxfu Date: Wed, 2 Jun 2021 09:32:26 -0500 Subject: [PATCH 43/49] get pressures asynchronously --- scalewiz/components/scalewiz_rinse_window.py | 8 ++++---- scalewiz/models/test_handler.py | 17 +++++++---------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/scalewiz/components/scalewiz_rinse_window.py b/scalewiz/components/scalewiz_rinse_window.py index 43c6a34..21f8dd5 100644 --- a/scalewiz/components/scalewiz_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 @@ -41,7 +41,7 @@ def __init__(self, handler: TestHandler) -> None: 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: @@ -51,11 +51,11 @@ 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.button.configure(text=f"{i+1}/{duration} s") - time.sleep(1) + sleep(1) else: break self.bell() diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index 07251f9..f7d0c6a 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -114,8 +114,7 @@ def start_test(self) -> None: self.is_done = False self.is_running = True self.rebuild_views() - with self.pool as executor: - executor.submit(self.uptake_cycle) + self.pool.submit(self.uptake_cycle) def uptake_cycle(self) -> None: """Get ready to take readings.""" @@ -138,16 +137,13 @@ def get_pressure(pump: NextGenPump) -> Any: return pump.pressure interval = self.project.interval_seconds.get() - timeout = interval / 3 start_time = time() # readings loop ---------------------------------------------------------------- while self.can_run: self.elapsed_min = (time() - start_time) / 60 - with self.pool as executor: - psi1 = executor.submit(get_pressure, self.pump1) - psi2 = executor.submit(get_pressure, self.pump2) - - psi1, psi2 = psi1.result(timeout=timeout), psi2.result(timeout=timeout) + psi1 = self.pool.submit(get_pressure, self.pump1) + psi2 = self.pool.submit(get_pressure, self.pump2) + psi1, psi2 = psi1.result(), psi2.result() average = round(((psi1 + psi2) / 2)) reading = Reading( @@ -234,12 +230,13 @@ 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 flowrates to %s", pump.flowrate) + pump.flowrate = flowrate + self.logger.info("Set flowrates to %s", pump.flowrate) def load_project( self, From aa3cb53058df9048558cc29f84f96a75908f6dde Mon Sep 17 00:00:00 2001 From: Alex Whittington Date: Wed, 2 Jun 2021 09:46:05 -0500 Subject: [PATCH 44/49] try after again --- scalewiz/models/test_handler.py | 4 +- scalewiz/models/test_handler2.py | 323 +++++++++++++++++++++++++++++++ 2 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 scalewiz/models/test_handler2.py diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index f7d0c6a..c72223a 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -10,7 +10,7 @@ from queue import Empty, Queue from time import sleep, time from tkinter import filedialog, messagebox -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from py_hplc import NextGenPump @@ -133,7 +133,7 @@ def uptake_cycle(self) -> None: self.take_readings() # still in the Future's thread def take_readings(self) -> None: - def get_pressure(pump: NextGenPump) -> Any: + def get_pressure(pump: NextGenPump) -> Union[float, int]: return pump.pressure interval = self.project.interval_seconds.get() diff --git a/scalewiz/models/test_handler2.py b/scalewiz/models/test_handler2.py new file mode 100644 index 0000000..350f392 --- /dev/null +++ b/scalewiz/models/test_handler2.py @@ -0,0 +1,323 @@ +"""Handles a test.""" + +from __future__ import annotations + +import tkinter as tk +from concurrent.futures import ThreadPoolExecutor +from datetime import date +from logging import DEBUG, FileHandler, Formatter, getLogger +from pathlib import Path +from queue import Empty, Queue +from time import 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 Reading, Test + +if TYPE_CHECKING: + from logging import Logger + from typing import List, Set, Union + + +class TestHandler: + """Handles a Test.""" + + # pylint: disable=too-many-instance-attributes + + def __init__(self, name: str = "Nemo") -> None: + self.name = name + self.root: tk.Tk = scalewiz.ROOT + self.logger: Logger = getLogger(f"scalewiz.{name}") + self.project: Project = Project() + self.test: Test = None + self.readings: Queue = Queue() + 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.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: bool = bool() + self.progress = tk.IntVar() + self.elapsed_min: float = float() # current duration + self.pump1: NextGenPump = None + self.pump2: NextGenPump = None + self.pool = ThreadPoolExecutor(max_workers=3) + + # UI concerns + 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.limit_psi or self.max_psi_2 < self.limit_psi) + and self.elapsed_min < self.limit_minutes + and self.readings.qsize() < self.max_readings + and not self.stop_requested + ) + + 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.""" + issues = [] + if not Path(self.project.path.get()).is_file(): + msg = "Select an existing project file first" + issues.append(msg) + + if self.test.name.get() == "": + 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) + + # 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 = False + self.is_done = False + self.is_running = True + self.rebuild_views() + self.uptake_cycle(self.project.uptake_seconds.get() * 1000) + + def uptake_cycle(self, duration_ms: int) -> None: + """Get ready to take readings.""" + # run the uptake cycle --------------------------------------------------------- + ms_step = round((duration_ms / 100)) # we will sleep for 100 steps + self.pump1.run() + self.pump2.run() + + def cycle(start, i, step_ms) -> None: + if self.can_run: + if i < 100: + i += 1 + self.progress.set(i) + self.root.after( + round(step_ms - (((time() - start) * 1000) % step_ms)), + cycle, + start, + i, + step_ms, + ) + else: + self.take_readings() + else: + self.stop_test(save=False) + + cycle(time(), 0, ms_step) + + def take_readings(self, start_time: float = None, interval: float = None) -> None: + if start_time is None: + start_time = time() + if interval is None: + interval = self.project.interval_seconds.get() * 1000 + # readings loop ---------------------------------------------------------------- + if self.can_run: + self.elapsed_min = round((time() - start_time) / 60, 2) + + psi1, psi2 = self.pump1.pressure, self.pump2.pressure + average = round(((psi1 + psi2) / 2)) + + 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( + self.elapsed_min, psi1, psi2, average + ) + self.log_queue.put(msg) + self.logger.debug(msg) + self.readings.put(reading) + prog = round((self.readings.qsize() / self.max_readings) * 100) + self.progress.set(prog) + + if psi1 > self.max_psi_1: + self.max_psi_1 = psi1 + if psi2 > self.max_psi_2: + self.max_psi_2 = psi2 + + # TYSM https://stackoverflow.com/a/25251804 + self.root.after( + round(interval - (((time() - start_time) * 1000) % interval)), + self.take_readings, + start_time, + interval, + ) + else: + # end of readings loop ----------------------------------------------------- + self.logger.warn("about to request saving") + self.stop_test(save=True) + + def request_stop(self) -> None: + """Requests that the Test stop.""" + if self.is_running: + self.stop_requested = True + + 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, + ) + + 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.""" + while True: + try: + reading = self.readings.get(block=False) + except Empty: + break + else: + self.test.readings.append(reading) + + self.project.tests.append(self.test) + self.project.dump_json() + # refresh data / UI + 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. + Appends errors to the passed list + """ + if issues is None: + issues = [] + + if self.dev1.get() in ("", "None found"): + issues.append("Select a port for pump 1") + + if self.dev2.get() in ("", "None found"): + issues.append("Select a port for pump 2") + + if self.dev1.get() == self.dev2.get(): + issues.append("Select two unique ports") + else: + 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 = flowrate + self.logger.info("Set flowrates to %s", pump.flowrate) + + 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. + + `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 display or modify the Project file.""" + for widget in self.views: + if widget.winfo_exists(): + self.logger.debug("Rebuilding %s", widget) + self.root.after_idle(widget.build, {"reload": True}) + else: + self.logger.debug("Removing dead widget %s", widget) + self.views.remove(widget) + + self.logger.debug("Rebuilt all view widgets") + + 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(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()) From 74a8340285748528cc2944c22db7c2ac11587175 Mon Sep 17 00:00:00 2001 From: teauxfu Date: Wed, 2 Jun 2021 14:12:48 -0500 Subject: [PATCH 45/49] cleaning --- sample.py | 5 +++++ scalewiz/models/project.py | 2 +- scalewiz/models/test_handler.py | 4 +--- scalewiz/models/test_handler2.py | 19 ++++++++++++++----- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/sample.py b/sample.py index 7849b60..c5ca834 100644 --- a/sample.py +++ b/sample.py @@ -8,6 +8,11 @@ def __init__(self, parent) -> None: self.count = tk.IntVar() self.delay = tk.DoubleVar() + self.text = tk.StringVar() + + for variable in (self.count, self.delay, self.text): + variable.set(None) + tk.Label(self, textvariable=self.delay).pack(padx=20, pady=20) tk.Label(self, textvariable=self.count).pack(padx=20, pady=20) self.cycle(1000) diff --git a/scalewiz/models/project.py b/scalewiz/models/project.py index f1d928f..a758718 100644 --- a/scalewiz/models/project.py +++ b/scalewiz/models/project.py @@ -199,9 +199,9 @@ def load_json(self, path: str) -> None: 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["outputFormat"]) self.plot.set(obj["plot"]) - self.output_format.set(obj["outputFormat"]) self.tests.clear() for entry in obj["tests"]: diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index c72223a..6c66e3b 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -145,18 +145,16 @@ def get_pressure(pump: NextGenPump) -> Union[float, int]: psi2 = self.pool.submit(get_pressure, self.pump2) psi1, psi2 = psi1.result(), psi2.result() average = round(((psi1 + psi2) / 2)) - 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( self.elapsed_min, psi1, psi2, average ) + self.readings.put(reading) self.log_queue.put(msg) self.logger.debug(msg) - self.readings.put(reading) prog = round((self.readings.qsize() / self.max_readings) * 100) self.progress.set(prog) diff --git a/scalewiz/models/test_handler2.py b/scalewiz/models/test_handler2.py index 350f392..df64498 100644 --- a/scalewiz/models/test_handler2.py +++ b/scalewiz/models/test_handler2.py @@ -1,4 +1,7 @@ -"""Handles a test.""" +"""Handles a test. Experimental / not currently used. + +Readings are collected using a combination of multithreading and tk.after calls. +""" from __future__ import annotations @@ -23,6 +26,10 @@ from typing import List, Set, Union +def get_pressure(pump: NextGenPump) -> Union[float, int]: + return pump.pressure + + class TestHandler: """Handles a Test.""" @@ -122,6 +129,7 @@ def uptake_cycle(self, duration_ms: int) -> None: ms_step = round((duration_ms / 100)) # we will sleep for 100 steps self.pump1.run() self.pump2.run() + print("starting rinse for", duration_ms, "with 100 steps of", ms_step) def cycle(start, i, step_ms) -> None: if self.can_run: @@ -136,7 +144,7 @@ def cycle(start, i, step_ms) -> None: step_ms, ) else: - self.take_readings() + self.pool.submit(self.take_readings) else: self.stop_test(save=False) @@ -149,9 +157,12 @@ def take_readings(self, start_time: float = None, interval: float = None) -> Non interval = self.project.interval_seconds.get() * 1000 # readings loop ---------------------------------------------------------------- if self.can_run: + self.elapsed_min = round((time() - start_time) / 60, 2) - psi1, psi2 = self.pump1.pressure, self.pump2.pressure + psi1 = self.pool.submit(get_pressure, self.pump1) + psi2 = self.pool.submit(get_pressure, self.pump2) + psi1, psi2 = psi1.result(), psi2.result() average = round(((psi1 + psi2) / 2)) reading = Reading( @@ -172,7 +183,6 @@ def take_readings(self, start_time: float = None, interval: float = None) -> Non self.max_psi_1 = psi1 if psi2 > self.max_psi_2: self.max_psi_2 = psi2 - # TYSM https://stackoverflow.com/a/25251804 self.root.after( round(interval - (((time() - start_time) * 1000) % interval)), @@ -182,7 +192,6 @@ def take_readings(self, start_time: float = None, interval: float = None) -> Non ) else: # end of readings loop ----------------------------------------------------- - self.logger.warn("about to request saving") self.stop_test(save=True) def request_stop(self) -> None: From 15bbbaa6132dfae8383767bf2abeed600de431c6 Mon Sep 17 00:00:00 2001 From: teauxfu Date: Wed, 2 Jun 2021 15:09:22 -0500 Subject: [PATCH 46/49] . --- scalewiz/components/handler_view_controls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scalewiz/components/handler_view_controls.py b/scalewiz/components/handler_view_controls.py index 4e02054..eea5a06 100644 --- a/scalewiz/components/handler_view_controls.py +++ b/scalewiz/components/handler_view_controls.py @@ -65,7 +65,7 @@ def poll_log_queue(self) -> None: pass else: self.display(record) - self.after(self.interval, self.poll_log_queue) + self.after_idle(self.poll_log_queue) def display(self, msg: str) -> None: """Displays a message in the log.""" From ce4df98f6ce90b52cc765060ffb4c574c3257d4e Mon Sep 17 00:00:00 2001 From: teauxfu Date: Wed, 2 Jun 2021 15:55:17 -0500 Subject: [PATCH 47/49] tighter log polling --- scalewiz/components/handler_view_controls.py | 15 ++++++++------- scalewiz/components/scalewiz_log_window.py | 13 +++++++------ scalewiz/models/project.py | 2 +- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/scalewiz/components/handler_view_controls.py b/scalewiz/components/handler_view_controls.py index eea5a06..102060e 100644 --- a/scalewiz/components/handler_view_controls.py +++ b/scalewiz/components/handler_view_controls.py @@ -59,13 +59,14 @@ def build(self) -> None: def poll_log_queue(self) -> None: """Checks on an interval if there is a new message in the queue to display.""" - try: - record = self.handler.log_queue.get(block=False) - except Empty: - pass - else: - self.display(record) - self.after_idle(self.poll_log_queue) + 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.""" diff --git a/scalewiz/components/scalewiz_log_window.py b/scalewiz/components/scalewiz_log_window.py index 2118c82..6fed5f4 100644 --- a/scalewiz/components/scalewiz_log_window.py +++ b/scalewiz/components/scalewiz_log_window.py @@ -46,12 +46,13 @@ def build(self) -> None: def poll_log_queue(self) -> None: """Checks every 100ms if there is a new message in the queue to display.""" - try: - record = self.log_queue.get(block=False) - except Empty: - pass - else: - self.display(record) + while True: + try: + record = self.log_queue.get(block=False) + except Empty: + break + else: + self.display(record) self.after(100, self.poll_log_queue) def display(self, record: LogRecord) -> None: diff --git a/scalewiz/models/project.py b/scalewiz/models/project.py index a758718..a9fdafa 100644 --- a/scalewiz/models/project.py +++ b/scalewiz/models/project.py @@ -199,7 +199,7 @@ def load_json(self, path: str) -> None: 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["outputFormat"]) + self.output_format.set(obj.get("outputFormat", defaults["output_format"])) self.plot.set(obj["plot"]) From 612b81ed8cbc0147e8b74219bf03e565e05f4304 Mon Sep 17 00:00:00 2001 From: teauxfu Date: Tue, 20 Jul 2021 10:40:14 -0500 Subject: [PATCH 48/49] pullup (#29) * update to 0.5.7 (#22) [v0.5.7] Changed User experience overhaul the TestHandlerView to be better oragnized overhaul the EvaluationWindow to be better oragnized setting labels for each Test is now handled in the EvaluationWindows' "Plot" tab updated docs ensured exported plot dimensions are always uniform Performance updated the TestHandler to poll for readings asynchronously updated the TestHandler to be more robust when generating log files minor performance buff to the LivePlot component minor performance buff to Project serialization minor performance buff to reading user configuration file Data handling the Project data model now records calcium concentration updated the Test object model to handle the Reading class updated the Project object model to be more backwards compatible refactored data analysis out of the EvaluationWindow and into its own score function updated score function to handle the Reading class Misc update all os.path operations to fancy :code:pathlib.Path operations update all 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 * Update CHANGELOG.rst * Update evaluation_window.py * Update score.py * Update CHANGELOG.rst * Update pyproject.toml * Update pyproject.toml * Create ARCHITECTURE.rst * Update ARCHITECTURE.rst * Add files via upload * Update ARCHITECTURE.rst * Update README.rst --- ARCHITECTURE.rst | 87 +++++++++++++++++++++ CHANGELOG.rst | 14 ++++ README.rst | 16 +++- img/architecture.png | Bin 0 -> 50355 bytes pyproject.toml | 4 +- scalewiz/components/evaluation_window.py | 11 ++- scalewiz/components/project_editor.py | 27 ++++--- scalewiz/components/scalewiz.py | 5 +- scalewiz/components/scalewiz_log_window.py | 2 +- scalewiz/components/scalewiz_menu_bar.py | 13 +++ scalewiz/helpers/export.py | 9 ++- scalewiz/helpers/score.py | 1 + scalewiz/models/test_handler.py | 25 +++++- todo | 2 + 14 files changed, 192 insertions(+), 24 deletions(-) create mode 100644 ARCHITECTURE.rst create mode 100644 img/architecture.png diff --git a/ARCHITECTURE.rst b/ARCHITECTURE.rst new file mode 100644 index 0000000..58762aa --- /dev/null +++ b/ARCHITECTURE.rst @@ -0,0 +1,87 @@ +This is a general mapping of the code / code flow + + +.. image:: https://github.com/teauxfu/scalewiz/blob/main/img/architecture.png + :alt: code graph + + +:: + + models/ + data models, dict-like collections of tkinter variables that can serialize themselves as JSON + ├── project.py + │ organizes a collection of Tests with some metadata + ├── test.py + │ organizes a collection of readings for a Test with some metadata + ╰── test_handler.py + not really a 'model' nor a 'component' - collects readings over serial, sticks them in a Test in a Project + + components/ + custom tkinter widgets bundled with a minimum of business logic + ├── scalewiz_log_window.py + │ a tkinter ScrolledText that trampolines on the mainloop to poll logging messages from a Queue + ├── scalewiz_rinse_window.py + │ a small toplevel that can run the pumps for a user-defined duration + ├── scalewiz.py + │ core object of the app, used for setting up logging and ttk styles + │ ╰── scalewiz_main_frame.py + │ the main frame of the application, holds a notebook widget + │ ├── handler_view.py + │ │ represents a tab within the main frame's notebook + │ │ ├── handler_view_devices_entry.py + │ │ │ widget for comboboxes, can poll for COM/serial port devices + │ │ ├── handler_view_info_entry.py + │ │ │ widget for user entry of Test metadata + │ │ ├── handler_view_controls.py + │ │ │ widget that holds the progess bar, readings log, and start/stop buttons + │ │ ╰── handler_view_plot.py + │ │ widget that displays an animated matplotlib plot of the data collected for a running Test + │ ╰── scalewiz_menu_bar.py + │ defines the menu bar that gets loaded on to the main menu + ├── project_editor.py + │ toplevel for making/mutating Projects + │ ├── project_editor_info.py + │ │ form for metadata + │ ├── project_editor_params.py + │ │ form for experiment parameters -- affects how Tests are run and scored + │ ╰── project_editor_report.py + │ form for setting exported report preferences + ╰── evaluation_window.py + toplevel for displaying a Project summary with a notebook widget + ├── evaluation_data_view.py + │ frame that displays a table-like view of data in a Project, giving each Test a row + ╰── evaluation_plot_view.py + frame that uses matplotlib to plot a selection of data + + helpers/ + helper functions that didn't fit elsewhere + ├── configuration.py + │ handles read/writing a config TOML file + ├── score.py + │ modifies a Project by calculating and assigning a score for each Test, optionally sending a log to a text widget + ├── export.py + │ handles exporting a summary of a Project to an output (JSON, CSV, etc.) + ├── show_help.py + │ opens a link to the documentation in a browser window + ├── sort_nicely.py + │ does some pleasant sorting -- used when sorting Tests within a Project + ├── validation.py + │ some functions used for validation in entry widgets + ├── set_icon.py + │ sets the icon of a toplevel widget + ╰── get_resource.py + fetches a file + + main thread -- tkinter mainloop, performs UI updates + can spawn an arbitrary number of TestHandlers/RinseWindows, each with child threads as follows + ├── TestHandler's data collection thread -- alive only while a Test is running + │ collects readings on a blocking loop + │ ╰── 2 data collection threads + │ one for each pump -- performs a quick (~30ms) I/O and returns + ├── RinseWindow's thread + │ the rinse window can spawn a thread IFF the TestHandler isn't running a Test + ╰── ... + + + + diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2c19616..c9100d3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,14 @@ adheres to `Semantic Versioning `_. +[v0.5.8] +-------- + +Hotfix +====== + +- plot figure saving fixed + [v0.5.7] -------- @@ -35,15 +43,20 @@ 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 +- calculations log is a bit more verbose now - updated :code:`score` function to handle the :code:`Reading` class Misc @@ -51,6 +64,7 @@ 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 diff --git a/README.rst b/README.rst index fb60c71..b7882d3 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,6 @@ -======================================================================== -scalewiz |license| |python| |pypi| |build-status| |style| |code quality| -======================================================================== +=========================================================================================== +scalewiz |license| |python| |pypi| |build-status| |style| |code quality| |maintainability| +=========================================================================================== A graphical user interface designed to work with `Teledyne SSI MX-class HPLC pumps`_ for the purpose of calcite scale inhibitor chemical @@ -26,6 +26,10 @@ Or, if you use :code:`pipx` (`try it!`_ 😉) :: pipx install scalewiz +Or, if you use :code:`pipx` (`try it!`_ 😉) :: + + pipx install scalewiz + Usage ===== @@ -72,6 +76,11 @@ Acknowledgements .. |code quality| image:: https://img.shields.io/badge/code%20quality-flake8-black :target: https://gitlab.com/pycqa/flake8 :alt: Code quality + +.. |maintainability| image:: https://api.codeclimate.com/v1/badges/9f4d424afac626a8b2e3/maintainability + :target: https://codeclimate.com/github/teauxfu/scalewiz/maintainability + :alt: Maintainability + .. _`Premier Chemical Technologies, LLC`: https://premierchemical.tech .. _`@balacla`: https://github.com/balacla @@ -80,4 +89,3 @@ Acknowledgements .. _`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/img/architecture.png b/img/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..ffe5c9b722e43a08e2c93d594d6095719e5318a5 GIT binary patch literal 50355 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 ln8XaHDkpk55dUp1NuSKV{;pa3M!yemqyJ$` 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 NGRn;O^hmq){|A#XFBAX( literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml index 99f6089..953a4bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,9 @@ [tool.poetry] name = "scalewiz" -version = "0.5.7" +version = "0.5.8" 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" +license = "GPL-3.0" authors = ["Alex Whittington "] packages = [ {include = "scalewiz"} diff --git a/scalewiz/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index cb9c2ae..a90d211 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -81,7 +81,14 @@ def build(self, reload: bool = False) -> None: # finished adding to tab control button_frame = ttk.Frame(self) - save_btn = ttk.Button(button_frame, text="Save", command=self.save, width=10) + 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 + ) + save_btn.grid(row=0, column=0, padx=5) export_btn = ttk.Button( button_frame, @@ -120,7 +127,7 @@ def save(self) -> None: ) 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.plot_view.fig.savefig(str(plot_output)) self.editor_project.plot.set(str(plot_output)) # update log log_output = ( diff --git a/scalewiz/components/project_editor.py b/scalewiz/components/project_editor.py index c72e1e8..cd8414e 100644 --- a/scalewiz/components/project_editor.py +++ b/scalewiz/components/project_editor.py @@ -58,15 +58,22 @@ 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) @@ -79,6 +86,8 @@ def new(self) -> None: def save(self) -> None: """Save the current Project to file as JSON.""" + # 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() diff --git a/scalewiz/components/scalewiz.py b/scalewiz/components/scalewiz.py index b0e7fb0..07b9d2c 100644 --- a/scalewiz/components/scalewiz.py +++ b/scalewiz/components/scalewiz.py @@ -38,12 +38,15 @@ def __init__(self, parent) -> None: # 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" # fmt = ( # "%(asctime)s - %(func)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, diff --git a/scalewiz/components/scalewiz_log_window.py b/scalewiz/components/scalewiz_log_window.py index 6fed5f4..298c4ce 100644 --- a/scalewiz/components/scalewiz_log_window.py +++ b/scalewiz/components/scalewiz_log_window.py @@ -22,7 +22,7 @@ class LogWindow(tk.Toplevel): """A Toplevel with a ScrolledText. Displays messages from a Logger.""" def __init__(self, core: ScaleWiz) -> None: - tk.Toplevel.__init__(self) + super().__init__() self.log_queue = core.log_queue self.title("Log Window") # replace the window closing behavior with withdrawing instead 🐱‍👤 diff --git a/scalewiz/components/scalewiz_menu_bar.py b/scalewiz/components/scalewiz_menu_bar.py index bcc374a..9bc3e96 100644 --- a/scalewiz/components/scalewiz_menu_bar.py +++ b/scalewiz/components/scalewiz_menu_bar.py @@ -5,6 +5,9 @@ import logging import tkinter as tk from pathlib import Path + +# from time import time + from tkinter.messagebox import showinfo from typing import TYPE_CHECKING @@ -45,6 +48,7 @@ def __init__(self, parent: MainFrame) -> None: 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 @@ -98,7 +102,16 @@ def about(self) -> None: def _debug(self) -> None: """Used for debugging.""" 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/helpers/export.py b/scalewiz/helpers/export.py index 6032d09..94331d7 100644 --- a/scalewiz/helpers/export.py +++ b/scalewiz/helpers/export.py @@ -48,16 +48,17 @@ def export(project: Project) -> Tuple[int, Path]: "plotPath": project.plot.get(), } # filter the blanks and trials to sort them - blanks = { + blanks = [ test for test in project.tests if test.include_on_report.get() and test.is_blank.get() - } - trials = { + ] + trials = [ test for test in project.tests 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] diff --git a/scalewiz/helpers/score.py b/scalewiz/helpers/score.py index 0f958f0..d6c6d41 100644 --- a/scalewiz/helpers/score.py +++ b/scalewiz/helpers/score.py @@ -100,6 +100,7 @@ def score(project: Project, log_widget: ScrolledText = None, *args) -> None: 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): diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index 6c66e3b..f0c03b8 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -7,7 +7,9 @@ from datetime import date from logging import DEBUG, FileHandler, Formatter, getLogger from pathlib import Path + from queue import Empty, Queue + from time import sleep, time from tkinter import filedialog, messagebox from typing import TYPE_CHECKING @@ -34,6 +36,7 @@ def __init__(self, name: str = "Nemo") -> None: self.logger: Logger = getLogger(f"scalewiz.{name}") self.project: Project = Project() self.test: Test = None + self.readings: Queue = Queue() self.max_readings: int = None # max # of readings to collect self.limit_psi: int = None @@ -63,7 +66,9 @@ def can_run(self) -> bool: return ( (self.max_psi_1 < self.limit_psi or self.max_psi_2 < self.limit_psi) and self.elapsed_min < self.limit_minutes + and self.readings.qsize() < self.max_readings + and not self.stop_requested ) @@ -118,7 +123,6 @@ def start_test(self) -> None: def uptake_cycle(self) -> None: """Get ready to take readings.""" - uptake = self.project.uptake_seconds.get() step = uptake / 100 # we will sleep for 100 steps self.pump1.run() self.pump2.run() @@ -133,7 +137,15 @@ def uptake_cycle(self) -> None: 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() @@ -141,9 +153,13 @@ def get_pressure(pump: NextGenPump) -> Union[float, int]: # readings loop ---------------------------------------------------------------- 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 = Reading( elapsedMin=self.elapsed_min, pump1=psi1, pump2=psi2, average=average @@ -152,6 +168,7 @@ def get_pressure(pump: NextGenPump) -> Union[float, int]: msg = "@ {:.2f} min; pump1: {}, pump2: {}, avg: {}".format( self.elapsed_min, psi1, psi2, average ) + self.readings.put(reading) self.log_queue.put(msg) self.logger.debug(msg) @@ -166,6 +183,7 @@ def get_pressure(pump: NextGenPump) -> Union[float, int]: # TYSM https://stackoverflow.com/a/25251804 sleep(interval - ((time() - start_time) % interval)) else: + self.stop_test(save=True) def request_stop(self) -> None: @@ -175,6 +193,7 @@ def request_stop(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() @@ -196,6 +215,9 @@ def stop_test(self, save: bool = False, rinsing: bool = False) -> None: def save_test(self) -> None: """Saves the test to the Project file in JSON format.""" + self.logger.info( + "Saving %s to %s", self.test.name.get(), self.project.name.get() + ) while True: try: reading = self.readings.get(block=False) @@ -244,6 +266,7 @@ def load_project( ) -> None: """Opens a file dialog then loads the selected Project file. + `loaded` gets built from scratch every time it is passed in -- no need to update """ if path is None: diff --git a/todo b/todo index a1454cc..855298d 100644 --- a/todo +++ b/todo @@ -1,6 +1,8 @@ todo ---- +- try to clean up export code / add export confirmation dialog +- handle a queue of changes to a project more gracefully - try to clean up export code / VBA import code bugs From 96b80d0656c17491060812f4e4d10242ba3c3237 Mon Sep 17 00:00:00 2001 From: teauxfu Date: Tue, 20 Jul 2021 10:42:21 -0500 Subject: [PATCH 49/49] Threading dev2 (#30) * update to 0.5.7 (#22) [v0.5.7] Changed User experience overhaul the TestHandlerView to be better oragnized overhaul the EvaluationWindow to be better oragnized setting labels for each Test is now handled in the EvaluationWindows' "Plot" tab updated docs ensured exported plot dimensions are always uniform Performance updated the TestHandler to poll for readings asynchronously updated the TestHandler to be more robust when generating log files minor performance buff to the LivePlot component minor performance buff to Project serialization minor performance buff to reading user configuration file Data handling the Project data model now records calcium concentration updated the Test object model to handle the Reading class updated the Project object model to be more backwards compatible refactored data analysis out of the EvaluationWindow and into its own score function updated score function to handle the Reading class Misc update all os.path operations to fancy :code:pathlib.Path operations update all 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 * Update CHANGELOG.rst * Update evaluation_window.py * Update score.py * Update CHANGELOG.rst * Update pyproject.toml * Update pyproject.toml * Create ARCHITECTURE.rst * Update ARCHITECTURE.rst * Add files via upload * Update ARCHITECTURE.rst * Update README.rst