diff --git a/README.md b/README.md index 115d4c6..461a948 100644 --- a/README.md +++ b/README.md @@ -2,5 +2,15 @@ App and small arduino code to develop an optimal filter algorithm. +## Install dependencies +``` +pip install -r requirements.txt +``` + +## Run App +``` +python -m filter_dev_app.app +``` + ## Algorithm diff --git a/filter_dev/app.py b/filter_dev/app.py index 3d2801b..3d6116d 100644 --- a/filter_dev/app.py +++ b/filter_dev/app.py @@ -1,5 +1,4 @@ import tkinter as tk -from tkinter import ttk import matplotlib.pyplot as plt from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg @@ -7,7 +6,7 @@ import asyncio from .filter import * from .gui.device import Device -from .gui.slider import Slider +from .gui.toolbar import RecordForm, FilterForm, DataStats class FilterDevApp(tk.Tk): def __init__(self, loop: asyncio.EventLoop): @@ -15,9 +14,7 @@ class FilterDevApp(tk.Tk): self.loop = loop self.protocol("WM_DELETE_WINDOW", self.close) self.tasks = [] - self.tasks.append(loop.create_task(self.updater(1./2))) - self.tasks.append(loop.create_task(self.update_plot(1./20))) - self.tasks.append(loop.create_task(self.read_values(1./100))) + self.tasks.append(loop.create_task(self.updater(1./100))) self.filter = None @@ -29,6 +26,7 @@ class FilterDevApp(tk.Tk): # Create a figure for plotting self.fig, self.ax = plt.subplots() + self.ax2 = self.ax.twinx() self.canvas = FigureCanvasTkAgg(self.fig, master=self.frame) self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH) @@ -37,91 +35,92 @@ class FilterDevApp(tk.Tk): self.toolbar.pack(side=tk.RIGHT, fill=tk.Y) # Device Settings - self.connect_button = ttk.Button(self.toolbar, text="Connect", command=self.connect_disconnect) - self.connect_button.pack(pady=10) - self.device_label = ttk.Label(self.toolbar, text="Device Name:") - self.device_label.pack(pady=10) - self.device_name = ttk.Entry(self.toolbar) - self.device_name.insert(0, "Smaage") # Set default value - self.device_name.pack(pady=10) + self.record_form = RecordForm(self.toolbar, self.record_data) + self.record_form.pack(pady=10) - self.device = Device(self.device_name) + self.device = Device(self.record_form.device_name) # Filter Settings - self.filter_type_label = ttk.Label(self.toolbar, text="Filter:") - self.filter_type_label.pack(pady=10) - self.filter_type_combobox = ttk.Combobox(self.toolbar, values=["MovAvg"]) - self.filter_type_combobox.set("MovAvg") # Set default value - self.filter_type_combobox.pack(pady=10) - self.change_filter = ttk.Button(self.toolbar, text="Change Filter", command=self.update_filter) - self.change_filter.pack(pady=10) - - # Objects - self.filter = MovAvg(self.device, self.toolbar, lambda: None) - self.filter.pack() + self.filter_form = FilterForm(self.toolbar, self.update_filter) + self.filter = MovAvg(self.device, self.toolbar, self.update_plot) - async def update_plot(self, interval): - while await asyncio.sleep(interval, True): - if self.filter is None: - continue - - # Clear the current plot - self.ax.clear() + self.data_stats = DataStats(self.toolbar, self.reset) - # Get current values from sliders - df = self.filter() + def update_plot(self): + if self.filter is None: + return + + # Clear the current plot + self.ax.clear() + self.ax2.clear() - # Generate data - x = df['timestamps'] - y1 = df['weights'] - y2 = df['filtered'] + # Get current values from sliders + df = self.filter() + self.data_stats.update_stats(df) - # Plot the data - self.ax.plot(x, y1) - self.ax.plot(x, y2) - self.ax.set_xlabel("Time in ms") - self.ax.set_ylabel("Weight") - self.ax.grid() + # Generate data + x = df['timestamps'] + y1 = df['weights'] + # y1_g = df['calib_weights'] + # y2 = df['filtered'] + y2_g = df['filtered_calib'] - # Draw the updated plot - self.canvas.draw() + # Plot the data + self.ax.plot(x, y1) + # self.ax.plot(x, y2) + self.ax.set_xlabel("Time in ms") + self.ax.set_ylabel("Raw Weight") + self.ax.grid() + + # self.ax2.plot(x, y1_g) + self.ax2.plot(x, y2_g, color="orange") + + # Draw the updated plot + self.canvas.draw() def update_filter(self): - option = self.filter_type_combobox.get() + option = self.filter_form.filter_type_combobox.get() if option == 'MovAvg' and not isinstance(self.filter, MovAvg): self.filter = MovAvg(self.device, self.toolbar, self.update_plot) self.update_plot() - def connect_disconnect(self): + def record_data(self): if self.device.is_connected: - self.device_label.pack(pady=10) - self.device_name.pack(pady=10) - self.connect_button.config(text="Connect") - self.connect_button.pack(pady=10) + self.record_form.pack(pady=10) self.device.disconnect() else: - task = self.loop.create_task(self.device.connect()) - task.add_done_callback(self.connected) + record_duration = self.record_form.record_time.get_value() + task = self.loop.create_task(self.device.read_values(record_duration)) + task.add_done_callback(self.data_recorded) - def connected(self, *args): + def data_recorded(self, *args): if self.device.is_connected: - self.device_label.pack_forget() - self.device_name.pack_forget() - self.connect_button.config(text="Disconnect") + self.record_form.pack_forget() + + self.filter_form.pack(pady=10) + self.filter.pack(pady=10) + self.data_stats.pack(pady=10) - self.filter.pack() - - async def read_values(self, interval): - await self.device.read_values(interval) + self.update_filter() async def updater(self, interval): while await asyncio.sleep(interval, True): self.update() - + + def reset(self): + self.device.disconnect() + self.device.clear_data() + + self.filter_form.pack_forget() + self.filter.pack_forget() + self.data_stats.pack_forget() + + self.record_form.pack() + self.update_plot() def close(self): for task in self.tasks: diff --git a/filter_dev/config.py b/filter_dev/config.py index 736f7b9..fdb7ba5 100644 --- a/filter_dev/config.py +++ b/filter_dev/config.py @@ -1,4 +1,6 @@ # Replace with your Arduino's service and characteristic UUIDs SERVICE_UUID = "9f0dfdb2-e978-494c-8f15-68dbe8d28672" MILLIS_UUID = "abb92561-a809-453c-8c7c-71d3fff5b86e" -WEIGHT_UUID = "123e4567-e89b-12d3-a456-426614174000" \ No newline at end of file +WEIGHT_UUID = "123e4567-e89b-12d3-a456-426614174000" + +DEFAULT_CALIB = 104167.17 \ No newline at end of file diff --git a/filter_dev/filter/base.py b/filter_dev/filter/base.py index 1eeb469..7312a3e 100644 --- a/filter_dev/filter/base.py +++ b/filter_dev/filter/base.py @@ -3,8 +3,8 @@ from typing import Dict import pandas as pd from tkinter.ttk import Frame -from ..gui.device import Device -from ..gui.slider import Slider +from ..gui import Device, Slider, Entry +from ..config import DEFAULT_CALIB class Filter: @@ -15,6 +15,8 @@ class Filter: self.toolbar = toolbar self.callback = callback + self.calib_entry = Entry(toolbar, "Calibration Factor", DEFAULT_CALIB) + self.init_params(toolbar) def init_params(self, toolbar): @@ -28,17 +30,21 @@ class Filter: return params def __call__(self) -> pd.DataFrame: + calib_factor = 100. / float(self.calib_entry.get()) df = self.device.data - df['filtered'] = self.filter(df) + df['filtered'], df['filtered_calib'] = self.filter(df, calib_factor) + df['calib_weights'] = df['weights'] * calib_factor return df - def filter(self, df: pd.DataFrame) -> pd.Series: + def filter(self, df: pd.DataFrame, calib_factor: float) -> pd.Series: raise NotImplementedError() - def pack(self): + def pack(self, **kwargs): + self.calib_entry.pack(**kwargs) for k, v in self.param_map.items(): - v.pack() + v.pack(**kwargs) def pack_forget(self): + self.calib_entry.pack_forget() for k, v in self.param_map.items(): v.pack_forget() \ No newline at end of file diff --git a/filter_dev/filter/mov_avg.py b/filter_dev/filter/mov_avg.py index 4d1ee05..2704984 100644 --- a/filter_dev/filter/mov_avg.py +++ b/filter_dev/filter/mov_avg.py @@ -1,18 +1,21 @@ +from tkinter import ttk + import pandas as pd from .base import Filter -from ..gui.slider import Slider +from ..gui import Slider class MovAvg(Filter): def init_params(self, toolbar): self.param_map = { - "window_size": Slider(toolbar, "Window Size", 1, 500, 10, self.callback), + "window_size": Slider(toolbar, "Window Size", 1, 100, 10, self.callback), "decimals": Slider(toolbar, "Decimals", 1, 5, 1, self.callback), # "reset_threshold": Slider(self.toolbar, "Reset Threshold", 0.001, 0.1, 0.1, self.update), } - def filter(self, df: pd.DataFrame) -> pd.Series: + def filter(self, df: pd.DataFrame, calib_factor: float) -> pd.Series: params = self._get_params() - return df['weights'].rolling(window=int(params['window_size'])).mean()\ - .round(int(params['decimals'])) + mov_avg = df['weights'].rolling(window=int(params['window_size'])).mean() + mov_avg_calib = (mov_avg * calib_factor).round(int(params['decimals'])) + return mov_avg, mov_avg_calib diff --git a/filter_dev/gui/__init__.py b/filter_dev/gui/__init__.py index e69de29..999ab7c 100644 --- a/filter_dev/gui/__init__.py +++ b/filter_dev/gui/__init__.py @@ -0,0 +1,4 @@ +from .device import Device + +from .slider import Slider +from .entry import Entry \ No newline at end of file diff --git a/filter_dev/gui/device.py b/filter_dev/gui/device.py index d969bf5..a5b5ce3 100644 --- a/filter_dev/gui/device.py +++ b/filter_dev/gui/device.py @@ -1,7 +1,9 @@ from bleak import BleakClient, BleakScanner import pandas as pd from tkinter.ttk import Entry +from tkinter.messagebox import showerror, showinfo import asyncio +from time import time from ..config import MILLIS_UUID, WEIGHT_UUID @@ -32,21 +34,26 @@ class Device: def disconnect(self): self.device = None - async def read_values(self, interval): - while await asyncio.sleep(interval, True): - if self.is_connected: - async with BleakClient(self.device.address) as client: - while await asyncio.sleep(interval, True): - millis = await client.read_gatt_char(MILLIS_UUID) - millis = int.from_bytes(millis, byteorder='little') # Adjust based on your data format - weight = await client.read_gatt_char(WEIGHT_UUID) - weight = int.from_bytes(weight, byteorder='little') # Adjust based on your data format - - self.timestamps.append(millis) - self.weights.append(weight) + async def read_values(self, duration): + if not await self.connect(): + showerror("Record Data", f"Device {self.device_name.get()} not found!") + return + + self.clear_data() + async with BleakClient(self.device.address) as client: + showinfo("Recording Data", f"Recording data for {duration} seconds.") + time_start = time() + time_passed = 0 + while time_passed < duration: + millis = await client.read_gatt_char(MILLIS_UUID) + millis = int.from_bytes(millis, byteorder='little') # Adjust based on your data format + weight = await client.read_gatt_char(WEIGHT_UUID) + weight = int.from_bytes(weight, byteorder='little') # Adjust based on your data format + + self.timestamps.append(millis) + self.weights.append(weight) - if not self.is_connected: - break + time_passed = time() - time_start def clear_data(self): diff --git a/filter_dev/gui/entry.py b/filter_dev/gui/entry.py new file mode 100644 index 0000000..4425f44 --- /dev/null +++ b/filter_dev/gui/entry.py @@ -0,0 +1,19 @@ +from tkinter import ttk + +class Entry: + + def __init__(self, master, title, default_value): + self.label = ttk.Label(master, text=title) + self.entry = ttk.Entry(master) + self.entry.insert(0, default_value) # Set default value + + def get(self): + return self.entry.get() + + def pack(self, **kwargs): + self.label.pack(**kwargs) + self.entry.pack(**kwargs) + + def pack_forget(self): + self.label.pack_forget() + self.entry.pack_forget() \ No newline at end of file diff --git a/filter_dev/gui/toolbar/__init__.py b/filter_dev/gui/toolbar/__init__.py new file mode 100644 index 0000000..ad94cc7 --- /dev/null +++ b/filter_dev/gui/toolbar/__init__.py @@ -0,0 +1,3 @@ +from .record_form import RecordForm +from .filter_form import FilterForm +from .data_stats import DataStats \ No newline at end of file diff --git a/filter_dev/gui/toolbar/data_stats.py b/filter_dev/gui/toolbar/data_stats.py new file mode 100644 index 0000000..4b3f723 --- /dev/null +++ b/filter_dev/gui/toolbar/data_stats.py @@ -0,0 +1,26 @@ +import tkinter as tk +from tkinter import ttk + +import pandas as pd + +class DataStats(tk.Frame): + + def __init__(self, master, command, **kwargs): + super().__init__(master, **kwargs) + + self.items = ttk.Label(self, text="Recorded Items:") + self.items.pack() + self.duration = ttk.Label(self, text="Time Delta:") + self.duration.pack() + self.std = ttk.Label(self, text="Raw Weights:") + self.std.pack() + + self.reset_button = ttk.Button(self, text="Reset", command=command) + self.reset_button.pack() + + def update_stats(self, df: pd.DataFrame): + self.items.config(text=f"Recorded Items: {df.size:01d}") + + delta = df['timestamps'].diff().shift(-1) + self.duration.config(text=f"Time Delta: {delta.mean():.2f} ± {delta.std():.2f}") + self.std.config(text=f"Raw Weights: {df['weights'].mean():.2f} ± {df['weights'].std():.2f}") diff --git a/filter_dev/gui/toolbar/filter_form.py b/filter_dev/gui/toolbar/filter_form.py new file mode 100644 index 0000000..51cc4d1 --- /dev/null +++ b/filter_dev/gui/toolbar/filter_form.py @@ -0,0 +1,15 @@ +import tkinter as tk +from tkinter import ttk + +class FilterForm(tk.Frame): + + def __init__(self, master, command, **kwargs): + super().__init__(master, **kwargs) + + self.filter_type_label = ttk.Label(self, text="Filter:") + self.filter_type_label.pack() + self.filter_type_combobox = ttk.Combobox(self, values=["MovAvg"]) + self.filter_type_combobox.pack() + self.filter_type_combobox.set("MovAvg") # Set default value + self.change_filter = ttk.Button(self, text="Change Filter", command=command) + self.change_filter.pack() diff --git a/filter_dev/gui/toolbar/record_form.py b/filter_dev/gui/toolbar/record_form.py new file mode 100644 index 0000000..f68a696 --- /dev/null +++ b/filter_dev/gui/toolbar/record_form.py @@ -0,0 +1,19 @@ +import tkinter as tk +from tkinter import ttk + +from ..slider import Slider + +class RecordForm(tk.Frame): + + def __init__(self, master, record_command, **kwargs): + super().__init__(master, **kwargs) + + self.device_label = ttk.Label(self, text="Device Name:") + self.device_label.pack(pady=10) + self.device_name = ttk.Entry(self) + self.device_name.insert(0, "Smaage") # Set default value + self.device_name.pack() + self.record_time = Slider(self, "Record Time:", 10, 30, 10, lambda: None) + self.record_time.pack(pady=10) + self.record_button = ttk.Button(self, text="Record Data", command=record_command) + self.record_button.pack(pady=10) \ No newline at end of file