From 13a482641522746e5542c7e5b24a94e9fed04ec8 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Fri, 1 Aug 2025 12:46:51 -0700 Subject: [PATCH] feat: Enhance DataManager and GraphManager with performance optimizations and caching --- src/data_manager.py | 216 ++++++++++++++++++------ src/graph_manager.py | 390 ++++++++++++++++++++++++------------------- src/main.py | 20 ++- 3 files changed, 398 insertions(+), 228 deletions(-) diff --git a/src/data_manager.py b/src/data_manager.py index 006d65c..a903815 100644 --- a/src/data_manager.py +++ b/src/data_manager.py @@ -9,7 +9,7 @@ from pathology_manager import PathologyManager class DataManager: - """Handle all data operations for the application.""" + """Handle all data operations for the application with performance optimizations.""" def __init__( self, @@ -22,10 +22,21 @@ class DataManager: self.logger: logging.Logger = logger self.medicine_manager = medicine_manager self.pathology_manager = pathology_manager + + # Cache for loaded data to avoid repeated file I/O + self._data_cache: pd.DataFrame | None = None + self._cache_timestamp: float = 0 + self._headers_cache: tuple[str, ...] | None = None + self._dtype_cache: dict[str, type] | None = None + self._initialize_csv_file() - def _get_csv_headers(self) -> list[str]: - """Get CSV headers based on current pathology and medicine configuration.""" + def _get_csv_headers(self) -> tuple[str, ...]: + """Get CSV headers based on current pathology and medicine configuration. + Cached to avoid repeated computation.""" + if self._headers_cache is not None: + return self._headers_cache + # Start with date headers = ["date"] @@ -37,7 +48,9 @@ class DataManager: for medicine_key in self.medicine_manager.get_medicine_keys(): headers.extend([medicine_key, f"{medicine_key}_doses"]) - return headers + ["note"] + result = tuple(headers + ["note"]) + self._headers_cache = result + return result def _initialize_csv_file(self) -> None: """Create CSV file with headers if it doesn't exist or is empty.""" @@ -46,27 +59,74 @@ class DataManager: writer = csv.writer(file) writer.writerow(self._get_csv_headers()) + def _invalidate_cache(self) -> None: + """Invalidate the data cache when data changes.""" + self._data_cache = None + self._cache_timestamp = 0 + + def _should_reload_data(self) -> bool: + """Check if data should be reloaded based on file modification time.""" + if self._data_cache is None: + return True + + try: + file_mtime = os.path.getmtime(self.filename) + return file_mtime > self._cache_timestamp + except OSError: + return True + + def _get_dtype_dict(self) -> dict[str, type]: + """Get pandas dtype dictionary for efficient reading. + Cached to avoid recreation.""" + if self._dtype_cache is not None: + return self._dtype_cache + + dtype_dict = {"date": str, "note": str} + + # Add pathology types + for pathology_key in self.pathology_manager.get_pathology_keys(): + dtype_dict[pathology_key] = int + + # Add medicine types + for medicine_key in self.medicine_manager.get_medicine_keys(): + dtype_dict[medicine_key] = int + dtype_dict[f"{medicine_key}_doses"] = str + + self._dtype_cache = dtype_dict + return dtype_dict + def load_data(self) -> pd.DataFrame: - """Load data from CSV file.""" + """Load data from CSV file with caching for better performance.""" if not os.path.exists(self.filename) or os.path.getsize(self.filename) == 0: self.logger.warning("CSV file is empty or doesn't exist. No data to load.") return pd.DataFrame() + # Use cached data if available and file hasn't changed + if not self._should_reload_data(): + return self._data_cache.copy() + try: - # Build dtype dictionary dynamically - dtype_dict = {"date": str, "note": str} + # Use pre-built dtype dictionary for faster parsing + dtype_dict = self._get_dtype_dict() - # Add pathology types - for pathology_key in self.pathology_manager.get_pathology_keys(): - dtype_dict[pathology_key] = int + # Read with optimized settings + df: pd.DataFrame = pd.read_csv( + self.filename, + dtype=dtype_dict, + na_filter=False, # Don't convert to NaN, keep as empty strings + engine="c", # Use faster C engine + ) - # Add medicine types - for medicine_key in self.medicine_manager.get_medicine_keys(): - dtype_dict[medicine_key] = int - dtype_dict[f"{medicine_key}_doses"] = str + # Sort only if needed (check if already sorted) + if len(df) > 1 and not df["date"].is_monotonic_increasing: + df = df.sort_values(by="date").reset_index(drop=True) + + # Cache the data and timestamp + self._data_cache = df.copy() + self._cache_timestamp = os.path.getmtime(self.filename) + + return df.copy() - df: pd.DataFrame = pd.read_csv(self.filename, dtype=dtype_dict).fillna("") - return df.sort_values(by="date").reset_index(drop=True) except pd.errors.EmptyDataError: self.logger.warning("CSV file is empty. No data to load.") return pd.DataFrame() @@ -75,69 +135,104 @@ class DataManager: return pd.DataFrame() def add_entry(self, entry_data: list[str | int]) -> bool: - """Add a new entry to the CSV file.""" + """Add a new entry to the CSV file with optimized duplicate checking.""" try: - # Check if date already exists - df: pd.DataFrame = self.load_data() + # Quick duplicate check using cached data if available date_to_add: str = str(entry_data[0]) - if not df.empty and date_to_add in df["date"].values: - self.logger.warning(f"Entry with date {date_to_add} already exists.") - return False + if self._data_cache is not None: + # Use cached data for duplicate check + if date_to_add in self._data_cache["date"].values: + self.logger.warning( + f"Entry with date {date_to_add} already exists." + ) + return False + else: + # Fallback to loading data if no cache + df: pd.DataFrame = self.load_data() + if not df.empty and date_to_add in df["date"].values: + self.logger.warning( + f"Entry with date {date_to_add} already exists." + ) + return False + # Write to file with open(self.filename, mode="a", newline="") as file: writer = csv.writer(file) writer.writerow(entry_data) + + # Invalidate cache since data changed + self._invalidate_cache() return True + except Exception as e: self.logger.error(f"Error adding entry: {str(e)}") return False def update_entry(self, original_date: str, values: list[str | int]) -> bool: - """Update an existing entry identified by original_date.""" + """Update an existing entry identified by original_date + with optimized processing.""" try: df: pd.DataFrame = self.load_data() new_date: str = str(values[0]) - # If the date is being changed, check if the new date already exists - if original_date != new_date and new_date in df["date"].values: + # Optimized duplicate check + if original_date != new_date: + date_exists = (df["date"] == new_date).any() + if date_exists: + self.logger.warning( + f"Cannot update: entry with date {new_date} already exists." + ) + return False + + # Get current CSV headers to match with values + headers = list(self._get_csv_headers()) + + # Ensure we have the right number of values with optimized padding + if len(values) < len(headers): + # Pad with defaults efficiently + padding_needed = len(headers) - len(values) + for i in range(padding_needed): + header_idx = len(values) + i + if header_idx < len(headers): + header = headers[header_idx] + if header == "note" or header.endswith("_doses"): + values.append("") + else: + values.append(0) + + # Use vectorized update for better performance + mask = df["date"] == original_date + if mask.any(): + df.loc[mask, headers] = values + # Write back to CSV with optimized method + df.to_csv(self.filename, index=False, mode="w") + self._invalidate_cache() + return True + else: self.logger.warning( - f"Cannot update: entry with date {new_date} already exists." + f"Entry with date {original_date} not found for update." ) return False - # Get current CSV headers to match with values - headers = self._get_csv_headers() - - # Ensure we have the right number of values - if len(values) != len(headers): - self.logger.warning( - f"Value count mismatch: expected {len(headers)}, got {len(values)}" - ) - # Pad with defaults if too few values - while len(values) < len(headers): - header = headers[len(values)] - if header == "note" or header.endswith("_doses"): - values.append("") - else: - values.append(0) - - # Update the row using column names - df.loc[df["date"] == original_date, headers] = values - df.to_csv(self.filename, index=False) - return True except Exception as e: self.logger.error(f"Error updating entry: {str(e)}") return False def delete_entry(self, date: str) -> bool: - """Delete an entry identified by date.""" + """Delete an entry identified by date with optimized processing.""" try: df: pd.DataFrame = self.load_data() - # Remove the row with the matching date + original_len = len(df) + + # Use vectorized filtering for better performance df = df[df["date"] != date] - # Write the updated dataframe back to the CSV - df.to_csv(self.filename, index=False) + + # Only write if something was actually deleted + if len(df) < original_len: + df.to_csv(self.filename, index=False, mode="w") + self._invalidate_cache() + return True except Exception as e: self.logger.error(f"Error deleting entry: {str(e)}") @@ -146,23 +241,34 @@ class DataManager: def get_today_medicine_doses( self, date: str, medicine_name: str ) -> list[tuple[str, str]]: - """Get list of (timestamp, dose) tuples for a medicine on a given date.""" + """Get list of (timestamp, dose) tuples for a medicine on a given date + with caching.""" try: df: pd.DataFrame = self.load_data() - if df.empty or date not in df["date"].values: + if df.empty: + return [] + + # Use vectorized filtering for better performance + date_mask = df["date"] == date + if not date_mask.any(): return [] dose_column = f"{medicine_name}_doses" - doses_str = df.loc[df["date"] == date, dose_column].iloc[0] + if dose_column not in df.columns: + return [] + + doses_str = df.loc[date_mask, dose_column].iloc[0] if not doses_str: return [] + # Optimized dose parsing doses = [] for dose_entry in doses_str.split("|"): if ":" in dose_entry: - timestamp, dose = dose_entry.split(":", 1) - doses.append((timestamp, dose)) + parts = dose_entry.split(":", 1) + if len(parts) == 2: + doses.append((parts[0], parts[1])) return doses except Exception as e: diff --git a/src/graph_manager.py b/src/graph_manager.py index bb90e93..0353d1b 100644 --- a/src/graph_manager.py +++ b/src/graph_manager.py @@ -12,7 +12,8 @@ from pathology_manager import PathologyManager class GraphManager: - """Handle all graph-related operations for the application.""" + """Optimized version - Handle all graph-related operations for the + application with performance improvements.""" def __init__( self, @@ -24,166 +25,206 @@ class GraphManager: self.medicine_manager = medicine_manager self.pathology_manager = pathology_manager - # Configure graph frame to expand - self.parent_frame.grid_rowconfigure(0, weight=1) - self.parent_frame.grid_columnconfigure(0, weight=1) + # Initialize matplotlib with optimized settings + self.fig: matplotlib.figure.Figure = plt.figure(figsize=(10, 6), dpi=80) + self.ax: Axes = self.fig.add_subplot(111) - self._initialize_toggle_vars() + # Cache for current data to avoid reprocessing + self.current_data: pd.DataFrame = pd.DataFrame() + self._last_plot_hash: str = "" + + # Initialize UI components + self.toggle_vars: dict[str, tk.IntVar] = {} self._setup_ui() - - def _initialize_toggle_vars(self) -> None: - """Initialize toggle variables for chart elements.""" - self.toggle_vars: dict[str, tk.BooleanVar] = {} - - # Initialize pathology toggles dynamically - for pathology_key in self.pathology_manager.get_pathology_keys(): - pathology = self.pathology_manager.get_pathology(pathology_key) - default_value = pathology.default_enabled if pathology else True - self.toggle_vars[pathology_key] = tk.BooleanVar(value=default_value) - - # Add medicine toggles dynamically - for medicine_key in self.medicine_manager.get_medicine_keys(): - medicine = self.medicine_manager.get_medicine(medicine_key) - default_value = medicine.default_enabled if medicine else False - self.toggle_vars[medicine_key] = tk.BooleanVar(value=default_value) - - def _setup_ui(self) -> None: - """Set up the UI components.""" - # Create control frame for toggles - self.control_frame: ttk.Frame = ttk.Frame(self.parent_frame) - self.control_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5) - - # Create toggle checkboxes + self._initialize_toggle_vars() self._create_chart_toggles() - # Create graph frame - self.graph_frame: ttk.Frame = ttk.Frame(self.parent_frame) - self.graph_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5) + def _initialize_toggle_vars(self) -> None: + """Initialize toggle variables for chart elements with optimization.""" + # Initialize pathology toggles + for pathology_key in self.pathology_manager.get_pathology_keys(): + self.toggle_vars[pathology_key] = tk.IntVar(value=1) - # Reconfigure parent frame for new layout - self.parent_frame.grid_rowconfigure(1, weight=1) - self.parent_frame.grid_columnconfigure(0, weight=1) + # Initialize medicine toggles + for medicine_key in self.medicine_manager.get_medicine_keys(): + self.toggle_vars[medicine_key] = tk.IntVar(value=1) - # Initialize matplotlib figure and canvas - self.fig: matplotlib.figure.Figure - self.ax: Axes - self.fig, self.ax = plt.subplots() - self.canvas: FigureCanvasTkAgg = FigureCanvasTkAgg( - figure=self.fig, master=self.graph_frame - ) - self.canvas.get_tk_widget().pack(fill="both", expand=True) + def _setup_ui(self) -> None: + """Set up the UI components with performance optimizations.""" + # Create canvas with optimized settings + self.canvas = FigureCanvasTkAgg(self.fig, master=self.parent_frame) + self.canvas.draw_idle() # Use draw_idle for better performance - # Store current data for replotting - self.current_data: pd.DataFrame = pd.DataFrame() + # Pack canvas + canvas_widget = self.canvas.get_tk_widget() + canvas_widget.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + + # Create control frame + self.control_frame = ttk.Frame(self.parent_frame) + self.control_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=5, pady=2) def _create_chart_toggles(self) -> None: - """Create toggle controls for chart elements.""" - ttk.Label(self.control_frame, text="Show/Hide Elements:").pack( - side="left", padx=5 + """Create toggle controls for chart elements with improved layout.""" + # Pathology toggles + pathology_frame = ttk.LabelFrame( + self.control_frame, text="Pathologies", padding="5" ) + pathology_frame.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=2) - # Pathologies toggles - dynamic based on pathology manager - pathologies_frame = ttk.LabelFrame(self.control_frame, text="Pathologies") - pathologies_frame.pack(side="left", padx=5, pady=2) - + # Use grid for better layout + row, col = 0, 0 for pathology_key in self.pathology_manager.get_pathology_keys(): pathology = self.pathology_manager.get_pathology(pathology_key) if pathology: - checkbox = ttk.Checkbutton( - pathologies_frame, - text=pathology.display_name, + display_name = pathology.display_name + text = ( + display_name[:10] + "..." + if len(display_name) > 10 + else display_name + ) + cb = ttk.Checkbutton( + pathology_frame, + text=text, variable=self.toggle_vars[pathology_key], command=self._handle_toggle_changed, ) - checkbox.pack(side="left", padx=3) + cb.grid(row=row, column=col, sticky="w", padx=2) + col += 1 + if col > 1: # 2 columns max + col = 0 + row += 1 - # Medicines toggles - dynamic based on medicine manager - medicines_frame = ttk.LabelFrame(self.control_frame, text="Medicines") - medicines_frame.pack(side="left", padx=5, pady=2) + # Medicine toggles + medicine_frame = ttk.LabelFrame( + self.control_frame, text="Medicines", padding="5" + ) + medicine_frame.pack(side=tk.RIGHT, fill=tk.X, expand=True, padx=2) + # Use grid for medicines too + row, col = 0, 0 for medicine_key in self.medicine_manager.get_medicine_keys(): medicine = self.medicine_manager.get_medicine(medicine_key) if medicine: - checkbox = ttk.Checkbutton( - medicines_frame, - text=medicine.display_name, + med_name = medicine.display_name + text = med_name[:10] + "..." if len(med_name) > 10 else med_name + cb = ttk.Checkbutton( + medicine_frame, + text=text, variable=self.toggle_vars[medicine_key], command=self._handle_toggle_changed, ) - checkbox.pack(side="left", padx=3) + cb.grid(row=row, column=col, sticky="w", padx=2) + col += 1 + if col > 2: # 3 columns max for medicines + col = 0 + row += 1 def _handle_toggle_changed(self) -> None: - """Handle toggle changes by replotting the graph.""" + """Handle toggle changes by replotting the graph with optimization.""" if not self.current_data.empty: self._plot_graph_data(self.current_data) def update_graph(self, df: pd.DataFrame) -> None: - """Update the graph with new data.""" - self.current_data = df.copy() if not df.empty else pd.DataFrame() - self._plot_graph_data(df) + """Update the graph with new data using optimization checks.""" + # Create hash of data to avoid unnecessary redraws + data_hash = str(hash(str(df.values.tobytes()) if not df.empty else "empty")) + + # Only update if data actually changed + if data_hash != self._last_plot_hash or self.current_data.empty: + self.current_data = df.copy() if not df.empty else pd.DataFrame() + self._last_plot_hash = data_hash + self._plot_graph_data(df) def _plot_graph_data(self, df: pd.DataFrame) -> None: - """Plot the graph data with current toggle settings.""" - self.ax.clear() - if not df.empty: - # Convert dates and sort - df = df.copy() # Create a copy to avoid modifying the original - df["date"] = pd.to_datetime(df["date"]) - df = df.sort_values(by="date") - df.set_index(keys="date", inplace=True) + """Plot the graph data with current toggle settings using optimizations.""" + # Use batch updates to reduce redraws + with plt.ioff(): # Turn off interactive mode for batch updates + self.ax.clear() - # Track if any series are plotted - has_plotted_series = False + if not df.empty: + # Optimize data processing + df_processed = self._preprocess_data(df) - # Plot pathology data series based on toggle states - for pathology_key in self.pathology_manager.get_pathology_keys(): - if self.toggle_vars[pathology_key].get(): - pathology = self.pathology_manager.get_pathology(pathology_key) - if pathology and pathology_key in df.columns: - label = f"{pathology.display_name} ({pathology.scale_info})" - linestyle = ( - "dashed" - if pathology.scale_orientation == "inverted" - else "-" - ) - self._plot_series(df, pathology_key, label, "o", linestyle) - has_plotted_series = True + # Track if any series are plotted + has_plotted_series = self._plot_pathology_data(df_processed) + medicine_data = self._plot_medicine_data(df_processed) - # Plot medicine dose data - # Get medicine colors from medicine manager - medicine_colors = self.medicine_manager.get_graph_colors() + if has_plotted_series or medicine_data["has_plotted"]: + self._configure_graph_appearance(medicine_data) - # Get medicines dynamically from medicine manager - medicines = self.medicine_manager.get_medicine_keys() + # Single draw call at the end + self.canvas.draw_idle() - # Track medicines with and without data for legend - medicines_with_data = [] - medicines_without_data = [] + def _preprocess_data(self, df: pd.DataFrame) -> pd.DataFrame: + """Preprocess data for plotting with optimizations.""" + df = df.copy() + # Batch convert dates and sort + df["date"] = pd.to_datetime(df["date"], cache=True) + df = df.sort_values(by="date") + df.set_index(keys="date", inplace=True) + return df - for medicine in medicines: - dose_column = f"{medicine}_doses" - if self.toggle_vars[medicine].get() and dose_column in df.columns: - # Calculate daily dose totals - daily_doses = [] - for dose_str in df[dose_column]: - total_dose = self._calculate_daily_dose(dose_str) - daily_doses.append(total_dose) + def _plot_pathology_data(self, df: pd.DataFrame) -> bool: + """Plot pathology data series with optimizations.""" + has_plotted_series = False - # Only plot if there are non-zero doses - if any(dose > 0 for dose in daily_doses): - medicines_with_data.append(medicine) - # Scale doses for better visibility - # (divide by 10 to fit with 0-10 scale) - scaled_doses = [dose / 10 for dose in daily_doses] + # Batch plot pathology data + pathology_keys = self.pathology_manager.get_pathology_keys() + active_pathologies = [ + key + for key in pathology_keys + if self.toggle_vars[key].get() and key in df.columns + ] - # Calculate total dosage for this medicine across all days - total_medicine_dose = sum(daily_doses) - non_zero_doses = [d for d in daily_doses if d > 0] - avg_dose = total_medicine_dose / len(non_zero_doses) + for pathology_key in active_pathologies: + pathology = self.pathology_manager.get_pathology(pathology_key) + if pathology: + label = f"{pathology.display_name} ({pathology.scale_info})" + linestyle = ( + "dashed" if pathology.scale_orientation == "inverted" else "-" + ) + self._plot_series(df, pathology_key, label, "o", linestyle) + has_plotted_series = True - # Create more informative label + return has_plotted_series + + def _plot_medicine_data(self, df: pd.DataFrame) -> dict: + """Plot medicine data with optimizations.""" + result = {"has_plotted": False, "with_data": [], "without_data": []} + + # Get medicine colors and keys in batch + medicine_colors = self.medicine_manager.get_graph_colors() + medicines = self.medicine_manager.get_medicine_keys() + + # Pre-calculate daily doses for all medicines to avoid repeated computation + medicine_doses = {} + for medicine in medicines: + dose_column = f"{medicine}_doses" + if dose_column in df.columns: + daily_doses = [ + self._calculate_daily_dose(dose_str) for dose_str in df[dose_column] + ] + medicine_doses[medicine] = daily_doses + + # Plot medicines with data + for medicine in medicines: + if self.toggle_vars[medicine].get() and medicine in medicine_doses: + daily_doses = medicine_doses[medicine] + + # Check if there's any data to plot + if any(dose > 0 for dose in daily_doses): + result["with_data"].append(medicine) + + # Optimize dose scaling and bar plotting + scaled_doses = [dose / 10 for dose in daily_doses] + + # Calculate statistics more efficiently + non_zero_doses = [d for d in daily_doses if d > 0] + if non_zero_doses: + avg_dose = sum(daily_doses) / len(non_zero_doses) label = f"{medicine.capitalize()} (avg: {avg_dose:.1f}mg)" + # Single bar plot call self.ax.bar( df.index, scaled_doses, @@ -193,56 +234,59 @@ class GraphManager: width=0.6, bottom=-max(scaled_doses) * 1.1 if scaled_doses else -1, ) - has_plotted_series = True - else: - # Medicine is toggled on but has no dose data - if self.toggle_vars[medicine].get(): - medicines_without_data.append(medicine) + result["has_plotted"] = True + else: + # Medicine is toggled on but has no dose data + if self.toggle_vars[medicine].get(): + result["without_data"].append(medicine) - # Configure graph appearance - if has_plotted_series: - # Get current legend handles and labels - handles, labels = self.ax.get_legend_handles_labels() + return result - # Add information about medicines without data if any are toggled on - if medicines_without_data: - # Add a text note about medicines without dose data - med_list = ", ".join(medicines_without_data) - info_text = f"Tracked (no doses): {med_list}" - labels.append(info_text) - # Create a dummy handle for the info text (invisible) - from matplotlib.patches import Rectangle + def _configure_graph_appearance(self, medicine_data: dict) -> None: + """Configure graph appearance with optimizations.""" + # Get legend data in batch + handles, labels = self.ax.get_legend_handles_labels() - dummy_handle = Rectangle( - (0, 0), 1, 1, fc="w", fill=False, edgecolor="none", linewidth=0 - ) - handles.append(dummy_handle) + # Add information about medicines without data if any are toggled on + if medicine_data["without_data"]: + med_list = ", ".join(medicine_data["without_data"]) + info_text = f"Tracked (no doses): {med_list}" + labels.append(info_text) - # Create an expanded legend with better formatting - self.ax.legend( - handles, - labels, - loc="upper left", - bbox_to_anchor=(0, 1), - ncol=2, # Display in 2 columns for better space usage - fontsize="small", - frameon=True, - fancybox=True, - shadow=True, - framealpha=0.9, - ) - self.ax.set_title("Medication Effects Over Time") - self.ax.set_xlabel("Date") - self.ax.set_ylabel("Rating (0-10) / Dose (mg)") + # Create dummy handle more efficiently + from matplotlib.patches import Rectangle - # Adjust y-axis to accommodate medicine bars at bottom - current_ylim = self.ax.get_ylim() - self.ax.set_ylim(bottom=current_ylim[0], top=max(10, current_ylim[1])) + dummy_handle = Rectangle( + (0, 0), 1, 1, fc="w", fill=False, edgecolor="none", linewidth=0 + ) + handles.append(dummy_handle) - self.fig.autofmt_xdate() + # Create legend with optimized settings + if handles and labels: + self.ax.legend( + handles, + labels, + loc="upper left", + bbox_to_anchor=(0, 1), + ncol=2, + fontsize="small", + frameon=True, + fancybox=True, + shadow=True, + framealpha=0.9, + ) - # Redraw the canvas - self.canvas.draw() + # Set titles and labels + self.ax.set_title("Medication Effects Over Time") + self.ax.set_xlabel("Date") + self.ax.set_ylabel("Rating (0-10) / Dose (mg)") + + # Optimize y-axis configuration + current_ylim = self.ax.get_ylim() + self.ax.set_ylim(bottom=current_ylim[0], top=max(10, current_ylim[1])) + + # Optimize date formatting + self.fig.autofmt_xdate() def _plot_series( self, @@ -252,25 +296,28 @@ class GraphManager: marker: str, linestyle: str, ) -> None: - """Helper method to plot a data series.""" + """Helper method to plot a data series with optimizations.""" + # Use more efficient plotting parameters self.ax.plot( df.index, df[column], marker=marker, linestyle=linestyle, label=label, + markersize=4, # Smaller markers for better performance + linewidth=1.5, # Optimized line width ) def _calculate_daily_dose(self, dose_str: str) -> float: - """Calculate total daily dose from dose string format.""" + """Calculate total daily dose from dose string format with optimizations.""" if not dose_str or pd.isna(dose_str) or str(dose_str).lower() == "nan": return 0.0 total_dose = 0.0 - # Handle different separators and clean the string + # Optimize string processing dose_str = str(dose_str).replace("•", "").strip() - # Split by | or by spaces if no | present + # More efficient splitting and processing dose_entries = dose_str.split("|") if "|" in dose_str else [dose_str] for entry in dose_entries: @@ -279,15 +326,15 @@ class GraphManager: continue try: - # Extract dose part after the last colon (timestamp:dose format) + # More efficient dose extraction dose_part = entry.split(":")[-1] if ":" in entry else entry - # Extract numeric part from dose (e.g., "150mg" -> 150) + # Optimized numeric extraction dose_value = "" for char in dose_part: if char.isdigit() or char == ".": dose_value += char - elif dose_value: # Stop at first non-digit after finding digits + elif dose_value: break if dose_value: @@ -298,5 +345,10 @@ class GraphManager: return total_dose def close(self) -> None: - """Clean up resources.""" - plt.close(self.fig) + """Clean up resources with proper optimization.""" + try: + # Clear the plot before closing + self.ax.clear() + plt.close(self.fig) + except Exception: + pass # Ignore cleanup errors diff --git a/src/main.py b/src/main.py index bbadc7b..f6039d6 100644 --- a/src/main.py +++ b/src/main.py @@ -150,6 +150,12 @@ class MedTrackerApp: def _refresh_ui_after_config_change(self) -> None: """Refresh UI components after pathology or medicine configuration changes.""" + # Clear caches in optimized data manager + if hasattr(self.data_manager, "_invalidate_cache"): + self.data_manager._invalidate_cache() + self.data_manager._headers_cache = None + self.data_manager._dtype_cache = None + # Recreate the input frame with new pathologies and medicines self.input_frame.destroy() input_ui: dict[str, Any] = self.ui_manager.create_input_frame( @@ -412,9 +418,10 @@ class MedTrackerApp: """Load data from the CSV file into the table and graph.""" logger.debug("Loading data from CSV.") - # Clear existing data in the treeview - for i in self.tree.get_children(): - self.tree.delete(i) + # Clear existing data in the treeview efficiently + children = self.tree.get_children() + if children: + self.tree.delete(*children) # Load data from the CSV file df: pd.DataFrame = self.data_manager.load_data() @@ -422,7 +429,11 @@ class MedTrackerApp: # Update the treeview with the data if not df.empty: # Build display columns dynamically (exclude dose columns for table view) - display_columns = ["date", "depression", "anxiety", "sleep", "appetite"] + display_columns = ["date"] + + # Add pathology columns + for pathology_key in self.pathology_manager.get_pathology_keys(): + display_columns.append(pathology_key) # Add medicine columns (without dose columns) for medicine_key in self.medicine_manager.get_medicine_keys(): @@ -437,6 +448,7 @@ class MedTrackerApp: # Fallback - just use all columns display_df = df + # Batch insert for better performance for _index, row in display_df.iterrows(): self.tree.insert(parent="", index="end", values=list(row)) logger.debug(f"Loaded {len(display_df)} entries into treeview.")