add analysis tool

This commit is contained in:
2025-10-14 11:34:06 +02:00
parent d48728254c
commit 9f822ab63b
5 changed files with 555 additions and 4 deletions

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
__pycache__
.venv
.venv
logs

View File

@@ -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("<Button-1>", 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()

View File

@@ -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

View File

@@ -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]

39
uv.lock generated
View File

@@ -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"