From 9f822ab63b0ac14fa0a8e85859fb4f72d48c4bc5 Mon Sep 17 00:00:00 2001 From: Jannes Date: Tue, 14 Oct 2025 11:34:06 +0200 Subject: [PATCH] add analysis tool --- .gitignore | 3 +- evaluation/calibration/analysis.py | 509 +++++++++++++++++++++++++++++ evaluation/calibration/frame.py | 6 +- pyproject.toml | 2 + uv.lock | 39 +++ 5 files changed, 555 insertions(+), 4 deletions(-) create mode 100644 evaluation/calibration/analysis.py diff --git a/.gitignore b/.gitignore index b0f2192..b17c63a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__ -.venv \ No newline at end of file +.venv +logs \ No newline at end of file diff --git a/evaluation/calibration/analysis.py b/evaluation/calibration/analysis.py new file mode 100644 index 0000000..a6fb479 --- /dev/null +++ b/evaluation/calibration/analysis.py @@ -0,0 +1,509 @@ +#!/usr/bin/env python3 +""" +Calibration Data Analysis GUI + +This module provides a GUI for analyzing calibration data from CSV files. +It monitors the ./logs directory for new files and displays interactive plots. +""" + +import os +from pathlib import Path +import time +import threading +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler + +import tkinter as tk +from tkinter import ttk, messagebox +import pandas as pd +import matplotlib.pyplot as plt +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +import seaborn as sns +import numpy as np + +# Set seaborn style for better plots +sns.set_style("whitegrid") +sns.set_palette("husl") +plt.rcParams['figure.facecolor'] = 'white' +sns.set_theme(font_scale=0.5) + + +class FileWatcher(FileSystemEventHandler): + """Watch for changes in the logs directory""" + + def __init__(self, callback): + self.callback = callback + + def on_created(self, event): + if not event.is_directory and event.src_path.endswith('.csv'): + # Small delay to ensure file is fully written + threading.Timer(0.5, self.callback).start() + + def on_modified(self, event): + if not event.is_directory and event.src_path.endswith('.csv'): + # Small delay to ensure file is fully written + threading.Timer(0.5, self.callback).start() + + +class CalibrationAnalysisGUI: + def __init__(self, root, root_dir="./logs"): + self.root = root + self.root.title("Calibration Data Analysis - Filter Dev App") + self.root.geometry("1400x900") + + # Set minimum window size + self.root.minsize(1000, 700) + + # Data storage + self.csv_files = {} # filepath -> DataFrame + self.selected_files = set() + + # Setup directory monitoring + self.logs_dir = Path(root_dir) + self.logs_dir.mkdir(parents=True, exist_ok=True) + + # Status tracking + self.last_file_count = 0 + + self.setup_ui() + self.setup_file_watcher() + self.refresh_files() + + # Auto-refresh timer + self.root.after(1000, self.auto_refresh) + + def setup_ui(self): + """Setup the main user interface""" + + # Main container with paned windows + main_paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL) + main_paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Left panel for file selection and controls + left_frame = ttk.Frame(main_paned) + main_paned.add(left_frame, weight=0) + + # Right panel for plots + right_frame = ttk.Frame(main_paned) + main_paned.add(right_frame, weight=1) + + self.setup_control_panel(left_frame) + self.setup_plot_panel(right_frame) + + def setup_control_panel(self, parent): + """Setup the left control panel""" + + # Title + title_label = ttk.Label(parent, text="Calibration Analysis", font=('Arial', 14, 'bold')) + title_label.pack(pady=(10, 20)) + + # Directory info + dir_frame = ttk.LabelFrame(parent, text="Monitoring Directory", padding=10) + dir_frame.pack(fill=tk.X, padx=10, pady=5) + + self.dir_label = ttk.Label(dir_frame, text=f"Directory: {os.path.abspath(self.logs_dir)}") + self.dir_label.pack(anchor=tk.W) + + self.file_count_label = ttk.Label(dir_frame, text="Files found: 0") + self.file_count_label.pack(anchor=tk.W) + + # Refresh button + refresh_btn = ttk.Button(dir_frame, text="Refresh Files", command=self.refresh_files) + refresh_btn.pack(pady=(5, 0)) + + # File selection + file_frame = ttk.LabelFrame(parent, text="Select Files to Plot", padding=10) + file_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) + + # Select all/none buttons + btn_frame = ttk.Frame(file_frame) + btn_frame.pack(fill=tk.X, pady=(0, 5)) + + ttk.Button(btn_frame, text="Select All", command=self.select_all_files).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Button(btn_frame, text="Clear All", command=self.clear_all_files).pack(side=tk.LEFT) + + # File listbox with checkboxes (using Treeview) + list_frame = ttk.Frame(file_frame) + list_frame.pack(fill=tk.BOTH, expand=True) + + # Scrollable treeview + tree_scroll = ttk.Scrollbar(list_frame) + tree_scroll.pack(side=tk.RIGHT, fill=tk.Y) + + self.file_tree = ttk.Treeview(list_frame, yscrollcommand=tree_scroll.set, height=15) + self.file_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + tree_scroll.config(command=self.file_tree.yview) + + # Configure treeview + self.file_tree["columns"] = ("name", "samples", "date") + self.file_tree.column("#0", width=30, minwidth=30) + self.file_tree.column("name", width=100, minwidth=80) + self.file_tree.column("samples", width=80, minwidth=60) + self.file_tree.column("date", width=100, minwidth=100) + + self.file_tree.heading("#0", text="✓") + self.file_tree.heading("name", text="Name") + self.file_tree.heading("samples", text="Samples") + self.file_tree.heading("date", text="Date") + + self.file_tree.bind("", self.on_file_click) + + # Plot controls + plot_frame = ttk.LabelFrame(parent, text="Plot Options", padding=10) + plot_frame.pack(fill=tk.X, padx=10, pady=5) + + self.show_raw_var = tk.BooleanVar(value=True) + ttk.Checkbutton(plot_frame, text="Show Raw Readings", variable=self.show_raw_var, + command=self.update_plots).pack(anchor=tk.W) + + self.show_stats_var = tk.BooleanVar(value=True) + ttk.Checkbutton(plot_frame, text="Show Statistics", variable=self.show_stats_var, + command=self.update_plots).pack(anchor=tk.W) + + # Update plots button + ttk.Button(plot_frame, text="Update Plots", command=self.update_plots).pack(pady=(10, 0)) + + # Status and info section + status_frame = ttk.LabelFrame(parent, text="Status", padding=10) + status_frame.pack(fill=tk.X, padx=10, pady=5) + + self.status_label = ttk.Label(status_frame, text="Ready", foreground="green") + self.status_label.pack(anchor=tk.W) + + self.last_update_label = ttk.Label(status_frame, text="Last update: Never") + self.last_update_label.pack(anchor=tk.W) + + def setup_plot_panel(self, parent): + """Setup the right plot panel""" + + # Create matplotlib figure with custom subplot layout using seaborn style + # One large plot in first row, two plots in second row + self.fig = plt.figure() + gs = self.fig.add_gridspec(2, 2, height_ratios=[1, 1], hspace=0.5, wspace=0.5, + left=0.15, right=0.95, top=0.95, bottom=0.15) + + # Top row - one large plot spanning both columns + self.ax1 = self.fig.add_subplot(gs[0, :]) + + # Bottom row - two smaller plots + self.ax2 = self.fig.add_subplot(gs[1, 0]) + self.ax3 = self.fig.add_subplot(gs[1, 1]) + + # Apply seaborn styling to axes + for ax in [self.ax1, self.ax2, self.ax3]: + sns.despine(ax=ax) + + # Adjust layout + try: + self.fig.tight_layout(pad=2.0) + except Exception: + # If tight_layout fails, use subplots_adjust with more padding + self.fig.subplots_adjust(left=0.1, right=0.95, top=0.92, bottom=0.15, hspace=0.5, wspace=0.5) + + # Create canvas + self.canvas = FigureCanvasTkAgg(self.fig, parent) + self.canvas.draw() + self.canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True, padx=5, pady=5) + + # Add toolbar + toolbar_frame = ttk.Frame(parent) + toolbar_frame.pack(fill=tk.X) + + from matplotlib.backends.backend_tkagg import NavigationToolbar2Tk + toolbar = NavigationToolbar2Tk(self.canvas, toolbar_frame) + toolbar.update() + + def setup_file_watcher(self): + """Setup file system watcher for automatic updates""" + self.observer = Observer() + self.observer.schedule(FileWatcher(self.refresh_files), self.logs_dir, recursive=False) + self.observer.start() + + def refresh_files(self): + """Refresh the list of CSV files in the logs directory""" + try: + # Find all CSV files in logs directory + csv_files = list(self.logs_dir.glob("*.csv")) + + # Update file count + self.file_count_label.config(text=f"Files found: {len(csv_files)}") + + # Clear existing items + for item in self.file_tree.get_children(): + self.file_tree.delete(item) + + # Load and display files + self.csv_files.clear() + + for file_path in sorted(csv_files): + try: + df = pd.read_csv(file_path) + if not df.empty: + name = file_path.stem.replace('_readings', '')[9:] + self.csv_files[name] = df + + # Extract info for display + sample_count = len(df) + + # Try to get date from filename or file modification time + try: + if 'timestamp' in df.columns and not df['timestamp'].empty: + date_str = pd.to_datetime(df['timestamp'].iloc[0]).strftime('%Y-%m-%d %H:%M') + else: + mod_time = os.path.getmtime(file_path) + date_str = time.strftime('%Y-%m-%d %H:%M', time.localtime(mod_time)) + except: + date_str = "Unknown" + + # Add to tree + checkbox = "☐" # Unchecked by default + if file_path in self.selected_files: + checkbox = "☑" + + self.file_tree.insert("", tk.END, + text=checkbox, + values=(name, sample_count, date_str), + tags=(name,)) + except Exception as e: + print(f"Error loading {file_path}: {e}") + + # Update plots if files are selected + if self.selected_files: + self.update_plots() + + except Exception as e: + messagebox.showerror("Error", f"Error refreshing files: {e}") + + def on_file_click(self, event): + """Handle clicking on file list items""" + item = self.file_tree.selection()[0] if self.file_tree.selection() else None + if item: + file_path = self.file_tree.item(item, "tags")[0] + if file_path: + # Toggle selection + if file_path in self.selected_files: + self.selected_files.remove(file_path) + self.file_tree.item(item, text="☐") + else: + self.selected_files.add(file_path) + self.file_tree.item(item, text="☑") + + # Update plots + self.update_plots() + + def select_all_files(self): + """Select all files""" + self.selected_files = set(self.csv_files.keys()) + for item in self.file_tree.get_children(): + self.file_tree.item(item, text="☑") + self.update_plots() + + def clear_all_files(self): + """Clear all file selections""" + self.selected_files.clear() + for item in self.file_tree.get_children(): + self.file_tree.item(item, text="☐") + self.update_plots() + + def update_plots(self): + """Update all plots with selected data""" + # Clear all axes + for ax in [self.ax1, self.ax2, self.ax3]: + ax.clear() + + if not self.selected_files: + # Show "no files selected" message with seaborn styling + for i, ax in enumerate([self.ax1, self.ax2, self.ax3]): + ax.text(0.5, 0.5, 'No files selected\nUse checkboxes to select files', + ha='center', va='center', transform=ax.transAxes, + fontsize=12, style='italic', color='gray') + sns.despine(ax=ax, left=True, bottom=True) + ax.set_xticks([]) + ax.set_yticks([]) + self.canvas.draw() + return + + try: + self.plot_time_series() + self.plot_boxplot() + self.plot_histogram() + + # Apply seaborn styling to all axes + for ax in [self.ax1, self.ax2, self.ax3]: + sns.despine(ax=ax) + + try: + self.fig.tight_layout(pad=2.0) + except Exception: + # If tight_layout fails, use subplots_adjust with more padding + self.fig.subplots_adjust(left=0.1, right=0.95, top=0.92, bottom=0.15, hspace=0.5, wspace=0.5) + + self.canvas.draw() + + # Update status + self.status_label.config(text=f"Plots updated - {len(self.selected_files)} files", foreground="green") + self.last_update_label.config(text=f"Last update: {time.strftime('%H:%M:%S')}") + + except Exception as e: + print(f"Error updating plots: {e}") + # Show error message on plots + for ax in [self.ax1, self.ax2, self.ax3]: + ax.clear() + ax.text(0.5, 0.5, f'Plot Error:\n{str(e)}', + ha='center', va='center', transform=ax.transAxes, + fontsize=10, color='red') + sns.despine(ax=ax, left=True, bottom=True) + ax.set_xticks([]) + ax.set_yticks([]) + + self.canvas.draw() + self.status_label.config(text="Error updating plots", foreground="red") + messagebox.showerror("Plot Error", f"Error updating plots: {e}") + + def plot_time_series(self): + """Plot time series of raw readings using seaborn""" + self.ax1.set_title('Raw Readings Over Time') + + # Prepare data for seaborn + plot_data = [] + for file_path in self.selected_files: + df = self.csv_files[file_path] + + if 'raw_reading' in df.columns: + temp_df = df.copy() + temp_df['file'] = file_path + if 'time_seconds' in df.columns: + plot_data.append(temp_df[['time_seconds', 'raw_reading', 'file']]) + else: + temp_df['time_seconds'] = temp_df.index + plot_data.append(temp_df[['time_seconds', 'raw_reading', 'file']]) + + if plot_data: + combined_data = pd.concat(plot_data, ignore_index=True) + + # Use seaborn lineplot + sns.lineplot(data=combined_data, x='time_seconds', y='raw_reading', + hue='file', ax=self.ax1, alpha=0.8, linewidth=2) + + self.ax1.set_xlabel('Time (seconds)') + self.ax1.set_ylabel('Raw Reading') + + # Only show legend if there are multiple files + if len(self.selected_files) > 1: + self.ax1.legend() + else: + self.ax1.text(0.5, 0.5, 'No time series data available', + ha='center', va='center', transform=self.ax1.transAxes, fontsize=12) + + def plot_boxplot(self): + """Plot boxplot of raw readings using seaborn""" + self.ax2.set_title('Distribution of Raw Readings') + + # Prepare data for seaborn boxplot + plot_data = [] + for file_path in self.selected_files: + df = self.csv_files[file_path] + + if 'raw_reading' in df.columns: + temp_df = pd.DataFrame({ + 'raw_reading': df['raw_reading'], + 'file': file_path[:15] + '...' if len(file_path) > 15 else file_path + }) + plot_data.append(temp_df) + + if plot_data: + combined_data = pd.concat(plot_data, ignore_index=True) + + # Use seaborn boxplot + sns.boxplot(data=combined_data, x='file', y='raw_reading', ax=self.ax2) + + self.ax2.set_xlabel('Files') + self.ax2.set_ylabel('Raw Reading') + self.ax2.tick_params(axis='x', rotation=45) + + # Add padding to prevent x-label cutoff + self.ax2.margins(x=0.1) + else: + self.ax2.text(0.5, 0.5, 'No raw reading data available', + ha='center', va='center', transform=self.ax2.transAxes, fontsize=10) + + def plot_histogram(self): + """Plot histogram of raw readings using seaborn""" + self.ax3.set_title('Distribution Density') + + # Prepare data for seaborn histogram + plot_data = [] + for file_path in self.selected_files: + df = self.csv_files[file_path] + + if 'raw_reading' in df.columns: + temp_df = pd.DataFrame({ + 'raw_reading': df['raw_reading'], + 'file': file_path[:15] + '...' if len(file_path) > 15 else file_path + }) + plot_data.append(temp_df) + + if plot_data: + combined_data = pd.concat(plot_data, ignore_index=True) + + # Use seaborn histplot with kde + sns.histplot(data=combined_data, x='raw_reading', hue='file', + kde=True, alpha=0.6, ax=self.ax3, stat='density') + + self.ax3.set_xlabel('Raw Reading') + self.ax3.set_ylabel('Density') + + # Only add legend if there are actually multiple files + if len(self.selected_files) > 1: + self.ax3.legend() + else: + self.ax3.text(0.5, 0.5, 'No raw reading data available', + ha='center', va='center', transform=self.ax3.transAxes, fontsize=10) + + def auto_refresh(self): + """Auto refresh every 5 seconds""" + self.refresh_files() + self.root.after(5000, self.auto_refresh) # Refresh every 5 seconds + + def on_closing(self): + """Handle application closing""" + if hasattr(self, 'observer'): + self.observer.stop() + self.observer.join() + self.root.destroy() + + +def main(): + """Main entry point""" + try: + # Create and configure the main window + root = tk.Tk() + + # Create the application + app = CalibrationAnalysisGUI(root) + + # Handle window closing + root.protocol("WM_DELETE_WINDOW", app.on_closing) + + # Center the window + root.update_idletasks() + width = root.winfo_width() + height = root.winfo_height() + x = (root.winfo_screenwidth() // 2) - (width // 2) + y = (root.winfo_screenheight() // 2) - (height // 2) + root.geometry(f'{width}x{height}+{x}+{y}') + + # Start the GUI + print("Starting Calibration Analysis GUI...") + print(f"Monitoring directory: {os.path.abspath('./logs')}") + root.mainloop() + + except KeyboardInterrupt: + print("\nShutting down...") + except Exception as e: + print(f"Error: {e}") + messagebox.showerror("Error", f"Failed to start application: {e}") + + +if __name__ == "__main__": + main() diff --git a/evaluation/calibration/frame.py b/evaluation/calibration/frame.py index bfb0c88..54eeece 100644 --- a/evaluation/calibration/frame.py +++ b/evaluation/calibration/frame.py @@ -150,7 +150,7 @@ class CalibrationFrame(ttk.Frame): # Update UI self.status_label.config(text=f"Calibration '{calibration_name}' complete!") - self.calibration_factor_label.config(text=f"Calibration Factor: {calibration_factor:.2f}") + self.calibration_factor_label.config(text=f"Calibration Factor: {calibration_factor:.6f}") self.std_label.config(text=f"Standard Deviation: {std_reading:.2f} (raw units)") self.drift_label.config(text=f"Drift: {overall_drift:.2f} units ({drift_rate:.2f} units/sec)") @@ -164,7 +164,7 @@ class CalibrationFrame(ttk.Frame): f"Mean reading: {mean_reading:.2f} (raw units)\n" f"Standard deviation: {std_reading:.2f} (raw units)\n" f"Overall drift: {overall_drift:.2f} units ({drift_rate:.2f} units/sec)\n" - f"Calibration factor: {calibration_factor:.2f}\n\n" + f"Calibration factor: {calibration_factor:.6f}\n\n" f"The scale is now calibrated for 100g.\n\n" f"Data saved to:\n" f"• Summary: calib_logs.csv\n" @@ -256,7 +256,7 @@ class CalibrationFrame(ttk.Frame): 'std_deviation': round(std_reading, 4), 'overall_drift': round(overall_drift, 4), 'drift_rate_per_sec': round(drift_rate, 6), - 'calibration_factor': round(calibration_factor, 4), + 'calibration_factor': round(calibration_factor, 6), 'min_reading': round(min(calibration_readings), 4), 'max_reading': round(max(calibration_readings), 4), 'measurement_duration_sec': 30.0 diff --git a/pyproject.toml b/pyproject.toml index 78d0dea..4f71574 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,8 @@ dependencies = [ "pyserial>=3.5", "python-toolkit", "tqdm>=4.67.1", + "watchdog>=4.0.0", + "seaborn>=0.12.0", ] [tool.uv.sources] diff --git a/uv.lock b/uv.lock index 31f6e61..443ca6b 100644 --- a/uv.lock +++ b/uv.lock @@ -130,7 +130,9 @@ dependencies = [ { name = "pandas" }, { name = "pyserial" }, { name = "python-toolkit" }, + { name = "seaborn" }, { name = "tqdm" }, + { name = "watchdog" }, ] [package.metadata] @@ -141,7 +143,9 @@ requires-dist = [ { name = "pandas", specifier = ">=2.3.2" }, { name = "pyserial", specifier = ">=3.5" }, { name = "python-toolkit", git = "https://git.magnuss.link/JannTer/python-toolkit" }, + { name = "seaborn", specifier = ">=0.12.0" }, { name = "tqdm", specifier = ">=4.67.1" }, + { name = "watchdog", specifier = ">=4.0.0" }, ] [[package]] @@ -545,6 +549,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] +[[package]] +name = "seaborn" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -584,6 +602,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "winrt-runtime" version = "3.2.1"