diff --git a/CHANGELOG.rst b/CHANGELOG.rst index afff24e..1c080e2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -21,6 +21,11 @@ Hotfix [v0.5.7] -------- +Added +~~~~~ + +- the :code:`Project` data model now records calcium concentration + Changed ~~~~~~~ diff --git a/README.rst b/README.rst index c5499d9..a729429 100644 --- a/README.rst +++ b/README.rst @@ -26,6 +26,14 @@ Or, if you use :code:`pipx` (`try it!`_ 😉) :: pipx install scalewiz +Or, if you use :code:`pipx` (`try it!`_ 😉) :: + + pipx install scalewiz + +Or, if you use :code:`pipx` (`try it!`_ 😉) :: + + pipx install scalewiz + Usage ===== @@ -72,7 +80,7 @@ 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 diff --git a/sample.py b/sample.py new file mode 100644 index 0000000..c5ca834 --- /dev/null +++ b/sample.py @@ -0,0 +1,44 @@ +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() + 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) + + 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/components/evaluation_window.py b/scalewiz/components/evaluation_window.py index 17142ae..a90d211 100644 --- a/scalewiz/components/evaluation_window.py +++ b/scalewiz/components/evaluation_window.py @@ -88,6 +88,7 @@ def build(self, reload: bool = False) -> None: 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, @@ -111,6 +112,13 @@ 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 to this Project right now", + "Can't save while a Test in this Project is running", + ) + return + # update image plot_output = ( f"{self.editor_project.numbers.get().replace(' ', '')} " diff --git a/scalewiz/components/handler_view_plot.py b/scalewiz/components/handler_view_plot.py index e7d1a2d..059a694 100644 --- a/scalewiz/components/handler_view_plot.py +++ b/scalewiz/components/handler_view_plot.py @@ -53,13 +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 - # cast to tuple in case the list changes during iteration - readings = tuple(self.handler.readings) + for reading in readings: pump1.append(reading.pump1) pump2.append(reading.pump2) diff --git a/scalewiz/components/project_editor.py b/scalewiz/components/project_editor.py index 11edbf9..cd8414e 100644 --- a/scalewiz/components/project_editor.py +++ b/scalewiz/components/project_editor.py @@ -73,6 +73,7 @@ def build(self, reload: bool = False) -> None: 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) @@ -86,6 +87,7 @@ 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 834e1e8..3823b96 100644 --- a/scalewiz/components/scalewiz.py +++ b/scalewiz/components/scalewiz.py @@ -38,9 +38,17 @@ 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" + # ) + # this is for inspecting the multithreading + # fmt = "%(asctime)s - %(thread)d - %(levelname)s - %(name)s - %(message)s" + # fmt = "%(asctime)s - %(levelname)s - %(name)s - %(message)s" date_fmt = "%Y-%m-%d %H:%M:%S" formatter = logging.Formatter( fmt, diff --git a/scalewiz/components/scalewiz_menu_bar.py b/scalewiz/components/scalewiz_menu_bar.py index 0ff65a0..8f95569 100644 --- a/scalewiz/components/scalewiz_menu_bar.py +++ b/scalewiz/components/scalewiz_menu_bar.py @@ -5,8 +5,7 @@ import logging import tkinter as tk from pathlib import Path - -# from time import time +from time import time from tkinter.messagebox import showinfo from typing import TYPE_CHECKING @@ -47,7 +46,8 @@ 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) + menubar.add_command(label="Debug", command=self._debug) + self.menubar = menubar def spawn_editor(self) -> None: @@ -99,17 +99,16 @@ def about(self) -> None: def _debug(self) -> None: """Used for debugging.""" - pass - # LOGGER.warn("DEBUGGING") - - # current_tab = self.parent.tab_control.select() - # widget: TestHandlerView = self.parent.nametowidget(current_tab) - # widget.handler.setup_pumps() - # t0 = time() - # widget.handler.pump1.pressure - # widget.handler.pump2.pressure - # t1 = time() - # widget.handler.close_pumps() - # LOGGER.warn("collected 2 pressures in %s", t1 - t0) - # widget.handler.rebuild_views() - # widget.bell() + 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 2ed16d5..94331d7 100644 --- a/scalewiz/helpers/export.py +++ b/scalewiz/helpers/export.py @@ -58,6 +58,7 @@ def export(project: Project) -> Tuple[int, Path]: 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 fb89363..d6c6d41 100644 --- a/scalewiz/helpers/score.py +++ b/scalewiz/helpers/score.py @@ -100,7 +100,8 @@ 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(result) + + trial.result.set(f"{result:.2f}") if isinstance(log_widget, tk.Text): to_log(log, log_widget) diff --git a/scalewiz/models/test_handler.py b/scalewiz/models/test_handler.py index 8a60f0b..b634f53 100644 --- a/scalewiz/models/test_handler.py +++ b/scalewiz/models/test_handler.py @@ -7,7 +7,7 @@ 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 @@ -34,7 +34,9 @@ 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 @@ -63,7 +65,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 ) @@ -110,7 +112,7 @@ def start_test(self) -> None: for pump in (self.pump1, self.pump2): pump.close() else: - self.readings.clear() + self.stop_requested = False self.is_done = False self.is_running = True @@ -118,11 +120,7 @@ def start_test(self) -> None: self.pool.submit(self.uptake_cycle) def uptake_cycle(self) -> None: - """Get ready to take readings. - - Meant to be run from a worker thread. - """ - self.logger.info("Starting an uptake cycle") + """Get ready to take readings.""" uptake = self.project.uptake_seconds.get() step = uptake / 100 # we will sleep for 100 steps self.pump1.run() @@ -146,6 +144,7 @@ def take_readings(self) -> None: 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() @@ -159,6 +158,7 @@ def get_pressure(pump: NextGenPump) -> Union[float, int]: 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 @@ -167,10 +167,12 @@ def get_pressure(pump: NextGenPump) -> Union[float, int]: msg = "@ {:.2f} min; pump1: {}, pump2: {}, avg: {}".format( self.elapsed_min, psi1, psi2, average ) - self.readings.append(reading) + + self.readings.put(reading) self.log_queue.put(msg) self.logger.debug(msg) - prog = round((len(self.readings) / self.max_readings) * 100) + prog = round((self.readings.qsize() / self.max_readings) * 100) + self.progress.set(prog) if psi1 > self.max_psi_1: @@ -181,7 +183,8 @@ def get_pressure(pump: NextGenPump) -> Union[float, int]: # TYSM https://stackoverflow.com/a/25251804 sleep(interval - ((time() - start_time) % interval)) else: - self.root.after(0, self.stop_test, {"save": True}) + + self.stop_test(save=True) def request_stop(self) -> None: """Requests that the Test stop.""" @@ -190,7 +193,16 @@ def request_stop(self) -> None: def stop_test(self, save: bool = False, rinsing: bool = False) -> None: """Stops the pumps, closes their ports.""" - self.close_pumps() + + for pump in (self.pump1, self.pump2): + if pump.is_open: + pump.stop() + pump.close() + self.logger.info( + "Stopped and closed the device @ %s", + pump.serial.name, + ) + if not rinsing: self.is_done = True self.is_running = False @@ -206,7 +218,14 @@ def save_test(self) -> None: self.logger.info( "Saving %s to %s", self.test.name.get(), self.project.name.get() ) - 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 @@ -239,17 +258,6 @@ def setup_pumps(self, issues: List[str] = None) -> None: pump.flowrate = flowrate self.logger.info("Set flowrates to %s", pump.flowrate) - def close_pumps(self) -> None: - """Tears down the pumps.""" - for pump in (self.pump1, self.pump2): - if pump.is_open: - pump.stop() - pump.close() - self.logger.info( - "Stopped and closed the device @ %s", - pump.serial.name, - ) - def load_project( self, path: Union[str, Path] = None, @@ -258,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/scalewiz/models/test_handler2.py b/scalewiz/models/test_handler2.py new file mode 100644 index 0000000..df64498 --- /dev/null +++ b/scalewiz/models/test_handler2.py @@ -0,0 +1,332 @@ +"""Handles a test. Experimental / not currently used. + +Readings are collected using a combination of multithreading and tk.after calls. +""" + +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 + + +def get_pressure(pump: NextGenPump) -> Union[float, int]: + return pump.pressure + + +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() + print("starting rinse for", duration_ms, "with 100 steps of", ms_step) + + 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.pool.submit(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 = 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( + 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.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()) diff --git a/todo b/todo index 8468c8b..3a4b8b0 100644 --- a/todo +++ b/todo @@ -3,6 +3,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 ----