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 0000000..ffe5c9b Binary files /dev/null and b/img/architecture.png differ 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